From 968356f925661043b037c2d8acf85dabd7ce083c Mon Sep 17 00:00:00 2001 From: Rene Arumetsa Date: Mon, 10 Aug 2026 18:31:46 +0300 Subject: [PATCH 01/21] Fix money-safety bugs in economy (heist, blackjack, bail) Multi-agent audit of the money-moving economy modules surfaced three confirmed correctness bugs. All three are fixed here with regression tests. heist (core/economy/heist.py): - `house = await get_user(house.HOUSE_ID)` shadowed the imported `house` module for the whole function, so `house.HOUSE_ID` raised UnboundLocalError on every successful heist (win payout was entirely dead) and on the fail-path compensation branch. Rename the local to `house_rec`. - Un-shadowing exposed a latent mint: the pot was floored at 300 but the house was debited only min(total, balance), so a poor house paid out more than it lost. Cap the pot at the balance and debit exactly what is paid (house debit == sum of payouts). No mint, no leak. - Add the missing `_is_jailed` import (do_heist_check referenced it unimported). blackjack (commands/economy_games_commands.py): - Button callbacks had no reentrancy guard; discord.py dispatches each click as its own task, so double-clicking Stand within the dealer-reveal window paid out twice (mint), and double-clicking Double/Split deducted the extra bet twice. Add a synchronous `_busy` guard (matching the existing RpsGame idiom) on all four callbacks plus a `_resolved` idempotency flag on settlement, so a game can only pay out once. bail (core/economy/jail.py, commands/economy_extra_commands.py): - do_bail only checked balance, never jail state; a double-click or a stale BailView from a re-run /jailbreak charged bail twice, destroying coins (bail is a pure sink). Make do_bail a no-op when the user is not jailed, and add a UI reentrancy guard + "already free" message. Tests: 47 passed (4 new regression tests covering heist coin-conservation and bail idempotency). Co-Authored-By: Claude Opus 4.8 (1M context) --- commands/economy_extra_commands.py | 14 +++ commands/economy_games_commands.py | 147 ++++++++++++++++++----------- core/economy/heist.py | 24 +++-- core/economy/jail.py | 6 ++ strings.py | 1 + tests/test_money_safety_fixes.py | 91 ++++++++++++++++++ 6 files changed, 220 insertions(+), 63 deletions(-) create mode 100644 tests/test_money_safety_fixes.py diff --git a/commands/economy_extra_commands.py b/commands/economy_extra_commands.py index 3f0f4d9..6b8356f 100644 --- a/commands/economy_extra_commands.py +++ b/commands/economy_extra_commands.py @@ -396,12 +396,17 @@ def register_economy_extra_commands( def __init__(self, user_id: int): super().__init__(timeout=60) self.user_id = user_id + self._paying = False @discord.ui.button(label=S.JAILBREAK_UI["bail_btn"], style=discord.ButtonStyle.danger) async def pay_bail(self, interaction: discord.Interaction, _: discord.ui.Button): if interaction.user.id != self.user_id: await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) return + if self._paying or self.is_finished(): + await interaction.response.defer() + return + self._paying = True res = await economy.do_bail(self.user_id) self.clear_items() self.stop() @@ -414,6 +419,15 @@ def register_economy_extra_commands( ), color=0xED4245, ) + elif not res["ok"]: + # Already released (stale view or double-click) - no charge applied. + embed = discord.Embed( + title=S.TITLE["jailbreak_bail"], + description=S.JAILBREAK_UI["bail_already_free"].format( + balance=coin(res["balance"]), + ), + color=0x57F287, + ) else: embed = discord.Embed( title=S.TITLE["jailbreak_bail"], diff --git a/commands/economy_games_commands.py b/commands/economy_games_commands.py index 5952f65..e798420 100644 --- a/commands/economy_games_commands.py +++ b/commands/economy_games_commands.py @@ -792,6 +792,11 @@ def register_economy_games_commands( self._doubled_hands: set[int] = set() self._split_aces: bool = False self.message: discord.Message | None = None + # _busy: a callback is mid-flight (prevents overlapping button tasks, + # since discord.py dispatches each click as its own task). + # _resolved: the game has paid out (makes settlement idempotent). + self._busy = False + self._resolved = False self._refresh_buttons() @property @@ -864,6 +869,9 @@ def register_economy_games_commands( return discord.Embed(title=S.TITLE["blackjack"], description=desc, color=0x5865F2) async def _resolve_all(self, interaction: discord.Interaction) -> None: + if self._resolved: + return + self._resolved = True active_games.discard(self.user_id) self.clear_items() self.stop() @@ -949,80 +957,111 @@ def register_economy_games_commands( if interaction.user.id != self.user_id: await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) return - await interaction.response.defer() - self._cur_hand.append(self.deck.pop()) - val = _bj_value(self._cur_hand) - if val > 21: - await self.message.edit(embed=self._cur_embed(), view=None) - await asyncio.sleep(_BJ_DEAL_DELAY) - if len(self.hands) > 1: - await self._advance_or_finish(interaction) + if self._busy or self.is_finished(): + await interaction.response.defer() + return + self._busy = True + try: + await interaction.response.defer() + self._cur_hand.append(self.deck.pop()) + val = _bj_value(self._cur_hand) + if val > 21: + await self.message.edit(embed=self._cur_embed(), view=None) + await asyncio.sleep(_BJ_DEAL_DELAY) + if len(self.hands) > 1: + await self._advance_or_finish(interaction) + else: + await self._resolve_all(interaction) + elif val == 21: + await self.message.edit(embed=self._cur_embed(), view=None) + await asyncio.sleep(_BJ_DEAL_DELAY * 0.5) + if len(self.hands) > 1: + await self._advance_or_finish(interaction) + else: + await self._do_dealer_reveal(interaction) else: - await self._resolve_all(interaction) - elif val == 21: - await self.message.edit(embed=self._cur_embed(), view=None) - await asyncio.sleep(_BJ_DEAL_DELAY * 0.5) - if len(self.hands) > 1: - await self._advance_or_finish(interaction) - else: - await self._do_dealer_reveal(interaction) - else: - self._refresh_buttons() - await self.message.edit(embed=self._cur_embed(), view=self) + self._refresh_buttons() + await self.message.edit(embed=self._cur_embed(), view=self) + finally: + self._busy = False async def _stand(self, interaction: discord.Interaction) -> None: if interaction.user.id != self.user_id: await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) return - await interaction.response.defer() - if len(self.hands) > 1: - await self._advance_or_finish(interaction) - else: - await self._do_dealer_reveal(interaction) + if self._busy or self.is_finished(): + await interaction.response.defer() + return + self._busy = True + try: + await interaction.response.defer() + if len(self.hands) > 1: + await self._advance_or_finish(interaction) + else: + await self._do_dealer_reveal(interaction) + finally: + self._busy = False async def _double(self, interaction: discord.Interaction) -> None: if interaction.user.id != self.user_id: await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) return - res = await economy.do_blackjack_bet(self.user_id, self.bet) - if not res["ok"]: - await interaction.response.send_message( - S.ERR["broke"].format(bal=coin(res.get("balance", 0))), ephemeral=True - ) + if self._busy or self.is_finished(): + await interaction.response.defer() return - await interaction.response.defer() - self._doubled_hands.add(0) - self.bets[0] *= 2 - self._cur_hand.append(self.deck.pop()) - await self.message.edit(embed=self._cur_embed(), view=None) - await asyncio.sleep(_BJ_DEAL_DELAY) - await self._do_dealer_reveal(interaction) + self._busy = True + try: + res = await economy.do_blackjack_bet(self.user_id, self.bet) + if not res["ok"]: + await interaction.response.send_message( + S.ERR["broke"].format(bal=coin(res.get("balance", 0))), ephemeral=True + ) + return + await interaction.response.defer() + self._doubled_hands.add(0) + self.bets[0] *= 2 + self._cur_hand.append(self.deck.pop()) + await self.message.edit(embed=self._cur_embed(), view=None) + await asyncio.sleep(_BJ_DEAL_DELAY) + await self._do_dealer_reveal(interaction) + finally: + self._busy = False async def _split_hand(self, interaction: discord.Interaction) -> None: if interaction.user.id != self.user_id: await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) return - res = await economy.do_blackjack_bet(self.user_id, self.bet) - if not res["ok"]: - await interaction.response.send_message( - S.ERR["broke"].format(bal=coin(res.get("balance", 0))), ephemeral=True - ) + if self._busy or self.is_finished(): + await interaction.response.defer() return - await interaction.response.defer() - card1, card2 = self._cur_hand[0], self._cur_hand[1] - self._split_aces = card1[0] == "A" - self.hands = [[card1, self.deck.pop()], [card2, self.deck.pop()]] - self.bets = [self.bet, self.bet] - self.hand_idx = 0 - await self.message.edit(embed=self._cur_embed(), view=None) - await asyncio.sleep(_BJ_DEAL_DELAY) - if self._split_aces: - await self._do_dealer_reveal(interaction) - else: - self._refresh_buttons() - await self.message.edit(embed=self._cur_embed(), view=self) + self._busy = True + try: + res = await economy.do_blackjack_bet(self.user_id, self.bet) + if not res["ok"]: + await interaction.response.send_message( + S.ERR["broke"].format(bal=coin(res.get("balance", 0))), ephemeral=True + ) + return + await interaction.response.defer() + card1, card2 = self._cur_hand[0], self._cur_hand[1] + self._split_aces = card1[0] == "A" + self.hands = [[card1, self.deck.pop()], [card2, self.deck.pop()]] + self.bets = [self.bet, self.bet] + self.hand_idx = 0 + await self.message.edit(embed=self._cur_embed(), view=None) + await asyncio.sleep(_BJ_DEAL_DELAY) + if self._split_aces: + await self._do_dealer_reveal(interaction) + else: + self._refresh_buttons() + await self.message.edit(embed=self._cur_embed(), view=self) + finally: + self._busy = False async def on_timeout(self) -> None: + if self._resolved: + return + self._resolved = True active_games.discard(self.user_id) try: await economy.do_blackjack_payout(self.user_id, 0, sum(self.bets)) diff --git a/core/economy/heist.py b/core/economy/heist.py index 0bf498e..ff0f75e 100644 --- a/core/economy/heist.py +++ b/core/economy/heist.py @@ -7,7 +7,7 @@ import random from .. import pb_client from ..pb_client import DatabaseError from . import house -from .store import HEIST_JAIL, _commit, _now, _txn, _user_lock, get_user +from .store import HEIST_JAIL, _commit, _is_jailed, _now, _txn, _user_lock, get_user from .house import _credit_house, _refund_house_safe, _refund_user_safe @@ -39,19 +39,25 @@ async def do_heist_resolve(user_ids: list[int], success: bool) -> dict: failed_users: list[int] = [] if success and house.HOUSE_ID is not None: + # NB: use a distinct local name - assigning to `house` here would shadow + # the imported module for the whole function and break `house.HOUSE_ID`. try: - house = await get_user(house.HOUSE_ID) + house_rec = await get_user(house.HOUSE_ID) pct = random.uniform(0.20, 0.55) - total = max(300, int(house["balance"] * pct)) - payout_each = total // len(user_ids) - # Atomic decrement (capped at the balance we read) instead of a full - # record commit, so concurrent _credit_house increments aren't lost. - debit = min(total, house["balance"]) + # Never promise more than the house actually holds: the desired pot + # is capped at the current balance, and each share is floored, so the + # amount debited equals the amount paid out (no minting, no leak). + pot = min(max(300, int(house_rec["balance"] * pct)), house_rec["balance"]) + pot = max(0, pot) + payout_each = pot // len(user_ids) + debit = payout_each * len(user_ids) + # Atomic decrement instead of a full record commit, so concurrent + # _credit_house increments aren't lost. if debit > 0: - await pb_client.update_record(house["_pb_id"], {"balance-": debit}) # type: ignore[typeddict-item] + await pb_client.update_record(house_rec["_pb_id"], {"balance-": debit}) # type: ignore[typeddict-item] except DatabaseError: return {"ok": False, "reason": "db_error"} - _txn("HEIST_HOUSE", change=f"-{debit}", house_bal=house["balance"] - debit) + _txn("HEIST_HOUSE", change=f"-{debit}", house_bal=house_rec["balance"] - debit) for uid in user_ids: async with _user_lock(uid): diff --git a/core/economy/jail.py b/core/economy/jail.py index 84ce0c0..0819d7e 100644 --- a/core/economy/jail.py +++ b/core/economy/jail.py @@ -50,6 +50,12 @@ async def do_bail(user_id: int) -> dict: """Charge bail fine after exhausting jailbreak rolls and free the user. Fine = 20-30% of current balance, floored at 350. If balance < 350, stay jailed.""" user = await get_user(user_id) + # Idempotency guard: only an actively-jailed user can be charged bail. The + # first successful call clears jailed_until, so a rapid second click or a + # stale BailView from a re-run /jailbreak becomes a no-op instead of a + # second fine (bail is a pure sink - a double charge destroys coins). + if not _is_jailed(user): + return {"ok": False, "reason": "not_jailed", "balance": user["balance"]} if user["balance"] < MIN_BAIL: return {"ok": False, "reason": "broke", "balance": user["balance"]} pct = random.uniform(0.20, 0.30) diff --git a/strings.py b/strings.py index 9033543..5ef005e 100644 --- a/strings.py +++ b/strings.py @@ -1167,6 +1167,7 @@ JAILBREAK_UI: dict[str, str] = { "bail_btn": "💾 Maksa kautsjon", "bail_broke_desc": "❌ Sul pole piisavalt raha (min {min}).\nJÀÀd vanglasse kuni aja lĂ”puni! Saldo: {balance}", "bail_paid_desc": "✅ Kautsjon makstud: **{fine}**\nOled vaba! Saldo: {balance}", + "bail_already_free": "✅ Oled juba vaba - kautsjonit ei vĂ”etud. Saldo: {balance}", "fail_broke_desc": "{d1} {d2} - ei olnud duubel\n\n❌ Sul pole kautsjoni maksmiseks piisavalt raha (min 350 ⏥).\nJÀÀd vanglasse kuni aja lĂ”puni! Saldo: {balance}", "fail_bail_desc": "{d1} {d2} - ei olnud duubel\n\nKautsjon makstud: **{fine}**\nSaldo: {balance}", "fail_bail_offer": "{d1} {d2} - ei olnud duubel\n\n💰 **Kautsjon: {min} - {max}** (20-30% saldost)\nSaldo: {bal}\n\nSaad maksta kautsjoni vĂ”i jÀÀda vanglasse kuni aja lĂ”puni.", diff --git a/tests/test_money_safety_fixes.py b/tests/test_money_safety_fixes.py new file mode 100644 index 0000000..39ccdfd --- /dev/null +++ b/tests/test_money_safety_fixes.py @@ -0,0 +1,91 @@ +"""Regression tests for the money-safety audit fixes. + +Covers the two pure-logic fixes: +- heist payout conserves coins (no minting when the house is poor) and the win + path no longer crashes on the shadowed `house` local. +- do_bail is idempotent: a jailed user can only be charged once per sentence, so + a double-click or a stale BailView cannot destroy coins with a second fine. +""" + +from datetime import datetime, timedelta, timezone + +from core import economy + +from conftest import run + +UID = 111 +OTHER = 222 +HOUSE = 999 + + +def _fixed_now(monkeypatch, dt: datetime): + monkeypatch.setattr(economy.store, "_clock", lambda: dt) + return dt + + +class TestHeistConservation: + def _setup_house(self, fake_pb, monkeypatch, balance: int): + monkeypatch.setattr(economy.house, "HOUSE_ID", HOUSE) + monkeypatch.setattr(economy.house, "_house_pb_id", None) + run(economy.get_user(HOUSE)) + fake_pb.record_for(HOUSE)["balance"] = balance + + def test_poor_house_win_does_not_mint(self, fake_pb, monkeypatch): + # Poor house is the case the pre-fix code minted from: the desired pot + # (floored at 300) exceeded the balance, so payouts outran the debit. + self._setup_house(fake_pb, monkeypatch, balance=100) + run(economy.get_user(UID)) + run(economy.get_user(OTHER)) + + house_before = fake_pb.record_for(HOUSE)["balance"] + res = run(economy.do_heist_resolve([UID, OTHER], True)) # must not raise + assert res["ok"] and res["success"] + + house_after = fake_pb.record_for(HOUSE)["balance"] + gains = fake_pb.record_for(UID)["balance"] + fake_pb.record_for(OTHER)["balance"] + # Coin conservation: the house loses exactly what the players gain. + assert house_before - house_after == gains + assert house_after >= 0 + assert res["payout_each"] * 2 == gains + + def test_rich_house_win_pays_out_and_conserves(self, fake_pb, monkeypatch): + self._setup_house(fake_pb, monkeypatch, balance=100_000) + run(economy.get_user(UID)) + run(economy.get_user(OTHER)) + + house_before = fake_pb.record_for(HOUSE)["balance"] + res = run(economy.do_heist_resolve([UID, OTHER], True)) + assert res["ok"] and res["payout_each"] > 0 + + house_after = fake_pb.record_for(HOUSE)["balance"] + gains = fake_pb.record_for(UID)["balance"] + fake_pb.record_for(OTHER)["balance"] + assert house_before - house_after == gains + + +class TestBailIdempotency: + def _jail(self, fake_pb, now, balance: int): + run(economy.get_user(UID)) + rec = fake_pb.record_for(UID) + rec["balance"] = balance + rec["jailed_until"] = (now + timedelta(minutes=30)).isoformat() + + def test_second_bail_when_free_is_noop(self, fake_pb, monkeypatch): + now = _fixed_now(monkeypatch, datetime(2026, 7, 25, 12, tzinfo=timezone.utc)) + self._jail(fake_pb, now, balance=1000) + + first = run(economy.do_bail(UID)) + assert first["ok"] + bal_after_first = fake_pb.record_for(UID)["balance"] + assert bal_after_first < 1000 # a fine was charged + assert fake_pb.record_for(UID)["jailed_until"] is None # freed + + # A double-click / stale view fires do_bail again while already free. + second = run(economy.do_bail(UID)) + assert not second["ok"] and second["reason"] == "not_jailed" + assert fake_pb.record_for(UID)["balance"] == bal_after_first # no second charge + + def test_bail_charges_once_for_a_real_sentence(self, fake_pb, monkeypatch): + now = _fixed_now(monkeypatch, datetime(2026, 7, 25, 12, tzinfo=timezone.utc)) + self._jail(fake_pb, now, balance=1000) + res = run(economy.do_bail(UID)) + assert res["ok"] and res["fine"] >= economy.MIN_BAIL From 3b35f82d80b72c93511d5063db1bf4479e6c932e Mon Sep 17 00:00:00 2001 From: Rene Arumetsa Date: Mon, 10 Aug 2026 18:49:25 +0300 Subject: [PATCH 02/21] Fix RequestView over-funding reentrancy (do_give double-transfer) A follow-up reentrancy sweep of the five remaining money-moving views found one more instance of the same bug class. The other four (quests, prestige, fish, shop) verified clean - their underlying do_* functions are idempotent. RequestView / FundModal (commands/economy_support_commands.py): - on_submit read self._view.remaining, then awaited do_give, then decremented remaining. Because discord.py dispatches each modal submit as its own task and do_give is a plain non-idempotent transfer, a funder could open two modals and submit both before the first resolved: both read the same pre-decrement remaining, both passed the range check, and both transferred `amount` - over-funding the request (remaining goes negative) and moving up to the funder's whole balance. - Fix: reserve the amount synchronously (decrement remaining BEFORE the do_give await, with no await in between - atomic under asyncio), and roll the reservation back if the transfer fails. The second concurrent submit now sees the reduced remaining and is rejected. Added a cheap _fund guard (remaining<=0 / is_finished) so a click on a funded request doesn't open a dead modal. Co-Authored-By: Claude Opus 4.8 (1M context) --- commands/economy_support_commands.py | 11 ++++++++++- strings.py | 1 + 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/commands/economy_support_commands.py b/commands/economy_support_commands.py index 4d4c25d..5d11e56 100644 --- a/commands/economy_support_commands.py +++ b/commands/economy_support_commands.py @@ -38,15 +38,21 @@ def register_economy_support_commands( ) return + # Reserve the amount synchronously (no await between the range check + # and the decrement) so two concurrent modal submits can't both fund + # the same request. discord.py runs each submit as its own task and + # do_give is not idempotent, so without this reservation a funder + # could double-submit and transfer `amount` more than once. + self._view.remaining -= amount res = await economy.do_give(interaction.user.id, self._view.requester.id, amount) if not res["ok"]: + self._view.remaining += amount # roll back the reservation data = await economy.get_user(interaction.user.id) await interaction.response.send_message( S.ERR["broke"].format(bal=coin(data["balance"])), ephemeral=True ) return - self._view.remaining -= amount funded_line = S.REQUEST_UI["funded_line"].format( name=interaction.user.display_name, amount=coin(amount), @@ -84,6 +90,9 @@ def register_economy_support_commands( self.add_item(self.fund_btn) async def _fund(self, interaction: discord.Interaction): + if self.remaining <= 0 or self.is_finished(): + await interaction.response.send_message(S.ERR["request_closed"], ephemeral=True) + return if interaction.user.id == self.requester.id: await interaction.response.send_message(S.ERR["request_self_fund"], ephemeral=True) return diff --git a/strings.py b/strings.py index 5ef005e..797e450 100644 --- a/strings.py +++ b/strings.py @@ -674,6 +674,7 @@ ERR: dict[str, str] = { "request_self": "❌ Sa ei saa iseendalt anuda.", "request_bot": "❌ Botid on teatavasti kitsid.", "request_targeted": "❌ See taotlus on suunatud kasutajale **{name}**.", + "request_closed": "❌ See taotlus on juba rahastatud vĂ”i aegunud.", "already_in_game": "❌ Sul on juba aktiivne mĂ€ng kĂ€imas!", "invalid_amount": "❌ Sisesta kehtiv summa vĂ”i 'all'.", "fund_range": "❌ Sisesta summa vahemikus 1-{max}.", From 738144ccfa204ad22710a947310bda8adf203b7b Mon Sep 17 00:00:00 2001 From: Rene Arumetsa Date: Wed, 19 Aug 2026 19:12:21 +0300 Subject: [PATCH 03/21] Refacotr strings.py into modules --- strings.py | 1416 ---------------------------------------------------- 1 file changed, 1416 deletions(-) delete mode 100644 strings.py diff --git a/strings.py b/strings.py deleted file mode 100644 index 797e450..0000000 --- a/strings.py +++ /dev/null @@ -1,1416 +0,0 @@ -"""All user-facing text for TipiLAN Bot. - -Edit this file to change any message, description, or flavour text -without touching any logic code. -""" - -from core.emoji import EMOJI as E - -# --------------------------------------------------------------------------- -# Flavour text -# --------------------------------------------------------------------------- - -WORK_JOBS: list[tuple[str, float]] = [ - ("paigaldasid aulas netikaableid", 1.0), - ("paigaldasid aulas voolukaableid", 1.0), - ("tĂ”id poest korraldajatele pitsat", 1.0), - ("konfigureerisid uue switchi", 1.0), - ("vedasid laudu aulasse", 1.0), - ("vedasid toole aulasse", 1.0), - ("paigaldasid aulasse vahekardinaid", 1.0), - ("aitasid ĂŒles seada turniiri lava", 1.0), - ("töötasid baaris", 1.0), - ("testisid helisĂŒsteemi \"Erootikapoodi\" blastides", 1.0), - ("juhendasid kĂŒlastajaid infolauas", 1.0), - ("seadistasid LAN serverit", 1.0), - ("paigaldasid monitore", 1.0), - ("ĂŒhendasid aulas kilpe", 1.0), - ("aitasid LANil osalejat", 1.0), - ("mĂŒĂŒsid energiajooke", 1.0), - ("tegid netikaableid", 1.0), - ("lahendasid IP-konflikti", 1.0), - ("parandasid kellegi arvutit", 1.0), - ("tegid korda striimi heli (mitte nagu PGL)", 1.2), - ("kirjutasid koodi miniturniiri jaoks", 1.2), - ("vĂ”itsid turniiri", 1.5), -] - -BEG_LINES: list[str] = [ - "istusid kurva nĂ€oga oma monitori taga", - "palusid abi kellegi DMis", - "postitasid #abi kanalisse", - "vaatasid kadedusega teiste asju", - "kirjutasid /beg kolmandat korda jĂ€rjest", - "kĂŒsisid oma tiimiliikmelt laenu", - "vĂ”tsid kiirlaenu", - "mĂŒĂŒsid 3 sendi eest oma CSi skinne", - "korjasid kampuselt taarat", - "tegid TikToki live'i", - "refreshisid pangaĂ€ppi lootuses, et raha tekib", - "kirjutasid \"pls donate\" chatti", - "vaatasid oma tĂŒhja rahakotti", - "pĂŒĂŒdsid kedagi guilt-trippida", - "lubasid exposure'it raha eest", - "mĂ€ngisid kurba muusikat taustaks", - "teesklesid, et see on sotsiaalne eksperiment", - "kirjutasid emotsionaalse loo oma olukorrast", - "pakkusid vastu future equity't", - "tegite sĂ”braga koos kerjamisplaani", - "otsisid diivani vahelt sente", - "lubasid boostida kellegi ranki viieka eest", - "kĂŒsisid chatist, kas keegi teab kust tasuta V-Buckse saab", - "avastasid, et su krĂŒptoportfell on sĂŒgavas miinuses", -] - -BEG_JAIL_LINES: list[str] = [ - "karjusid lĂ€bi trellide, et keegi mĂŒnte lĂ€bi lĂŒkkaks", - "kirjutasid kongi seina peale oma PayPali aadressi", - "lubasid, et maksad jĂ€rgmine kord vangi minnes tagasi", - "veensid valvurit, et oled tegelikult hea inimene", - "saatsid vanglast kĂ€sitsi kirjutatud kirja oma meeskonnale", - "pakkusid vahile tulevaste vihjete eest raha", - "koputasid morset vastu naaberkongi seina", - "ĂŒritasid vangla WiFit hĂ€kkida", - "lubasid teha tasuta IT-tööd vabanemise eest", - "ĂŒritasid lusikaga tunnelit kaevata", - "kirjutasid bug reporti vangla sĂŒsteemile", - "proovisid trellidest lĂ€bi pugeda, aga jĂ€id kinni", - "pakkusid vangivalvurile oma Steami kontot vabanemise eest", -] - -CRIME_WIN: list[str] = [ - "hĂ€kkisid turniiri tulemuste tabelit", - "varastasid oma vastase hiire", - "varastasid oma vastase klaviatuuri", - "varastasid oma vastase kĂ”rvaklapid", - "varastasid oma vastase monitori", - "varastasid oma vastase mikrofoni", - "mĂŒĂŒsid vĂ”ltsitud LANi pileteid", - "laenatasid kellegi GPUd ja ei tagastanud seda", - "laenatasid Filmiklubilt AUX-kaablit ja ei tagastanud seda", - "kĂ€ivitasid DDoS rĂŒnnaku serverile", - "tegid oma shitcoin'idega pump and dump'i", - "kasutasid aimbotti ja keegi ei mĂ€rganud", - "exploitisid mĂ€ngu bugi enda kasuks", - "phishisid kellegi Steami konto", - "varastasid prize pooli raha", - "ĂŒhendasid enda krĂŒptokaevandaja serverisse", - "tegid petukĂ”nesid" -] - -CRIME_LOSE: list[str] = [ - "jĂ€id turniiri adminile vahele", - "tĂ”mbasid oma arvuti kaabli pistikust vĂ€lja", - "kukutasid lahtise Red Bulli omale sĂŒlle", - "unustasid VPNi sisse lĂŒlitada", - "lagisid kriitilisel momendil", - "said banni otseĂŒlekande ajal", - "unustasid logid kustutada", - "sinu skript crashis valel hetkel", - "said reportitud mitme mĂ€ngija poolt", - "sinu krĂŒptokaevandaja sĂŒttis pĂ”lema", - "flashisid tervet oma tiimi", - "avastasid, et Windows otsustas keset mĂ€ngu uuendama hakata", - "jĂ€id politseile petukĂ”nedega vahele" -] - -# --------------------------------------------------------------------------- -# Slash command descriptions (shown in Discord's autocomplete) -# --------------------------------------------------------------------------- - -CMD: dict[str, str] = { - "ping": "Vaata, kas bot on ĂŒleval", - "help": "Nimekiri kĂ”ikidest kĂ€skudest kategooriate kaupa", - "status": "NĂ€ita boti staatust ja ressursside kasutust", - "birthdays": "NĂ€ita kĂ”igi liikmete sĂŒnnipĂ€evi kuu jĂ€rgi", - "check": "Laadi andmed, tĂ€ida ID'd ja sĂŒnkroniseeri kĂ”ik liikmed", - "sync": "SĂŒnkroniseeri kĂ€sklused Discordi serveriga", - "member": "NĂ€ita liikme andmeid tabelist", - "restart": "Tee taaskĂ€ivitus botile", - "shutdown": "LĂŒlita bot vĂ€lja (ilma taaskĂ€ivituseta)", - "pause": "Peata / jĂ€tka kĂ”ik kĂ€sklused (hooldusreĆŸiim)", - "send": "Saada sĂ”num valitud kanalisse", - "profile": "Vaata oma profiili: saldo, tase, esemed, statistika ja kalakogu", - "balance": "Vaata enda (vĂ”i kellegi teise) TipiCOINide saldot", - "daily": "VĂ”ta enda pĂ€evane TipiCOINi boonus", - "work": "Tööta ja teeni TipiCOINe (1h ooteaeg)", - "beg": "Kerja TipiCOINe (5min ooteaeg)", - "crime": "Proovi oma Ă”nne kriminaalse tegevusega (2h ooteaeg)", - "rob": "Proovi kelleltki TipiCOINe varastada", - "heist": "Alusta grupirööv pangahoidlasse (min 2 mĂ€ngijat, max 8, 4h serveri ooteaeg)", - "roulette": "Panusta TipiCOINe punasele, mustale vĂ”i rohelisele (1/37 vĂ”imalus, 14x payout)", - "give": "Anna TipiCOINe teisele mĂ€ngijale", - "leaderboard": "TipiBOTi edetabel - kes on kĂ”ige rikkam?", - "shop": "Sirvi TipiBOTi poodi", - "buy": "Osta ese TipiBOTi poodist", - "rps": "Kivi-paber-kÀÀrid mĂ”ne teise mĂ€ngija vastu", - "slots": "Proovi oma Ă”nne TipiBOTi mĂ€nguautomaadiga", - "request": "Kerja TipiCOINe teistelt mĂ€ngijatelt - nagu crowdfunding, aga halvem", - "reminders": "Halda DM meeldetuletusi - kĂ”ik on vaikimisi sees, lĂŒlita need siin vĂ€lja", - "cooldowns": "Vaata kĂ”ikide kĂ€skude ooteaegu", - "jailed": "Vaata, kes on praegu vanglas", - "rank": "Vaata oma EXP, taset ja edetabeli positsiooni", - "stats": "Vaata oma mĂ€ngustatistikat", - "jailbreak": "Proovi vanglast pĂ”geneda kasutades tĂ€ringuid", - "adminseason": "[Admin] LĂ”peta vĂ”istlus, teavita vĂ”itjaid ja lĂ€htesta EXP", - "admincoins": "[Admin] Anna vĂ”i vĂ”ta TipiCOINe kasutajale/kasutajalt", - "adminjail": "[Admin] Saada kasutaja vangi", - "adminunjail": "[Admin] Vabasta kasutaja vangist", - "adminban": "[Admin] Keela kasutajal majandussĂŒsteemis osaleda", - "adminunban": "[Admin] Eemalda majandussĂŒsteemi keeld kasutajalt", - "adminreset": "[Admin] LĂ€htesta kasutaja majandusandmed", - "adminview": "[Admin] Vaata kasutaja majandusandmeid", - "adminexp": "[Admin] Anna v\u00f5i v\u00f5ta EXP kasutajalt", - "adminitem": "[Admin] Anna v\u00f5i eemalda ese kasutajalt (tasuta)", - "allowchannel": "[Admin] Lisa kanal, kus bot vĂ”ib vastata kĂ€sklustele", - "denychannel": "[Admin] Eemalda kanal lubatud kanalite nimekirjast", - "channels": "[Admin] NĂ€ita lubatud kanalite nimekirja", - "economysetup": "[Admin] Loo ja sea korda majandussĂŒsteemi rollid", - "blackjack": "MĂ€ngi blackjacki TipiBOTi vastu", - "prestige": "PrestiiĆŸi (nĂ”uab taset 30) ja teeni PrestiiĆŸipunkte", - "prestigeshop": "Vaata prestiiĆŸipoodi ja sinu uuenduste taset", - "prestigebuy": "Osta prestiiĆŸiuuendus PrestiiĆŸipunktide eest", - "fish": "Mine kalastama (interaktiivne mĂ€ng, 2min ooteaeg)", - "fishbook": "Vaata oma kalakogu ja kogutud kalaliike", - "fishsell": "MĂŒĂŒ kalu oma inventarist", - "patchnotes": "Vaata TipiBOTi viimaseid muudatusi ja uuendusi", - "quests": "Vaata oma pĂ€eva- ja nĂ€dalaĂŒlesandeid ning nĂ”ua auhinnad", -} - -# --------------------------------------------------------------------------- -# Option descriptions (shown next to each parameter in Discord) -# --------------------------------------------------------------------------- - -OPT: dict[str, str] = { - "admin_kasutaja": "Kasutaja", - "admin_pĂ”hjus": "PĂ”hjus (saadetakse kasutajale DM kaudu)", - "admincoins_kogus": "Positiivne = anna, negatiivne = vĂ”ta", - "adminjail_minutid": "Vangistamise kestus minutites", - "balance_kasutaja": "MĂ€ngija, kelle saldot vaadata (vaikimisi sina)", - "roulette_panus": "Panus TipiCOINides ('all' = kogu saldo)", - "roulette_vĂ€rv": "Punane, must vĂ”i roheline", - "give_kasutaja": "Kellele annad?", - "give_summa": "Kui palju annad? ('all' = kogu saldo)", - "buy_ese": "Eseme nimi (vaata /shop)", - "rps_panus": "Valikuline TipiCOINide panus ('all' = kogu saldo)", - "rps_vastane": "VĂ€ljakutse teisele mĂ€ngijale (PvP)", - "slots_panus": "Panus TipiCOINides ('all' = kogu saldo)", - "request_summa": "Kui palju TipiCOINe vajad?", - "request_pĂ”hjus": "Miks sa vajad TipiCOINe?", - "request_sihtmĂ€rk": "Valikuline: kellelt tĂ€pselt palud (teised ei saa toetada)", - "send_kanal": "Kanal, kuhu sĂ”num saata", - "send_sĂ”num": "SĂ”num, mida saata", - "allowchannel_kanal": "Kanal, kus bot vĂ”ib vastata kĂ€sklustele", - "denychannel_kanal": "Kanal, kus bot ei vĂ”i vastata kĂ€sklustele", - "rob_sihtmĂ€rk": "Kellelt röövid?", - "member_user": "MĂ€ngija, kelle infot tahad vaadata", - "rank_kasutaja": "MĂ€ngija, kelle taset tahad vaadata (vaikimisi sina)", - "stats_kasutaja": "MĂ€ngija, kelle statistikat tahad vaadata (vaikimisi sina)", - "adminseason_top_n": "Kui palju mĂ€ngijaid vĂ”itis (vaikimisi 10)", - "blackjack_panus": "Panus TipiCOINides ('all' = kogu saldo)", - "prestigebuy_upgrade": "Uuenduse ID (vaata /prestigeshop)", - "fishbook_kasutaja": "MĂ€ngija, kelle kalakogu vaadata (vaikimisi sina)", - "profile_kasutaja": "MĂ€ngija, kelle profiili vaadata (vaikimisi sina)", - "adminexp_kogus": "Positiivne = anna, negatiivne = vĂ”ta", - "adminitem_ese": "Eseme ID (kasutatav sisse, vaata /shop)", - "adminitem_tegevus": "'anna' vĂ”i 'eemalda'", -} - -# --------------------------------------------------------------------------- -# /help categories -# --------------------------------------------------------------------------- - -HELP_CATEGORIES: dict[str, dict] = { - "ĂŒldine": { - "label": "đŸ€– Üldine", - "description": "Üldised kĂ€sklused", - "color": 0x57F287, - "fields": [ - ("/ping", "Vaata, kas bot on ĂŒleval"), - ("/status", "NĂ€ita boti staatust ja ressursside kasutust"), - ("/help", "Nimekiri kĂ”ikidest kĂ€skudest kategooriate kaupa"), - ("/birthdays", "NĂ€ita kĂ”igi liikmete sĂŒnnipĂ€evi kuu jĂ€rgi"), - ], - }, - "tipibot": { - "label": "đŸȘ™ TipiBOT", - "description": "TipiCOIN majandus", - "color": 0xF4C430, - "fields": [ - ("/profile [@user]", "Saldo, tase, EXP progress, prestiiz - kĂ”ik ĂŒhes kohas. Nupud: Esemed · Statistika · Kalakogu."), - ("/cooldowns", "Vaata kĂ”ikide kĂ€skude ooteaegu. NĂ€itab ka vangla ooteaega."), - ("/daily", "VĂ”ta enda pĂ€evane TipiCOINide boonus. 20h ooteaeg. Streak'i boonus: 3d=+50%, 7d=+100%, 14d=+200%."), - ("/work", "Tööta ja teeni TipiCOINe (1h ooteaeg)"), - ("/beg", "Kerja TipiCOINe (5min ooteaeg)"), - ("/crime", "Proovi oma Ă”nne kriminaalse tegevusega. 60% edu, 40% trahv + 30min vanglas. 2h ooteaeg."), - ("/rob @user", "Proovi kelleltki TipiCOINe varastada. 45% edu. EbaĂ”nnestumisel saad trahvi."), - ("/heist", "Alusta grupiröövi pangahoidlasse. Min 2 mĂ€ngijat, max 8. 5 min ĂŒhinemisaeg. Õnnestumisel jagatakse saak vĂ”rdselt - ebaĂ”nnestumisel 1h 30min vangis + trahv. 4h serveri ooteaeg (ei ole isiklik)."), - ("/jailbreak", "Proovi vanglas olles tĂ€ringuid visata, et duublit saada (3 katset). Duubli korral saad vabaks. EbaĂ”nnestumisel saad valida: maksa kautsjon (20-30% saldost, min 350 ⏥) vĂ”i jÀÀ vanglasse kuni aja lĂ”puni."), - ("/give @user ", "Anna TipiCOINe teisele mĂ€ngijale"), - ("/quests", "Vaata oma pĂ€eva- ja nĂ€dalaĂŒlesandeid ning nĂ”ua auhinnad (uueneb iga pĂ€ev/nĂ€dal)."), - ("/leaderboard", "TipiBOTi edetabel - kes on kĂ”ige rikkam?"), - ("/shop", "Sirvi TipiBOTi poodi"), - ("/buy ", "Osta ese TipiBOTi poodist"), - ("/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."), - ], - }, - "shop": { - "label": "đŸ›ïž Pood", - "description": "TipiBOTi poe esemed ja nende efektid", - "color": 0xF4C430, - "fields": [ - (f"{E['TipiHIIR']} MĂ€ngurihiir - 500 ⏥", "Teeni töötades 50% rohkem TipiCOINe."), - (f"{E['TipiMATT']} XL hiirematt - 600 ⏥", "Kerjamise ooteaeg 5min → 3min."), - (f"{E['TipiKLAPID']} KĂ”rvaklapid - 1200 ⏥", "PĂ€evase boonuse ooteaeg 20h → 18h."), - (f"{E['TipiPILET']} LAN pilet (2025) - 1200 ⏥", "PĂ€evane boonus on duubeldatud."), - (f"{E['TipiVAC']} Anticheat - 750 ⏥", "Röövimine sinu vastu ebaĂ”nnestub. PĂ€rast 2 kasutust pead ostma uue."), - (f"{E['TipiBULL']} Red Bull - 800 ⏥", "30% tĂ”enĂ€osus, et teenid töötades 3x rohkem."), - (f"{E['TipiLAP']} Botikoobas - 1500 ⏥", "RTX 5090 jooksutab botte 24/7. PĂ€evane boonus genereerib 5% intressi sinu saldo pealt."), - (f"{E['TipiLAUD']} Reguleeritav laud - 3500 ⏥ *(T2)*", "/work teenib 25% rohkem (stackib mĂ€ngurihiirega)."), - (f"{E['TipiSERVER']} Jellyfin server - 4000 ⏥ *(T2)*", "Röövimise edu tĂ”enĂ€osus 45% → 60%."), - (f"{E['TipiMIC']} Mikrofon - 2800 ⏥ *(T2)*", "Teeni 30% rohkem eduka /crime puhul."), - (f"{E['TipiKLAVA']} Mehhaaniline klaviatuur - 1800 ⏥ *(T2)*", "/beg teenib 2x rohkem."), - (f"{E['TipiMONITOR']} Ultralai monitor - 2500 ⏥ *(T2)*", "/work ooteaeg: 1h → 40min."), - (f"{E['TipiCAT']} CAT6 netikaabel - 3500 ⏥ *(T2)*", "/crime edu tĂ”enĂ€osus tĂ”useb 60% → 75%."), - (f"{E['TipiMONITOR2']} 360hz monitor - 7500 ⏥ *(T3)*", "MĂ€nguautomaadi jackpot 10x → 15x, kolmik 4x → 6x."), - (f"{E['TipiKARIKAS']} TipiLANi trofee - 6000 ⏥ *(T3)*", "Streak ei nulli, kui sa mĂ”ne pĂ€eva vahele jĂ€tad."), - (f"{E['TipiTOOL']} MĂ€nguritool - 9000 ⏥ *(T3)*", "/crime ebaĂ”nnestumine ei saada sind vanglasse."), - ], - }, - "games": { - "label": "🎼 MĂ€ngud", - "description": "LĂ”busad mĂ€ngud", - "color": 0x5865F2, - "fields": [ - ("/roulette ", "Panusta TipiCOINe punasele, mustale vĂ”i rohelisele (1/37 vĂ”imalus, 14x payout)"), - ("/rps [bet] [opponent]", "Kivi-paber-kÀÀrid mĂ”ne teise mĂ€ngija vastu"), - ("/slots ", "Proovi oma Ă”nne TipiBOTi mĂ€nguautomaadiga"), - ("/blackjack ", "MĂ€ngi blackjacki TipiBOTi vastu. Blackjack maksab 3:2. Kakskordistamine (double down) on vĂ”imalik vaid esimese kĂ€igu ajal."), - ], - }, - "admin": { - "label": "🔧 Admin", - "description": "Admin kĂ€sklused (peidetud tavaliste kasutajate eest)", - "color": 0xED4245, - "fields": [ - ("/check", "Laadi andmed, tĂ€ida ID'd ja sĂŒnkroniseeri kĂ”ik liikmed"), - ("/member @user", "NĂ€ita liikme andmeid tabelist"), - ("/sync", "SĂŒnkroniseeri kĂ€sklused Discordi serveriga"), - ("/restart", "Tee taaskĂ€ivitus botile"), - ("/send #channel message", "Saada sĂ”num valitud kanalisse"), - ("/admincoins @user ", "Anna vĂ”i vĂ”ta TipiCOINe kasutajale/kasutajalt"), - ("/adminjail @user ", "Saada kasutaja vangi"), - ("/adminunjail @user", "Vabasta kasutaja vangist"), - ("/adminban @user ", "Keela kasutajal majandussĂŒsteemis osaleda"), - ("/adminunban @user", "Eemalda majandussĂŒsteemi keeld kasutajalt"), - ("/adminreset @user ", "LĂ€htesta kasutaja majandusandmed"), - ("/adminview @user", "Vaata kasutaja majandusandmeid"), - ("/allowchannel #channel", "Lisa kanal, kus bot vĂ”ib vastata kĂ€sklustele"), - ("/denychannel #channel", "Eemalda kanal lubatud kanalite nimekirjast"), - ("/channels", "NĂ€ita lubatud kanalite nimekirja"), - ("/adminseason [top_n]", "LĂ”peta vĂ”istlus, teavita vĂ”itjaid ja lĂ€htesta EXP"), - ("/economysetup", "Loo ja sea korda majandussĂŒsteemi rollid (ECONOMY + taseme rollid) boti rolli alla"), - ], - }, -} - -# --------------------------------------------------------------------------- -# Repeated system messages -# --------------------------------------------------------------------------- - -MSG_BANNED = "đŸš« Sul keelati TipiBOTi majandussĂŒsteemis osaleda." -MSG_SPAM_JAIL = "⚠ Liiga kiire! Automaatsed skriptid/spam on keelatud. Oled **30 minutit vangis**. Kasuta `/jailbreak`, et varem vĂ€lja pÀÀseda." -MSG_PONG = "🏓 Pong!" -MSG_RESTART_DONE = "✅ Bot on taaskĂ€ivitatud!" -MSG_RESTARTING = "🔄 TaaskĂ€ivitan..." -MSG_SHUTTING_DOWN = "⛔ LĂŒlitan boti vĂ€lja..." -MSG_PAUSED = "⏞ Bot on hooldusreĆŸiimis - kĂ”ik kĂ€sklused on peatatud." -MSG_UNPAUSED = "▶ HooldusreĆŸiim lĂ”petatud - kĂ€sklused on taas saadaval." -MSG_MAINTENANCE = "🔧 Bot on hetkel hoolduses. Proovi mĂ”ne hetke pĂ€rast uuesti." -MSG_SYNC_DONE = "✅ KĂ€sud sĂŒnkroniseeritud!" -MSG_REMINDER = "⏰ **/{cmd}** on taas kasutamiseks valmis!\n-# Meeldetuletuste haldamiseks kasuta serveris `/reminders`." -MSG_LEVELUP = "âŹ†ïž **{name}** jĂ”udis **{level}. tasemele**!{extra}" -MSG_LEVELUP_ROLE = " 🎊 Uus tiitel: **{role}**!" - -# --------------------------------------------------------------------------- -# /reminders dropdown options (cmd_key, label, description) -# --------------------------------------------------------------------------- - -REMINDER_OPTS: list[tuple[str, str, str]] = [ - ("daily", "📅 /daily", "PĂ€evane boonus (20t ooteaeg)"), - ("work", "đŸ’Œ /work", "Töö (1t ooteaeg, 40min monitoriga)"), - ("beg", "🙏 /beg", "Kerjamine (5min ooteaeg)"), - ("crime", "đŸŠč /crime", "Kuritegu (2t ooteaeg)"), - ("rob", "đŸ”« /rob", "Rööv (2t ooteaeg)"), - ("fish", "🎣 /fish", "KalapĂŒĂŒk (2min ooteaeg)"), -] - -# --------------------------------------------------------------------------- -# /slots outcome strings (title, colour) -# --------------------------------------------------------------------------- - -SLOTS_TIERS: dict[str, tuple[str, int]] = { - "jackpot": (f"{E['TipiFIRE']} JACKPOT!!!", 0xF4C430), - "triple": ("🎰 Kolmik!", 0x57F287), - "pair": ("🎰 Paar", 0x99AAB5), - "miss": (f"{E['TipICRY']} Ei Ă”nnestunud", 0xED4245), -} - -# --------------------------------------------------------------------------- -# /roulette outcome strings -# --------------------------------------------------------------------------- - -ROULETTE: dict = { - "emoji": {"punane": "🔮", "must": "⚫", "roheline": "🟱"}, - "genitive": {"punane": "punase", "must": "musta", "roheline": "rohelise"}, - "win_title": "{emoji} VĂ”itsid!", - "lose_title": "{emoji} Kaotasid!", - "win_desc": "Ratas peatus **{genitive}** peal!{mult}\n+{change}\nSaldo: {balance}", - "lose_desc": "Ratas peatus **{genitive}** peal.\n-{change}\nSaldo: {balance}", - "spin_title": "🎰 Ratas keerleb...", - "spin_stop": "🎰 Ratas peatub...", - "spin_strip": "{s0} {s1} {s2} {s3} {s4}", -} - -# --------------------------------------------------------------------------- -# /heist narrative story lines -# Placeholders: {leader}, {member}, {names}, {vehicle} -# --------------------------------------------------------------------------- - -HEIST_STORY: dict = { - "vehicles": [ - "Ć koda", - "BMW", - "Lada", - "Mazda", - ], - "arrival": [ - "{leader} keerab nurga taga {vehicle} mootori kinni. Maskid ette. Keegi ei rÀÀgi.", - "{vehicle} peatub ĂŒhe kvartali kaugusel sihtmĂ€rgist. {leader}: *\"Pank on seal. Tegutseme plaani jĂ€rgi.\"*", - "Meeskond astub {vehicle}st vĂ€lja. {leader} kuulab kĂ”rvamonitori. *\"Kaks valvurit, ĂŒks pimeala. Liigume nĂŒĂŒd.\"*", - "{leader} jĂ€lgib {vehicle}st sissepÀÀsu. *\"Valvurite vahetus 40 sekundi pĂ€rast. See on meie ajaaken.\"*", - "Kell 3 öösel. TĂ€nav on tĂŒhi. {vehicle} seisab vĂ€ljas tĂŒhikĂ€igul. {leader} tĂ”mbab maski ette.", - "{vehicle} veereb aeglaselt vaiksesse pimedasse vahetĂ€navasse. {leader}: *\"Viimane kontroll. KĂ”ik valmis?\"*", - "{leader} koputab armatuurlauale. *\"Kui midagi lĂ€heb valesti, me lahkume kohe.\"* Vaikus vastuseks.", - "Tuul sahiseb mööda tĂŒhja tĂ€navat. {vehicle} uksed avanevad korraga.", - "{member} kontrollib relva. {leader} vaatab kella. *\"Me oleme graafikus.\"*", - "{vehicle} tuled kustuvad. Linn jÀÀb vaikseks. Nad liiguvad.", - ], - "entry_sneaky": [ - "{member} tĂ”mbab kloonitud kaarti kĂŒlgukse juures. Lukk klĂ”psab hÀÀleta lahti.", - "Meeskond libiseb sisse laadimisestakaadi kaudu, riietatud öisteks koristajateks.", - "{leader} on kaks nĂ€dalat valvurite graafikut pĂ€he Ă”ppinud. Röövlid kĂ”nnivad sisse vahetuse ajal.", - "VĂ”ltsitud alltöövĂ”tja kaardi abil saavad nad fuajeest lĂ€bi ilma lisakĂŒsimuseta.", - "{member} lĂŒlitab vĂ€likaamerad sĂŒlearvutist vĂ€lja. {leader} kĂ”nnib sisse, nagu see oleks tema oma maja.", - "{member} kasutab signaaliblokeerijat. HĂ€ired ei jĂ”ua kunagi sĂŒsteemi.", - "{leader} avab ventilatsiooniluugi. *\"LĂ€heme ĂŒlevalt.\"*", - "Turvamees haigutab. Sekund hiljem on ta seotud ja vaikselt nurka lohistatud.", - "{member} sĂŒstib lukku mikrokaamera. Mehhanism kaardistatakse sekunditega.", - ], - "entry_loud": [ - "{leader} lööb esiuksed lahti. *\"KÕIK PÕRANDALE - KOHE!\"*", - "Esmalt suitsugranaadid. Selleks ajaks, kui suits vaibub, on meeskond juba sees.", - "{leader} tulistab ĂŒhe lasu lakke. Haudvaikus. *\"Oleme siin seifi pĂ€rast! Kui teete koostööd, siis pÀÀsete elusana.\"*", - "*\"Kui keegi ei liigu, ei saa keegi ka viga.\"* {leader} omab ruumi tĂ€ielikku tĂ€helepanu.", - "{member} lĂŒkkab kilbist elektri vĂ€lja. Pimeduses juhib {leader} meeskonna mĂ€lu jĂ€rgi edasi.", - "*\"See on rööv! KĂ”ik pikali!\"* {leader} hÀÀl kajab lĂ€bi saali.", - "Alarm hakkab ulguma juba enne, kui nad tĂ€ielikult sisse jĂ”uavad.", - "Klaas puruneb. Inimesed karjuvad. TĂ€ielik kaos.", - ], - "inside": [ - "{names} liiguvad kiiresti lĂ€bi fuajee, sidudes turvamehi postide kĂŒlge kinni.", - "{member} katab vĂ€ljapÀÀsud. {leader} suundub otse seifi poole.", - "Üks kassapidajatest proovib kĂ€ivitada vaikset hĂ€iret, kuid {member} mĂ€rkab ta nihelemist ja peatab ta hoiatuslasuga.", - "{leader} hoiab töötajad rahulikuna samal ajal, kui teised suunduvad alumisele korrusele.", - "Turvakaamerad on tsĂŒklil. {names} on korrusel ĂŒksi.", - "{member} kontrollib kellaaega. *\"Me oleme 30 sekundit ees.\"*", - "{leader} annab kĂ€emĂ€rgi. Meeskond jaguneb ilma sĂ”nadeta.", - "Koridor on tĂŒhi. Liiga tĂŒhi. {leader} peatub hetkeks.", - "{names} liiguvad trepist alla, sammud summutatud.", - "Üks uks on lukus. {member} avab selle sekundiga.", - ], - "vault": [ - "Neljanda taseme ajalukuga seif. {member} tĂ”mbab puuri vĂ€lja. *\"Anna mulle kolm minutit.\"*", - "{leader} vaatab seifi ust. *\"DĂŒnamiit.\"* Keegi ei vaidle.", - "{member} ĂŒhendub seifi juhtpaneeliga. Vana tarkvara. ÜlevĂ”tmine vĂ”tab 90 sekundit.", - "Seifil on kĂ€sikombinatsiooni lukk. Hea, et {leader} veetis kuu aega juhatajaga, teenides tema usaldust.", - "{member} paigutab lĂ”hkeained. Meeskond astub tagasi. Üks kontrollitud plahvatus.", - "{leader} teeb magnetlukule tĂŒhistuse. Nagu Ă”pikust vĂ”etud. Mehhanism annab jĂ€rgi.", - "{member} higistab. *\"See pole standardlukk... anna mulle aega.\"*", - "{leader} kuulab vastu seifi ust. *\"Sees on liikumisandur.\"*", - "{member} lĂ”ikab lĂ€bi metallkihi nagu vĂ”id.", - "AjurĂŒnnak. {leader} meenutab skeemi ja leiab nĂ”rga koha.", - "{member} ĂŒhendab juhtmed ĂŒmber. SĂ€de. Vaikus. Lukk avaneb.", - ], - "vault_open": [ - "Uks avaneb. Kuhi-kuhja jĂ€rel raha, pĂ”randast laeni. TĂ€pselt nagu plaanitud.", - "*\"...jackpot.\"* {leader} piilub seifi sisse. Meeskond seisab hetkeks vaikuses.", - "{member} hakkab kotte tĂ€is laduma. {leader} piilub juba vĂ€ljapÀÀsu poole.", - "Puhas. Kiire. {names} on seifis ja tĂ€idavad kotte enne, kui tolm settib.", - "Seif on lahti. {leader} vĂ”tab hinge. *\"Okei. Kottidesse ja liikuma.\"*", - "{leader} naeratab esimest korda. *\"See oli seda vÀÀrt.\"*", - "Raha lĂ”hn tĂ€idab Ă”hu. {member}: *\"VĂ”tame kĂ”ik.\"*", - "{names} töötavad vaikides. Iga liigutus loeb.", - "Kotid tĂ€ituvad kiiremini kui oodatud.", - "{leader} pilk muutub tĂ”siseks. *\"Aeg otsas. Liigume.\"*", - ], - "police_inbound": [ - "Raadio krĂ”biseb. *\"KĂ”ik ĂŒksused, relvastatud rööv Keskpangas-\"*", - "VĂ€ljas on kuulda eemalt ulguvaid sireene. {leader}: *\"Meil on umbes neli minutit. Liikuma.\"*", - "Punased ja sinised tuled vilguvad ĂŒlemistest akendest lĂ€bi. Varualarm kĂ€ivitati.", - "Politsei helikopter skaneerib piirkonda proĆŸektoriga. Aken sulgub kiiresti.", - "{member} kontrollib politsei skĂ€nnerit. *\"Nad teavad. Kolm ĂŒksust, kaks minutit eemal.\"*", - "{member} kuulab raadiosidet. *\"Nad sulgevad kvartaleid.\"*", - "Sireenid lĂ€henevad kiiremini kui plaanitud.", - "Helikopteri valgus libiseb ĂŒle akna. Liiga lĂ€hedal.", - ], - "getaway_success": [ - "Adrenaliin. {names} jooksevad {vehicle} juurde. KĂ”ik sees. {leader} keerab mootori kĂ€ima.", - "{member} haarab roolist. {leader} on kĂ”rval. *\"LĂ€hme.\"* Rahulikult.", - "{leader} libistab {vehicle} kĂ”rvalteed pidi minema. Sireen kaugel taga. Nad on kiiremad.", - "Rohelised tuled. {vehicle} sĂ”idab vaikselt mööda parklas ootavast patrullautost. Keegi ei mĂ€rka.", - "{member} nĂ€itab teed. {leader} sĂ”idab vaikselt lĂ€bi tagakvartali. KĂ€ed lĂ”dvad.", - "{vehicle} rehvid vilisevad, kui nad pööravad kitsasse tĂ€navasse.", - "{leader} sĂ”idab ilma tuledeta. Ainult mĂ€lu juhib teda.", - "{member} vaatab kaarti. *\"Vasak, siis kohe parem!\"*", - "Mootor möirgab. Nad kaovad öösse.", - "{vehicle} libiseb lĂ€bi viimase rohelise tule.", - ], - "getaway_fail": [ - "{names} jooksevad {vehicle} juurde. Sireenid igalt poolt.", - "{member} haarab roolist. {leader} on kĂ”rval. *\"Mine! MINE!\"*", - "{vehicle} kihutab tĂ€navale, kuid helikopteri valgusvihk osutab neile. KĂ”ik on nĂ€htaval.", - "Kolm patrullautot jĂ”uavad nende taha. {member} vaatab ĂŒle Ă”la. *\"Nad on meil kannul.\"*", - "Raadio krĂ”biseb. *\"SihtmĂ€rk kinnitatud. Blokeeri Liivalaia tĂ€nav.\"*", - "{vehicle} ei kĂ€ivitu. {member}: *\"PĂ€riselt ka vĂ”i?!\"*", - "{member} komistab teel autoni. Tal oli pael lahti tulnud", - "{leader} avastab, et autot pole kuskil nĂ€ha. See pukseeriti Ă€ra." - ], - "escape_success": [ - "{names} murravad lĂ€bi perimeetri enne vĂ€ravate sulgemist. Puhas pĂ”genemine.", - "{leader} oli planeerinud kolm vĂ€ljumisteed. Vaja lĂ€ks ĂŒhte. {vehicle} kaob linna.", - "Kahe kvartali kaugusel asuv peibutushĂ€ire tĂ”mbab ĂŒksused eemale. {names} on kadunud enne, kui keegi seifi kontrollib.", - "Naelribad? Juba eemaldatud. Teeblokk? Vale tĂ€nav. {leader} mĂ”tles kĂ”igele.", - "{vehicle} sĂ”idab maanteele nagu poleks midagi juhtunud. {leader} hingab esimest korda tunnis vĂ€lja.", - "*\"Oleme vabad.\"* {member} piilub kardinate tagant vĂ€lja. TĂŒhi tĂ€nav. Nad tegid Ă€ra.", - "{names} kaovad sĂŒgavale linna nagu neid poleks kunagi olnud.", - "Raadio jÀÀb vaikseks. Keegi ei jĂ€lita enam.", - "{vehicle} jĂ€etakse maha. Uus plaan aktiveerub.", - "{leader}: *\"See oli liiga lihtne...\"*", - ], - "escape_fail": [ - "Patrullauto lĂ”ikab {vehicle} teekonna sillal Ă€ra. MĂ”lemalt poolt ĂŒmbritsetud. Ongi lĂ€bi.", - "{member} kukutab koti. Kolm sekundit kĂ”hklust - ja uksed on ĂŒmber piiratud.", - "Helikopter jĂ€lgib neid kuni pelgupaigani. KĂ”ik vĂ€ljapÀÀsud on kaetud.", - "{leader} arvutab vĂ”imalusi. *\"VĂ€ljapÀÀsu pole.\"* KĂ€ed lĂ€hevad ĂŒles.", - "Naelribad lĂ”huvad {vehicle} rehvid. Meeskond libiseb liikluse keskele seisma.", - "{vehicle} pĂ”rkub vastu teetĂ”ket. Mootor sureb. Igast suunast tuled.", - ], -} - -# --------------------------------------------------------------------------- -# /heist UI strings -# --------------------------------------------------------------------------- - -HEIST_UI: dict[str, str] = { - "names_duo": "**{a}** ja **{b}**", - "names_sep": " & ", - "names_crew": "**{leader}** ja meeskond", - "btn_join": "Ühine röövimisega đŸ”«", - "btn_start": "Alusta kohe ▶", - "already_joined": "Sa oled juba sees!", - "only_organizer": "Ainult heisti algataja saab heisti alustada.", - "lobby_desc": "**Osalejad ({n}/{max}):**\n{names}\n\nÕnnestumise tĂ”enĂ€osus: **{chance}%**\n\n*Ühinemisaken sulgub *", - "cancel_desc": "Ei piisanud osalejaid (vajad vĂ€hemalt **{min}**). Heist tĂŒhistati.", - "started_title": "đŸ”« Grupirööv kĂ€ib...", - "started_desc": "**{n}** osalejat alustasid heisti. JĂ€lgi allpool!", - "story_title": "đŸ”« Grupirööv", - "win_desc": "**Osalejad:**\n{names}\n\nSaak jagati vĂ”rdselt.\n**IgaĂŒks sai: +{payout}**", - "fail_desc": "**Osalejad:**\n{names}\n\nKĂ”ik osalejad saavad **1,5 tundi vangi** + trahv ~15% saldost.", -} - -# --------------------------------------------------------------------------- -# Embed titles -# --------------------------------------------------------------------------- - -TITLE: dict[str, str] = { - "daily": "📅 PĂ€evane boonus", - "work": "đŸ’Œ Töö", - "beg": "🙏 Kerjamine", - "crime_win": f"{E['TipiFIRE']} Kuritegu Ă”nnestus!", - "crime_fail": f"{E['TipiTROLL']} Vahele jĂ€id!", - "rob_win": f"{E['TipiFIRE']} Rööv Ă”nnestus!", - "rob_fail": f"{E['TipiTROLL']} Rööv ebaĂ”nnestus!", - "rob_anticheat": f"{E['TipiVAC']} Anticheat peatas sind!", - "jailbreak": "đŸŽČ Vanglast pĂ”genemine", - "jailbreak_free": f"đŸŽČ {E['TipiFIRE']} DUUBEL! Oled vaba!", - "jailbreak_fail": f"{E['TipICRY']} Kolm katset lĂ€bi!", - "jailbreak_miss": "đŸŽČ " + E["TipICRY"] + " Ei saanud duublit ({tries}/{max})", - "jailbreak_bail": "💾 Kautsjon", - "give": f"{E['TipiHEART']} TipiCOINi ĂŒlekanne", - "stats": "📊 MĂ€ngustatistika", - "leaderboard_coins": "đŸȘ™ TipiBOTi edetabel - MĂŒndid", - "leaderboard_exp": "📊 TipiBOTi edetabel - EXP / Tase", - "leaderboard_season": "🏆 TipiBOTi edetabel - Hooaja EXP", - "leaderboard_prestige": f"{E['TipiFIRE']} TipiBOTi edetabel - PrestiiĆŸ", - "leaderboard_wagered": "đŸŽČ TipiBOTi edetabel - HasartmĂ€ngud", - "leaderboard_fish": "🎣 TipiBOTi edetabel - KalapĂŒĂŒk", - "rps": "⚔ Kivi, Paber, KÀÀrid", - "rps_duel": "⚔ KPK duell", - "rps_duel_active": "⚔ KPK duell - kĂ€imas", - "rps_duel_done": "⚔ KPK duell - lĂ”ppenud", - "rps_duel_cancel": "⚔ KPK duell - tĂŒhistatud", - "rps_duel_expire": "⚔ KPK duell - aegus", - "rps_duel_decline": "⚔ KPK duell - keelduti", - "heist_lobby": "đŸ”« Grupirööv - kogunemine", - "heist_win": f"{E['TipiFIRE']} Grupirööv Ă”nnestus!", - "heist_fail": f"{E['TipiSKULL']} Grupirööv ebaĂ”nnestus!", - "heist_cancel": "đŸ”« Grupirööv tĂŒhistatud", - "request": f"{E['TipiHEART']} Rahataotlus", - "reminders": "⏰ Meeldetuletused", - "cooldowns": "⏱ Sinu ooteajad", - "adminseason": "🏆 Hooaeg lĂ”ppes!", - "economysetup": "⚙ Majanduse seadistamine", - "blackjack": "🃏 Blackjack", - "blackjack_bj": f"🃏 {E['TipiFIRE']} BLACKJACK!", - "blackjack_win": f"{E['TipiFIRE']} VĂ”itsid!", - "blackjack_lose": f"{E['TipiSKULL']} Kaotasid!", - "blackjack_bust": f"{E['TipiSKULL']} Üle 21 - kaotasid!", - "blackjack_push": "đŸ€ Viik!", - "blackjack_dbust": f"{E['TipiSKULL']} Üle 21 - mĂ”lemad kaotasid!", - "blackjack_dwin": f"{E['TipiFIRE']} Topeltpanus vĂ”itis!", - "prestige_confirm": "đŸ”„ PrestiiĆŸ - kinnita", - "prestige_success": E["TipiFIRE"] + " PrestiiĆŸ {level} saavutatud!", - "prestige_too_low": "❌ PrestiiĆŸ pole saadaval", - "prestige_shop": f"{E['TipiFIRE']} PrestiiĆŸipood", - "prestige_buy_ok": "✅ Uuendus ostetud!", - "fish_cast": "🎣 Otsid kala...", - "fish_bite": "🐟 KALA NÄKKAB!", - "fish_escape": "🎣 Kala pÀÀses!", - "fish_junk": "đŸ—‘ïž Ai ai ai...", - "fishbook": "📖 Kalakogu", - "quests": "🎯 Ülesanded", -} - -# --------------------------------------------------------------------------- -# Quest system (/quests) -# --------------------------------------------------------------------------- - -QUEST_UI: dict[str, str] = { - "daily_header": "📅 PĂ€evaĂŒlesanded", - "weekly_header": "📆 NĂ€dalaĂŒlesanded", - "progress": "⏳ {progress}/{max}", - "ready": "✅ Valmis - nĂ”ua auhind!", - "completed": "🏆 NĂ”utud", - "reward": "🎁 {coins} ⏥ · {exp} EXP", - "empty": "Ühtegi ĂŒlesannet pole. Proovi hiljem uuesti!", - "claim_btn": "🎁 NĂ”ua auhinnad", - "claimed_msg": "🏆 NĂ”udsid {count} ĂŒlesande auhinnad: +{coins} · +{exp} EXP!", - "nothing": "Sul pole ĂŒhtegi valmis ĂŒlesannet, mida nĂ”uda.", - "error": "❌ Ülesannete laadimine ebaĂ”nnestus. Proovi hiljem uuesti.", -} - -# Estonian one-line description per quest id (keys match economy.QUESTS_*). -QUEST_DESCRIPTIONS: dict[str, str] = { - # Daily - "work3": "Tööta 3 korda (/work)", - "beg5": "Kerja 5 korda (/beg)", - "wager500": "Panusta kokku 500 ⏥ hasartmĂ€ngudes", - "fish2": "PĂŒĂŒa 2 kala (/fish)", - "crime1": "Soorita edukalt 1 kuritegu (/crime)", - "earn1000": "Teeni kokku 1 000 ⏥", - "give200": "Kingi teistele kokku 200 ⏥ (/give)", - # Weekly - "work20": "Tööta 20 korda (/work)", - "fish15": "PĂŒĂŒa 15 kala (/fish)", - "wager5000": "Panusta kokku 5 000 ⏥ hasartmĂ€ngudes", - "crime5": "Soorita edukalt 5 kuritegu (/crime)", - "heist1": "Osale 1 grupiröövis (/heist)", - "earn10000": "Teeni kokku 10 000 ⏥", -} - -# --------------------------------------------------------------------------- -# Error / validation messages (use .format(**kwargs) for dynamic parts) -# --------------------------------------------------------------------------- - -ERR: dict[str, str] = { - "positive_bet": "❌ Panus peab olema positiivne.", - "positive_amount": "❌ Summa peab olema positiivne.", - "positive_duration": "❌ Aeg peab olema positiivne.", - "admincoins_zero": "Kogus ei tohi olla 0.", - "missing_perms": "❌ Sul on vaja **Rollide haldamise** Ă”igust selle kĂ€su kasutamiseks.", - "generic_error": "❌ Tekkis viga: ```{error}```", - "send_failed": "❌ SĂ”numi saatmine ebaĂ”nnestus: ```{error}```", - "rob_self": "❌ Sa ei saa iseennast röövida.", - "rob_bot": "❌ Botti ei saa röövida.", - "rob_no_house": "❌ Kassa pole veel avatud.", - "rob_house_blocked": "❌ Panga seif on liiga hĂ€sti kaitstud. Kasuta `/heist` selle rĂŒĂŒstamiseks.", - "rob_too_poor": "❌ **{name}** on liiga vaene röövimiseks.", - "rob_target_jailed": "❌ **{name}** on vanglas - vabad inimesed ei saa vanglas olevaid röövida.", - "heist_active": "❌ Serveris on juba aktiivne grupirööv kĂ€imas! Oota, kuni see lĂ”peb.", - "heist_full": "❌ Grupirööv on tĂ€is!", - "heist_min_players": "❌ Grupiröövi alustamiseks on vaja vĂ€hemalt **{min}** osalejat.", - "broke": E["TipICRY"] + " Sul pole piisavalt TipiCOINe. Saldo: {bal}", - "broke_need": E["TipICRY"] + " Sul pole piisavalt TipiCOINe. Vajad veel {need}.", - "item_owned": "❌ Sul on see ese juba olemas.", - "item_not_found": "❌ Eset ei leitud.", - "item_level_req": "🔒 Selle eseme ostmiseks vajad **taset {min_level}** (sul on tase {user_level}). Teeni EXP-id kĂ”iki kĂ€ske kasutades.", - "not_your_game": "❌ See pole sinu mĂ€ng!", - "game_in_progress": "❌ Sul on juba mĂ€ng kĂ€imas! LĂ”peta see enne.", - "not_your_challenge":"❌ See vĂ€ljakutse pole sulle!", - "not_your_menu": "❌ See ei ole sinu menĂŒĂŒ.", - "give_self": "❌ Sa ei saa iseendale TipiCOINe anda.", - "give_bot": "❌ Botile ei saa TipiCOINe anda.", - "give_jailed": "❌ Oled vangis - vanglas ei saa TipiCOINe anda. PÀÀsed vĂ€lja {ts}.", - "rps_self": "❌ Sa ei saa iseendale vĂ€ljakutset esitada.", - "rps_bot": "❌ Botid on KPK-is liiga head.", - "not_jailed": "❌ Sa pole praegu vangis.", - "not_in_leaderboard":"❌ Sind pole veel edetabelis.", - "admin_ban_bot": "❌ Botti ei saa bannida.", - "admin_reset_bot": "❌ Kassa andmeid ei saa lĂ€htestada.", - "member_not_found": "❌ **{name}** ei leitud tabelist.", - "request_self_fund": "❌ Sa ei saa oma taotlust ise rahastada.", - "request_self": "❌ Sa ei saa iseendalt anuda.", - "request_bot": "❌ Botid on teatavasti kitsid.", - "request_targeted": "❌ See taotlus on suunatud kasutajale **{name}**.", - "request_closed": "❌ See taotlus on juba rahastatud vĂ”i aegunud.", - "already_in_game": "❌ Sul on juba aktiivne mĂ€ng kĂ€imas!", - "invalid_amount": "❌ Sisesta kehtiv summa vĂ”i 'all'.", - "fund_range": "❌ Sisesta summa vahemikus 1-{max}.", - "channel_only": "❌ Boti kĂ€ske saab kasutada ainult nendes kanalites: {channels}", - "guild_only": "Seda kĂ€sku saab kasutada ainult serveris.", - "sheet_error": "❌ Tabeli laadimine ebaĂ”nnestus: ```{error}```", - "gamble_cooldown": "🎰 Oled just mĂ€nginud! Saad uuesti mĂ€ngida {ts}.", -} - -# --------------------------------------------------------------------------- -# Cooldown messages (use .format(ts=...) - ts is a Discord timestamp string) -# --------------------------------------------------------------------------- - -CD_MSG: dict[str, str] = { - "daily": "⏳ JĂ€rgmine boonus {ts}.", - "work": "⏳ Saad töötada {ts}.", - "beg": "⏳ Saad kerjata {ts}.", - "crime": "⏳ Saad uuesti proovida {ts}.", - "rob": "⏳ Saad uuesti röövida {ts}.", - "heist": "⏳ Saad uuesti heisti teha {ts}.", - "heist_global": "⏳ Pangahoidla alles kosub eelmisest röövist. JĂ€rgmine heist vĂ”imalik {ts}.", - "jailed": E["TipiTROLL"] + " Oled vangis! PÀÀsed vĂ€lja {ts}. Kasuta `/jailbreak`, et varem vĂ€lja pÀÀseda.", - "fish": "🎣 Saad uuesti kalastada {ts}.", -} - -# --------------------------------------------------------------------------- -# Shop UI strings -# --------------------------------------------------------------------------- - -SHOP_UI: dict[str, str] = { - "tier_1": "Tier 1 - Algaja", - "tier_2": "Tier 2 - Kogenud", - "tier_3": "Tier 3 - Legend", - "desc": "Saldo: {bal} · Osta kĂ€suga `/buy`", - "owned": "✅ Olemas", - "owned_uses_1": "✅ Olemas ({uses} kasutus jĂ€rel)", - "owned_uses_n": "✅ Olemas ({uses} kasutust jĂ€rel)", - "locked": "🔒 Tase {min_lvl} nĂ”utud *(sul on {user_lvl})*", -} - -# --------------------------------------------------------------------------- -# Blackjack in-game strings -# --------------------------------------------------------------------------- - -BJ: dict[str, str] = { - "dealing": "*Jagame kaardid...*", - "result_field": "Tulemus", - "push_result": "±0 (panus tagasi)", - "doubled_label": "💰 *Topeltpanus: {total}*", - "btn_hit": "🃏 VĂ”ta kaart", - "btn_stand": "✋ Seisa", - "btn_double": "💰 Kahekordista (+{bet}⏥)", - "btn_split": "✂ Split (+{bet}⏥)", -} - -# --------------------------------------------------------------------------- -# Shop item descriptions (keyed by SHOP dict key in economy.py) -# --------------------------------------------------------------------------- - -ITEM_DESCRIPTIONS: dict[str, str] = { - "gaming_hiir": "Koolist varastatud hiir? Ei, see on mĂ€ngurihiir. Teeni töötades 50% rohkem TipiCOINe.", - "hiirematt": "XXL suuruses, ainult parimast materjalist. Kerjamise ooteaeg 5min → 3min.", - "korvaklapid": "Noise-cancelling - kuuled ainult TipiCOINide kĂ”linat. PĂ€evase boonuse ooteaeg 20h → 18h + 25⏥ boonust.", - "lan_pass": "Ametlik TipiLANi pilet (2025). PĂ€evane boonus on duubeldatud.", - "energiajook": "Kolm Red Bulli jĂ€rjest. 30% tĂ”enĂ€osus, et teenid töötades 3x rohkem.", - "gaming_laptop": "RTX 5090 jooksutab botte 24/7. PĂ€evane boonus genereerib 5% intressi sinu saldo pealt.", - "anticheat": "VAC, EAC, Faceit AC ja BattlEye korraga. Röövimine sinu vastu ebaĂ”nnestub. **2 kasutust**, siis pead ostma uue.", - "reguleeritav_laud": "VĂ”id nii seista kui istuda - alati vĂ”idad. /work teenib 25% rohkem (stackib mĂ€ngurihiirega).", - "jellyfin": "Self-hosted meediaserver - oled suurfirmadest sĂ”ltumatu. Röövimise edu 45% → 60%. Grupiröövi Ă”nnestumisele +5%.", - "mikrofon": "Parem helikvaliteet teeb sind usutavamaks. Teeni 30% rohkem eduka /crime puhul.", - "klaviatuur": "KlĂ”binad kostuvad ĂŒle kogu saali. /beg teenib 2x rohkem.", - "monitor": "240Hz ja 27 tolli. /work ooteaeg: 1h → 40min.", - "cat6": "Gigabitine internet = ideaalne piraatluseks. /crime edu tĂ”enĂ€osus tĂ”useb 60% → 75%.", - "monitor_360": "360Hz, 1ms. MĂ€nguautomaadi jackpot 10x → 15x, kolmik 4x → 6x. HasartmĂ€ngude ooteaeg 30s → 25s.", - "karikas": "Ainult legendidele. Streak ei nulli, kui sa mĂ”ne pĂ€eva vahele jĂ€tad.", - "gaming_tool": "Nii mugav, et isegi admin ei saa sind ĂŒles. /crime ebaĂ”nnestumine ei saada sind vanglasse.", - "ussipurk": "Lakkumatu toiduga ussipurk - kalad ei saa vastu. Kalapyygi ooteaeg 2min → 90s.", - "kalavork": "Suurem vĂ”rk = suuremad kalad. KĂ”igi kalade haruldus tĂ”useb ĂŒhe astme vĂ”rra.", - "echolood": "TĂ€pne ehholood nĂ€itab kala tĂ€pset asukohta. Haukamise aken 2s → 3s.", -} - -# --------------------------------------------------------------------------- -# /help UI -# --------------------------------------------------------------------------- - -HELP_UI: dict[str, str] = { - "footer": "TipiBOT ‱ Vali kategooria allmenĂŒĂŒst", - "select_placeholder": "Vali kategooria...", -} - -JAILED_UI: dict[str, str] = { - "title": "🔒 Praegu vanglas", - "empty": "KĂ”ik on vabad! Vanglas pole kedagi.", - "entry": "‱ {mention} - vabaneb ", - "footer": "{count} vang{plural}", -} - -REMINDERS_UI: dict[str, str] = { - "select_placeholder": "Vali kĂ€sud, millest soovid meeldetuletust...", - "saved_on": "✅ Meeldetuletused sisse lĂŒlitatud: {names}", - "saved_off": "🔕 KĂ”ik meeldetuletused vĂ€lja lĂŒlitatud.", - "desc_active": "Praegu aktiivne: {status}\n\nMuuda valikut allpool.", - "desc_none": "Hetkel pole ĂŒhtegi meeldetuletust sisse lĂŒlitatud.\n\nVali allpool, millest soovid DM-i saada.", - "footer": "Bot DM-ib sulle, kui valitud kĂ€sk on taas kasutamiseks valmis.", -} - -SHOP_BTN: dict[int, str] = { - 1: "Tier 1", - 2: "Tier 2", - 3: "Tier 3", -} - -# --------------------------------------------------------------------------- -# /send UI strings -# --------------------------------------------------------------------------- - -SEND_UI: dict[str, str] = { - "sent": "✅ SĂ”num saadetud kanalisse {channel}!", - "forbidden": "❌ Mul pole Ă”igust kanalisse {channel} kirjutada.", -} - -# --------------------------------------------------------------------------- -# /patchnotes UI strings -# --------------------------------------------------------------------------- - -PATCHNOTES_UI: dict[str, str] = { - "title": "📝 Muudatuste logi — {version}", - "footer": "Versioon {idx}/{total}", - "btn_newer": "◀ Uuem", - "btn_older": "Vanem ▶", - "select_placeholder": "Vali versioon
", - "empty_file": "â„č Muudatuste logi on hetkel tĂŒhi.", - "empty_version": "_(selle versiooni kohta mĂ€rkmeid pole)_", -} - -# --------------------------------------------------------------------------- -# /allowchannel /denychannel /channels UI strings -# --------------------------------------------------------------------------- - -MEMBER_UI: dict[str, str] = { - "title": "đŸ‘€ {name}", - "age_field": "Vanus", - "age_val": "{age}a", -} - -MEMBER_FIELDS: list[tuple[str, str]] = [ - ("Nimi", "Nimi"), - ("Organisatsioon", "Organisatsioon"), - ("Valdkond", "Valdkond"), - ("Roll", "Roll"), - ("SĂŒnnipĂ€ev", "SĂŒnnipĂ€ev"), - ("Meil", "Meil"), - ("Telefon", "Telefon"), - ("Discord", "Discord"), - ("User ID", "Kasutaja ID"), - ("Discordis synced?", "SĂŒnkroniseeritud"), - ("Groupi lisatud?", "Grupis"), -] - -ADMINVIEW_UI: dict[str, str] = { - "title": "🔍 {name} - majandusandmed", - "banned_yes": "đŸš« JAH", - "banned_no": "✅ Ei", - "f_balance": "💰 Saldo", - "f_exp": "📊 EXP / Tase", - "f_streak": "đŸ”„ Streak", - "f_banned": "đŸš« Keelatud", - "f_jailed": "🚔 Vangis kuni", - "f_prestige": "đŸ”„ PrestiiĆŸ", - "f_items": "🎒 Esemed", - "f_uses": "🔱 Kasutused", - "f_fish": "🎣 Kala", - "f_last_daily": "⏱ Viimati daily", - "f_last_work": "⏱ Viimati work", - "f_last_crime": "⏱ Viimati crime", - "f_last_fish": "⏱ Viimati fish", - "footer": "ID: {uid}", - "exp_val": "{exp} EXP (Tase {level})", - "prestige_val": "PrestiiĆŸ {level} · {pp} PP", - "fish_val": "{caught} pĂŒĂŒtud · {inv} inventaris", -} - -ECONOMYSETUP_UI: dict[str, str] = { - "created_field": "✅ Loodud", - "existing_field": "♻ Juba olemas", - "footer": "Rollid on nĂŒĂŒd boti rolli all Ă”iges jĂ€rjekorras.", -} - -CHANNEL_UI: dict[str, str] = { - "already_allowed": "â„č {channel} on juba lubatud kanalite nimekirjas.", - "added": "✅ {channel} lisatud. Bot vastab kĂ€skudele nĂŒĂŒd ainult lubatud kanalites.", - "not_in_list": "â„č {channel} pole lubatud kanalite nimekirjas.", - "removed": "✅ {channel} eemaldatud lubatud kanalite nimekirjast.", - "removed_last": "✅ {channel} eemaldatud. Lubatud kanalite nimekiri on nĂŒĂŒd tĂŒhi - bot vastab kĂ”ikides kanalites.", - "list_title": "📋 Lubatud kanalid", - "list_empty": "Lubatud kanalite nimekiri on **tĂŒhi** - bot vastab kĂ€skudele kĂ”ikides kanalites.\n\nKasuta `/allowchannel`, et piirata kindlatele kanalitele.", - "list_filled": "Bot vastab kĂ€skudele ainult nendes kanalites:\n\n{lines}\n\n-# Kasuta `/allowchannel` / `/denychannel` nimekirja muutmiseks.", -} - -# --------------------------------------------------------------------------- -# /daily embed strings -# --------------------------------------------------------------------------- - -DAILY_UI: dict[str, str] = { - "earned": "✅ Said {earned}!", - "interest": E["TipiLAP"] + " Bot Farm tootis: +{interest}", - "vip": f"{E['TipiPILET']} LAN pileti boonus rakendus!", - "footer": "Streak: {streak_str} · Saldo: {balance}", -} - -# --------------------------------------------------------------------------- -# /stats embed strings -# --------------------------------------------------------------------------- - -STATS_UI: dict[str, str] = { - "economy_field": "💰 Majandus", - "economy_val": "Tipp-saldo: {peak}\nEluaegne tulu: {earned}\nEluaegne kaotus: {lost}", - "work_field": "đŸ› ïž Töö & Kerja", - "work_val": "Töötanud: **{work}** korda\nKerjanud: **{beg}** korda", - "gamble_field": "đŸŽČ HasartmĂ€ngud", - "gamble_val": "Panustatud kokku: {wagered}\nSuurim vĂ”it: {win}\nSuurim kaotus: {loss}\nSlotsi jackpotid: **{jackpots}** korda", - "crime_field": "đŸŠč Kuritegevus", - "crime_val": "Kuriteod: **{crimes}** ({succeeded} Ă”nnestus)\nHeistid: **{heists}** ({heists_won} vĂ”ideti)\nVangi sattunud: **{jailed}** korda\nKautsjon makstud: {bail}", - "social_field": "đŸ€ Sotsiaalne", - "social_val": "Kingitud: {given}\nSaadud: {received}", - "records_field": "đŸ”„ Rekordid", - "records_val": "Pikim pĂ€evane streak: **{streak}** pĂ€eva", -} - -# --------------------------------------------------------------------------- -# /birthdays UI -# --------------------------------------------------------------------------- - -BIRTHDAY_UI: dict[str, str] = { - "no_entries": "Sellel kuul sĂŒnnipĂ€evi ei ole.", - "today": "tĂ€na 🎉", - "tomorrow": "homme", - "in_days": "{days}p pĂ€rast", - "footer": "Leht {month}/12 · {month_name}", -} - -BIRTHDAY_MONTHS: list[str] = [ - "Jaanuar", "Veebruar", "MĂ€rts", "Aprill", "Mai", "Juuni", - "Juuli", "August", "September", "Oktoober", "November", "Detsember", -] - -# --------------------------------------------------------------------------- -# /check summary strings -# --------------------------------------------------------------------------- - -CHECK_UI: dict[str, str] = { - "sheet_stats_header": "📊 **Tabeli statistika** - {total} liiget", - "stat_ok": "✅ **{label}**: kĂ”ik tĂ€idetud", - "stat_warn": "⚠ **{label}** puudub ({count}): {names}{more}", - "stat_more": " (+{count} veel)", - "stat_uid": "Kasutaja ID", - "stat_discord": "Discordi kasutajanimi", - "stat_bday": "SĂŒnnipĂ€ev", - "no_name": "(no name)", - "done": "**Kontroll lĂ”petatud!**", - "already_ok": "✅ Juba korras: {count}", - "fixed": "🔧 Parandatud: {count}", - "not_found": "❓ Pole tabelis: {count}", - "bday_pings": "🎂 SĂŒnnipĂ€eva teavitused: {count}", - "errors": "⚠ Vead: {count}", - "details_header": "**Üksikasjad:**", - "details_more": "... ja {count} rohkem", - "detail_error": "⚠ {error}", - "detail_nickname": "hĂŒĂŒdnimi", - "detail_roles_added": "+rollid: {roles}", - "detail_changed": "🔧 **{name}**: {parts}", - "ids_filled": "\n🔑 TĂ€ideti **{count}** puuduvat kasutaja ID-d.", -} - -# --------------------------------------------------------------------------- -# /status UI -# --------------------------------------------------------------------------- - -STATUS_UI: dict[str, str] = { - "title": "đŸ–„ïž Boti olek", - "uptime_field": "🕐 Uptime", - "uptime_val": "{hours}t {minutes}m {seconds}s", - "latency_field": "📡 Latency", - "latency_val": "{ms} ms", - "ram_field": "🧠 RAM (RSS)", - "ram_val": "{mb} MB", - "cpu_field": "⚙ CPU", - "cpu_val": "{percent}%", - "tasks_field": "🔄 Async tasks", - "eco_players_field": "đŸ‘€ Eco players", - "members_cache_field": "📋 Liikmed (cache)", - "log_files_field": "📂 Log files", - "log_line": "`{name}` - {size_kb} KB", - "none": "-", -} - -# --------------------------------------------------------------------------- -# Admin command responses and DMs -# --------------------------------------------------------------------------- - -ADMIN: dict[str, str] = { - "coins_done": "{emoji} **{name}**: {verb} {coin} → saldo **{balance}**\n📝 PĂ”hjus: {reason}", - "coins_dm": "{emoji} Admin muutis sinu TipiCOINide saldot: **{verb} {coin}**\n📝 PĂ”hjus: *{reason}*\nUus saldo: **{balance} {coin}**", - "jail_done": "🚔 **{name}** on vangistatud {minutes} minutiks (vabaneb {ts}).\n📝 PĂ”hjus: {reason}", - "jail_dm": "🚔 Admin saatis sind vanglasse **{minutes} minutiks**.\n📝 PĂ”hjus: *{reason}*\nVabaneb {ts}.", - "unjail_done": "✅ **{name}** on vanglast vabastatud.", - "unjail_dm": "✅ Admin vabastas sind vanglast.", - "ban_done": "đŸš« **{name}** keelati majandussĂŒsteemis osaleda.\n📝 PĂ”hjus: {reason}", - "ban_dm": "đŸš« Sul keelati TipiBOTi majandussĂŒsteemis osalemine.\n📝 PĂ”hjus: *{reason}*", - "unban_done": "✅ **{name}** on majandussĂŒsteemi osalemise keelust eemaldatud.", - "unban_dm": "✅ Sinu TipiBOTi majandussĂŒsteemis osalemise keeld on tĂŒhistatud. Saad taas kĂ€ske kasutada.", - "reset_done": "đŸ—‘ïž **{name}** majandusandmed on lĂ€htestatud.\n📝 PĂ”hjus: {reason}", - "reset_dm": "đŸ—‘ïž Admin lĂ€htestas sinu TipiBOTi majandusandmed (saldo, esemed, streak).\n📝 PĂ”hjus: *{reason}*", - "exp_done": "{emoji} **{name}**: {verb} EXP → kokku **{exp} EXP** (Tase {level}).\n📝 PĂ”hjus: {reason}", - "exp_dm": "{emoji} Admin muutis sinu EXP-i: **{verb} EXP**\n📝 PĂ”hjus: *{reason}*\nUus EXP: **{exp}** (Tase {level})", - "item_given": "✅ **{item}** antud kasutajale **{name}** (tasuta).", - "item_removed":"đŸ—‘ïž **{item}** eemaldatud kasutajalt **{name}**.", - "item_invalid":"❌ Tundmatu ese: `{item_id}`. Kontrolli `/shop` eseme ID-d.", - "item_not_owned": "❌ **{name}** ei oma eset `{item_id}`.", - "item_dm_given": "✅ Admin andis sulle eseme: **{item}**.", - "item_dm_removed":"đŸ—‘ïž Admin eemaldas sult eseme: **{item}**.", -} - -# --------------------------------------------------------------------------- -# /adminseason strings -# --------------------------------------------------------------------------- - -SEASON: dict[str, str] = { - "top_header": "**Top mĂ€ngijad EXP jĂ€rgi:**", - "no_players": "Keegi ei teeninud EXP-i.", - "entry": "{prefix} {name} - {exp} EXP *(Tase {level})*", - "footer": "KĂ”igi EXP, mĂŒndid ja esemed lĂ€htestati. Uus hooaeg algab kohe!", - "done": "✅ Hooaeg lĂ”petatud - EXP, mĂŒndid ja esemed lĂ€htestatud.", -} - -# --------------------------------------------------------------------------- -# /profile tabbed view -# --------------------------------------------------------------------------- - -PROFILE_UI: dict[str, str] = { - "btn_profile": "💰 Profiil", - "btn_items": "🎒 Esemed", - "btn_stats": "📊 Statistika", - "btn_fish": "🎣 Kalakogu", - "main_title": "💰 {name}", - "items_title": "🎒 {name} - Esemed", - "stats_title": "📊 {name} - Statistika", - "fish_title": "🎣 {name} - Kalakogu", - "items_empty": "Sul pole ĂŒhtegi eset.", - "f_balance": "💰 Saldo", - "f_level": "📊 Tase", - "f_streak": "đŸ”„ Streak", - "f_prestige": "⭐ Prestiiz", - "f_jail": "🚔 Vangis kuni", - "f_progress": "→ Tase {next}", - "progress_bar": "`{bar}` {done}/{needed} EXP", - "level_val": "Tase {level} - {role}", - "prestige_val":"⭐ P{level} · {pp} PP", - "footer_t1": "Tase 10 avab T2 poe · Tase 20 avab T3 poe", - "footer_t2": "T2 pood avatud · Tase 20 avab T3 poe", - "footer_t3": "T2 ja T3 pood avatud", -} - -# --------------------------------------------------------------------------- -# /balance embed strings -# --------------------------------------------------------------------------- - -BALANCE_UI: dict[str, str] = { - "saldo": "Saldo", - "streak": "PĂ€eva streak", - "streak_val": "đŸ”„ {streak}p", - "jailed_until": "🚔 Vangis kuni", - "items": "Esemed", - "uses_one": " *({uses} kasutus jĂ€rel)*", - "uses_many": " *({uses} kasutust jĂ€rel)*", -} - -# --------------------------------------------------------------------------- -# /cooldowns embed strings -# --------------------------------------------------------------------------- - -COOLDOWNS_UI: dict[str, str] = { - "ready": "✅ Valmis", - "daily_line": "📅 **/daily** {status}{note}", - "work_line": "đŸ’Œ **/work** {status}{note}", - "beg_line": "🙏 **/beg** {status}{note}", - "crime_line": "đŸŠč **/crime** {status}", - "rob_line": "đŸ”« **/rob** {status}", - "fish_line": "🎣 **/fish** {status}{note}", - "note_korvak": " *(kĂ”rvaklapid: 18t)*", - "note_monitor": " *(monitor: 40min)*", - "note_hiirematt": " *(hiirematt: 3min)*", - "note_ussipurk": " *(ussipurk: 90s)*", - "jailed": "\nïżœ **Vanglas** - vabaneb ", - "jail_expired": "\n🔓 Vangla lĂ”ppes", -} - -# --------------------------------------------------------------------------- -# /rank embed strings -# --------------------------------------------------------------------------- - -RANK_UI: dict[str, str] = { - "title": "📊 {name} - Tase {level}", - "field_title": "Tiitel", - "field_exp": "EXP", - "field_progress": "Progress → Tase {next}", - "progress_val": "`{bar}` {progress}/{needed} EXP", - "footer_t1": "Tase 10 avab T2 poe · Tase 20 avab T3 poe", - "footer_t2": "T2 pood avatud · Tase 20 avab T3 poe", - "footer_t3": "T2 ja T3 pood avatud", -} - -# --------------------------------------------------------------------------- -# /work embed strings -# --------------------------------------------------------------------------- - -WORK_UI: dict[str, str] = { - "desc": "Sa {job} ja teenisid {earned}!", - "redbull": f"\n{E['TipiBULL']} Red Bull aktiveerus - 3x boonus!", - "hiir": f"\n{E['TipiHIIR']} MĂ€ngurihiir: +50% palk", - "laud": f"\n{E['TipiLAUD']} Reguleeritav laud: +25% palk", - "balance": "\nSaldo: {balance}", -} - -# --------------------------------------------------------------------------- -# /beg embed strings -# --------------------------------------------------------------------------- - -BEG_UI: dict[str, str] = { - "desc": "Sa {text} ja said {earned}.", - "klaviatuur": f"{E['TipiKLAVA']} Mehhaaniline klaviatuur: 2x tulu", - "balance": "Saldo: {balance}", -} - -# --------------------------------------------------------------------------- -# /crime embed strings -# --------------------------------------------------------------------------- - -CRIME_UI: dict[str, str] = { - "win_desc": "Sa {text} ja teenisid {earned}!", - "fail_base": "Sa {text} ja said trahvi {fine}.", - "fail_jailed": "\n\ud83d\udd12 Oled vangis! P\u00e4\u00e4sed {ts}.", - "fail_shield": "\n\ud83d\udee1\ufe0f Gaming Tool hoidis sind vanglast!", - "mikrofon": f"\n{E['TipiMIC']} Mikrofon: +30% saak", - "cat6": f"\n{E['TipiCAT']} CAT6: 75% edu t\u00f5en\u00e4osus", - "balance": "\nSaldo: {balance}", -} - -# --------------------------------------------------------------------------- -# /rob embed strings -# --------------------------------------------------------------------------- - -ROB_UI: dict[str, str] = { - "jackpot_desc": "đŸ’„ **JACKPOT!** Murdsid TipiBOTi kassasse sisse ja varastasid {stolen}!\nSaldo: {balance}", - "win_desc": "Varastasid {stolen} kasutajalt **{name}**!\nSaldo: {balance}", - "anticheat_desc": "**{name}** kaitseb end Anticheati'ga - said trahvi {fine}.", - "anticheat_worn": "⚠ Sinu **Anticheat** on kulunud! Osta uus `/buy` kĂ€suga.", - "victim_dm": "💾 **{robber}** varastas sinult **{stolen}** mĂŒnti!", - "fail_desc": "JĂ€id vahele! Trahv: {fine}.\nSaldo: {balance}", -} - -# --------------------------------------------------------------------------- -# /give embed strings -# --------------------------------------------------------------------------- - -GIVE_UI: dict[str, str] = { - "desc": "**{giver}** andis {amount} kasutajale **{receiver}**.", -} - -# --------------------------------------------------------------------------- -# /buy embed strings -# --------------------------------------------------------------------------- - -BUY_UI: dict[str, str] = { - "title": "{emoji} {name} ostetud!", - "desc": "{description}\nUus saldo: {balance}", -} - -# --------------------------------------------------------------------------- -# /jailbreak UI strings -# --------------------------------------------------------------------------- - -JAILBREAK_UI: dict[str, str] = { - "btn_roll": "đŸŽČ Viska tĂ€ringud ({try_}/{max})", - "rolling_desc": f"{E['TipiDICE']} *TĂ€ringud lendavad...*", - "free_desc": "{d1} {d2}\n\n✅ Viskasid duubli - pÀÀsesid vanglast!", - "miss_desc": "{d1} {d2}\n\n{left} katset jÀÀnud. Proovi uuesti!", - "intro_desc": "Oled vangis kuni {ts}.\n\nViska tĂ€ringuid ja proovi **duublit** saada - siis pÀÀsed tasuta vabaks!\nSul on **{tries} katset**. EbaĂ”nnestumisel saad valida: maksa kautsjon **(20–30% saldost, min 350 ⏥)** vĂ”i jÀÀ vanglasse kuni aja lĂ”puni.", - "already_bail": "Oled juba tĂ€ringuid visanud. Ainuke vĂ€ljapÀÀs on kautsjon.\n\n💰 **Kautsjon: {min} – {max}** (20–30% saldost)\nSaldo: {bal} VĂ€ljas: {ts}", - "already_broke": "Oled juba tĂ€ringuid visanud. Sul pole piisavalt raha kautsjoni maksmiseks.\n\n💰 Kautsjoni miinimum: **{min}** - sul on ainult {bal}\nVĂ€ljas: {ts}", - "bail_btn": "💾 Maksa kautsjon", - "bail_broke_desc": "❌ Sul pole piisavalt raha (min {min}).\nJÀÀd vanglasse kuni aja lĂ”puni! Saldo: {balance}", - "bail_paid_desc": "✅ Kautsjon makstud: **{fine}**\nOled vaba! Saldo: {balance}", - "bail_already_free": "✅ Oled juba vaba - kautsjonit ei vĂ”etud. Saldo: {balance}", - "fail_broke_desc": "{d1} {d2} - ei olnud duubel\n\n❌ Sul pole kautsjoni maksmiseks piisavalt raha (min 350 ⏥).\nJÀÀd vanglasse kuni aja lĂ”puni! Saldo: {balance}", - "fail_bail_desc": "{d1} {d2} - ei olnud duubel\n\nKautsjon makstud: **{fine}**\nSaldo: {balance}", - "fail_bail_offer": "{d1} {d2} - ei olnud duubel\n\n💰 **Kautsjon: {min} - {max}** (20-30% saldost)\nSaldo: {bal}\n\nSaad maksta kautsjoni vĂ”i jÀÀda vanglasse kuni aja lĂ”puni.", -} - -# --------------------------------------------------------------------------- -# /leaderboard UI strings -# --------------------------------------------------------------------------- - -LEADERBOARD_UI: dict[str, str] = { - "house_entry": "đŸ€– {name} *(maja)* - {balance}", - "house_default_name": "TipiBOT", - "no_entries": "Keegi ei ole veel punkte teeninud.", - "footer": "LehekĂŒlg {page}/{total} · {count} mĂ€ngijat", - "btn_coins": "đŸȘ™ MĂŒndid", - "btn_exp": "📊 EXP", - "btn_find_me": "📍 Mina", - "exp_entry": "{prefix} {name} - {exp} EXP *(Tase {level})*", - "unknown_user": "Kasutaja {uid}", - "btn_season": "🏆 Hooaeg", - "btn_prestige": "đŸ”„ PrestiiĆŸ", - "btn_wagered": "đŸŽČ HasartmĂ€ng", - "btn_fish": "🎣 Kalapyyk", - "season_entry": "{prefix} {name} - {exp} EXP *(PrestiiĆŸ {prestige})*", - "prestige_entry": "{prefix} {name} - PrestiiĆŸ **{prestige}** · {pp} PP", - "wagered_entry": "{prefix} {name} - {wagered} panustatud", - "fish_entry": "{prefix} {name} - {caught} kala", -} - -# --------------------------------------------------------------------------- -# /slots additional UI strings (titles/footers per outcome) -# --------------------------------------------------------------------------- - -SLOTS_UI: dict[str, str] = { - "playing": "🎰 MĂ€ngimas...", - "jackpot_footer": E["TipiKARIKAS"] + " Kolm karikat! +{change}", - "triple_footer": "✅ Kolm ĂŒhesugust! +{change}", - "pair_footer": "Kaks ĂŒhesugust! +{change}", - "miss_footer": "-{amount}", - "balance_line": "\nSaldo: {balance}", -} - -# --------------------------------------------------------------------------- -# /rps UI strings -# --------------------------------------------------------------------------- - -RPS_UI: dict[str, str] = { - "vs_bot_desc": "Tee oma valik! âŹ‡ïž", - "vs_bot_bet": "\nPanus: {bet}", - "result_tie": "đŸ€ Viik!", - "result_win": "🎉 Sina vĂ”itsid!", - "result_lose": "đŸ€– Bot vĂ”itis!", - "result_desc": "Sina: {player_pick} {player_name}\nBot: {bot_pick} {bot_name}\n\n**{result}**{bet_line}", - "bet_win": "\n+{amount} · Saldo: {balance}", - "bet_lose": "\n-{amount} · Saldo: {balance}", - "bet_tie": "\nViik - panus tagasi · Saldo: {balance}", - "challenge_desc": "{challenger} kutsub vĂ€lja {opponent}{bet}\n\n{opponent}, kas vĂ”tad vastu? âŹ‡ïž", - "challenge_bet": " · Panus: {bet}", - "challenge_footer": "VĂ€ljakutse aegub 60 sekundi pĂ€rast", - "duel_active_desc": "{a} vs {b}{bet}\n\nMĂ”lemad mĂ€ngijad valivad DM-is. ⏳", - "duel_active_bet": " · Panus: {bet}", - "duel_dm": "⚔ **KPK vs {opponent}**{bet}\nTee oma valik! âŹ‡ïž", - "duel_dm_bet": "\nPanus: {bet}", - "duel_waiting": "Valisid {choice} **{name}**. Ootame vastase valikut... ⏳", - "duel_result_a": "**⚔ KPK tulemus vs {opponent}**\nSinu valik: {pick_a} {name_a}\nVastase valik: {pick_b} {name_b}\n\n**{result}**{bet_line}\nSaldo: {balance}", - "duel_verdict_win": "🏆 **{name}** vĂ”itis!", - "duel_verdict_tie": "đŸ€ Viik!", - "duel_broke": "\n⚠ Makse ebaĂ”nnestus (ebapiisav saldo)", - "duel_expire_dm": "⏰ KPK duell aegus - keegi ei teinud valikut Ă”igeaegselt.", - "duel_expire_desc": "{a} vs {b}\n\nMĂ€ng aegus - valik jĂ€i tegemata.", - "duel_dm_fail": "Ei saanud DM saata: **{names}**. Kontrolli privaatsussĂ€tteid.", - "duel_decline": "**{name}** keeldus vĂ€ljakutsest.", - "duel_no_answer": "**{name}** ei vastanud vĂ€ljakutsele.", - "duel_insufficient": "{mention}-l pole piisavalt TipiCOINi panuseks.", - "duel_done_desc": "{a} {pick_a} vs {pick_b} {b}\n\n**{verdict}**\n\n{name_a}: {bal_a}\n{name_b}: {bal_b}", - "server_only": "Seda kĂ€sku saab kasutada ainult serveris.", - "btn_accept": "VĂ”ta vastu ✅", - "btn_decline": "Keeldu ❌", - "btn_rock": "đŸȘš Kivi", - "btn_paper": "📄 Paber", - "btn_scissors": "✂ KÀÀrid", -} - -RPS_CHOICES: dict[str, str] = { - "đŸȘš": "Kivi", - "📄": "Paber", - "✂": "KÀÀrid", -} - -# --------------------------------------------------------------------------- -# /request UI strings -# --------------------------------------------------------------------------- - -REQUEST_UI: dict[str, str] = { - "modal_title": "Rahasta taotlust", - "modal_label": "Kui palju soovid panustada?", - "btn_fund": "Rahasta 💾", - "btn_funded": "Rahastatud ✅", - "btn_fund_remaining": "Rahasta 💾 ({remaining}⏥ jÀÀnud)", - "funded_line": "✅ **{name}** panustas {amount}", - "funded_full": "\n🎯 Taotlus tĂ€ielikult tĂ€idetud!", - "funded_partial": "\n💰 JÀÀnud: {remaining}", - "audience_all": "Avatud kĂ”igile rahastajatele 🌍", - "audience_targeted": "Suunatud: **{name}**", - "desc": "**{requester}** palub {amount}\n\n📋 **PĂ”hjus:** {reason}\n\n{audience}", - "footer": "Taotlus aegub 5 minuti pĂ€rast", -} - -BJ_UI: dict[str, str] = { - "dealer": "Dealer", - "player": "Sina", - "hand_n": "KĂ€si {n}", - "hand_active": "▶ **KĂ€si {n}**", - "hand_pending": "KĂ€si {n} *...*", - "bust": " đŸ’„", - "balance_line": " · Saldo: {balance}", -} - -# --------------------------------------------------------------------------- -# Prestige system strings -# --------------------------------------------------------------------------- - -PRESTIGE_SHOP_NAMES: dict[str, str] = { - "coin_mult": "MĂŒndiboost", - "exp_mult": "EXP-boost", - "daily_plus": "PĂ€evabonus+", - "work_plus": "Töötaja+", -} - -PRESTIGE_SHOP_DESCRIPTIONS: dict[str, str] = { - "coin_mult": "KĂ”ik TipiCOINide teenimisallikad (daily, töö, kerja, kala) teenivad +8% rohkem iga taseme kohta. Max 5 taset → +40%.", - "exp_mult": "KĂ”ik EXP allikad teenivad +8% rohkem iga taseme kohta. Max 5 taset → +40%.", - "daily_plus": "PĂ€evase boonuse alussumma tĂ”useb +20% iga taseme kohta. Max 3 taset → +60%.", - "work_plus": "/work teenib +20% rohkem iga taseme kohta. Max 3 taset → +60%.", -} - -PRESTIGE_UI: dict[str, str] = { - "confirm_desc": ( - "Oled tasemel **{level}** ({exp} EXP).\n\n" - "PrestiiĆŸi korral saad **{pp}** " + E["TipiFIRE"] + " ja kĂ”ik lĂ€htestub:\n" - "‱ Saldo, EXP, esemed, ooteajad\n\n" - "**Kalakogu jÀÀb alles!**\n\nKas oled kindel?" - ), - "btn_confirm": "đŸ”„ Jah, prestiiĆŸi!", - "btn_cancel": "❌ TĂŒhista", - "btn_tab_status": "⭐ Prestiiz", - "btn_tab_shop": "đŸ›ïž Uuendused", - "success_desc": ( - "Said **{pp}** " + E["TipiFIRE"] + "\n" - "PrestiiĆŸi tase: **{level}**\n" - "Kogutud PP: **{total_pp}** " + E["TipiFIRE"] + "\n\n" - "*KĂ”ik lĂ€htestati. Alusta otsast!*" - ), - "too_low_desc": "PrestiiĆŸi jaoks vajad taset **{required}** (sul on tase {level}).", - "shop_desc": "Sul on **{pp}** " + E["TipiFIRE"] + " · Vajuta nuppu uuenduse ostmiseks", - "shop_maxed": "✅ Max", - "shop_level_fmt": "Tase {cur}/{max}", - "shop_cost_fmt": "{cost} " + E["TipiFIRE"], - "buy_success_desc":"**{name}** uuendatud tasemele **{new_level}/{max_level}**!\nPP alles: **{pp}** " + E["TipiFIRE"], - "buy_no_pp": E["TipICRY"] + " Sul pole piisavalt PP. Sul on **{have}**, vajad **{need}** " + E["TipiFIRE"] + ".", - "buy_maxed": "❌ See uuendus on juba maksimumtasemel.", - "buy_not_found": "❌ Sellist uuendust ei leitud. Vaata `/prestigeshop`.", - "rank_line": E["TipiFIRE"] + " PrestiiĆŸ **{level}** · {pp} PP", - "rank_season": "🏆 Hooaja EXP: **{exp}**", - "btn_buy_upgrade": "{emoji} {name} +1 ({cost} PP)", - "status_footer": "⭐ PrestiiĆŸ {level} · {pp} PP", -} - -# --------------------------------------------------------------------------- -# Fishing system strings -# --------------------------------------------------------------------------- - -FISH_NAMES: dict[str, str] = { - "sarj": "SĂ€rg", - "ahven": "Ahven", - "koger": "Koger", - "viidikas": "Viidikas", - "latikas": "Latikas", - "karpkala": "Karpkala", - "linask": "Linask", - "haug": "Haug", - "angerjas": "Angerjas", - "siig": "Siig", - "forell": "Forell", - "koha": "Koha", - "tougjas": "TĂ”ugjas", - "lohe": "LĂ”he", - "vimb": "Vimb", -} - -FISH_RARITY_NAMES: dict[str, str] = { - "common": "Tavaline", - "uncommon": "Ebatavaline", - "rare": "Haruldane", - "epic": "Eepiline", - "legendary": "Legendaarne", -} - -FISH_RARITY_EMOJI: dict[str, str] = { - "common": "🐟", - "uncommon": "🐠", - "rare": "🎣", - "epic": "⭐", - "legendary": "🌟", -} - -FISH_JUNK_LINES: list[str] = [ - "Sa saad... **vana saabas**. Klassika.", - "Õnnitlused, leidsid **kasutatud autorehvi**. Keskkond tĂ€nab sind... mitte.", - "Taas ĂŒks **klaaspudel** rohkem jĂ”es.", - "**Vana poes kĂ€imise kott**! Hoidis aega hĂ€sti.", - "**Roostes konserv** - ilma sildita. Parem mitte teada, mis sees on.", - "**Ummistunud drenaaĆŸitoru**. Keegi oli hoolimatu.", - "**TĂŒhi rahakott**. Kellegi pĂ€ev lĂ€ks halvemaks kui sinu oma.", - "**Vana CD-plaat** - Evanescence, 2003. Heas seisukorras.", - "Sa said **kivikese**. Ilus kivikene. Aga siiski kivikene.", - "**Kaotsi lĂ€inud droon**. GPS ei tööta, aku tĂŒhi.", -] - -FISH_UI: dict[str, str] = { - "btn_wait": "🎣 Oota nĂ€kkamist...", - "btn_bite": "🐟 TÕMBA!", - "btn_sell": "💰 MĂŒĂŒ", - "btn_keep": "🎒 Hoia", - "cast_desc": "Viskad Ă”nge vette. Oota, kuni kala nĂ€kkab...\n\n-# Vajuta nuppu, kui kala nĂ€kkab!", - "bite_desc": "**KALA NÄKKAB!** TĂ”mba kiiresti! ⚡\n\n-# Sul on 2 sekundit!", - "escape_desc": "Liiga hilja - kala lipsas minema. Proovi jĂ€rgmine kord kiiremini!", - "junk_desc": "{text}\n\n-# Saldo: {balance}", - "catch_desc": "**{name}** · {weight}g · +{exp} EXP\n-# Kas mĂŒĂŒd kohe ({value}) vĂ”i hoiad inventaris?", - "catch_sold": "**{name}** · {weight}g\n\n+{coins} · +{exp} EXP\nSaldo: {balance}", - "catch_kept": "**{name}** · {weight}g lisatud inventarisse. *(+{exp} EXP)*", - "new_fish": "\n✹ **Uus kala kalakogusse lisatud!**", - "too_early": "❌ Kala pole veel nĂ€kkanud! Oota...", - "book_caught": "PĂŒĂŒtud kalaliike: **{caught}/{total}**", - "book_yes": "✅ {emoji} **{name}** *({rarity})* · {count}×{inv}", - "book_inv": " *(inventaris: {n})*", - "book_no": "❓ **???** *({rarity})*", - "book_footer": "LehekĂŒlg {page}/{total_pages} · {caught}/{total} liiki", - "book_empty": "Sa pole veel ĂŒhtegi kala pĂŒĂŒdnud! Kasuta `/fish`.", - "inv_empty": "Sinu kalainventaar on tĂŒhi! Kasuta `/fish` kala pĂŒĂŒdmiseks.", - "inv_header": "Sul on **{count}** kala inventaris *(kokku vÀÀrt {total_value})*", - "inv_entry": "{emoji} **{name}** · {weight}g · {value}", - "inv_sold_all": "MĂŒĂŒsid **{count}** kala kokku {coins} eest!\nSaldo: {balance}", - "inv_none": "Inventaaris pole midagi mĂŒĂŒa.", -} From 0ea5580e15dbcb133ed1f42cbc67b1d74be08954 Mon Sep 17 00:00:00 2001 From: Rene Arumetsa Date: Wed, 19 Aug 2026 19:12:48 +0300 Subject: [PATCH 04/21] Refactor strings.py into modules --- core/sheets.py | 24 ++- strings/__init__.py | 186 +++++++++++++++++++ strings/admin.py | 125 +++++++++++++ strings/commands.py | 223 ++++++++++++++++++++++ strings/common.py | 247 ++++++++++++++++++++++++ strings/economy.py | 443 ++++++++++++++++++++++++++++++++++++++++++++ strings/fishing.py | 86 +++++++++ strings/games.py | 307 ++++++++++++++++++++++++++++++ strings/member.py | 96 ++++++++++ 9 files changed, 1733 insertions(+), 4 deletions(-) create mode 100644 strings/__init__.py create mode 100644 strings/admin.py create mode 100644 strings/commands.py create mode 100644 strings/common.py create mode 100644 strings/economy.py create mode 100644 strings/fishing.py create mode 100644 strings/games.py create mode 100644 strings/member.py diff --git a/core/sheets.py b/core/sheets.py index aac6cdc..cdcbd28 100644 --- a/core/sheets.py +++ b/core/sheets.py @@ -7,12 +7,15 @@ Pure-cache helpers (get_cache, find_*) remain sync. """ import asyncio +import logging import gspread from google.oauth2.service_account import Credentials import config +log = logging.getLogger(__name__) + # Scopes needed: read + write to Sheets SCOPES = [ "https://www.googleapis.com/auth/spreadsheets", @@ -69,12 +72,25 @@ def _get_worksheet() -> gspread.Worksheet: def _ensure_headers(ws: gspread.Worksheet) -> None: - """If the sheet is empty or missing headers, write them (headers are in row 1).""" + """Verify the header row matches what we expect. + + The production sheet is owner-managed and its header row (row 1) is a + protected range, so the bot must NOT write to it — attempting to do so + raises `APIError [400]: You are trying to edit a protected cell or object` + and aborts the whole refresh. We only log a mismatch so it can be fixed + by hand; column lookups still work as long as the headers we rely on exist. + """ existing = ws.row_values(1) if existing != EXPECTED_HEADERS: - for col_idx, header in enumerate(EXPECTED_HEADERS, start=1): - if col_idx > len(existing) or existing[col_idx - 1] != header: - ws.update_cell(1, col_idx, header) + missing = [h for h in EXPECTED_HEADERS if h not in existing] + log.warning( + "Sheet header row does not match EXPECTED_HEADERS " + "(missing/renamed: %s). Expected %s, found %s. " + "Not writing to the (protected) header row; fix it manually if needed.", + missing or "none — order/whitespace differs", + EXPECTED_HEADERS, + existing, + ) def _refresh_sync() -> list[dict]: diff --git a/strings/__init__.py b/strings/__init__.py new file mode 100644 index 0000000..5f34e6c --- /dev/null +++ b/strings/__init__.py @@ -0,0 +1,186 @@ +"""All user-facing text for TipiLAN Bot. + +Strings live in domain submodules (common, commands, member, economy, admin, +games, fishing) and are re-exported here, so ``import strings`` and +``strings.NAME`` keep working unchanged. Edit the relevant submodule to change +any message, description, or flavour text without touching any logic code.""" + +from .common import ( + MSG_BANNED, + MSG_SPAM_JAIL, + MSG_PONG, + MSG_RESTART_DONE, + MSG_RESTARTING, + MSG_SHUTTING_DOWN, + MSG_PAUSED, + MSG_UNPAUSED, + MSG_MAINTENANCE, + MSG_SYNC_DONE, + MSG_REMINDER, + MSG_LEVELUP, + MSG_LEVELUP_ROLE, + REMINDER_OPTS, + TITLE, + ERR, + CD_MSG, + REMINDERS_UI, + SEND_UI, + PATCHNOTES_UI, + STATUS_UI, +) + +from .commands import ( + CMD, + OPT, + HELP_CATEGORIES, + HELP_UI, +) + +from .member import ( + MEMBER_UI, + MEMBER_FIELDS, + ECONOMYSETUP_UI, + CHANNEL_UI, + BIRTHDAY_UI, + BIRTHDAY_MONTHS, + CHECK_UI, +) + +from .economy import ( + WORK_JOBS, + BEG_LINES, + BEG_JAIL_LINES, + CRIME_WIN, + CRIME_LOSE, + QUEST_UI, + QUEST_DESCRIPTIONS, + SHOP_UI, + ITEM_DESCRIPTIONS, + JAILED_UI, + SHOP_BTN, + DAILY_UI, + STATS_UI, + PROFILE_UI, + BALANCE_UI, + COOLDOWNS_UI, + RANK_UI, + WORK_UI, + BEG_UI, + CRIME_UI, + ROB_UI, + GIVE_UI, + BUY_UI, + LEADERBOARD_UI, + REQUEST_UI, +) + +from .admin import ( + ADMINVIEW_UI, + ADMIN, + SEASON, + PRESTIGE_SHOP_NAMES, + PRESTIGE_SHOP_DESCRIPTIONS, + PRESTIGE_UI, +) + +from .games import ( + SLOTS_TIERS, + ROULETTE, + HEIST_STORY, + HEIST_UI, + BJ, + JAILBREAK_UI, + SLOTS_UI, + RPS_UI, + RPS_CHOICES, + BJ_UI, +) + +from .fishing import ( + FISH_NAMES, + FISH_RARITY_NAMES, + FISH_RARITY_EMOJI, + FISH_JUNK_LINES, + FISH_UI, +) + +__all__ = [ + 'MSG_BANNED', + 'MSG_SPAM_JAIL', + 'MSG_PONG', + 'MSG_RESTART_DONE', + 'MSG_RESTARTING', + 'MSG_SHUTTING_DOWN', + 'MSG_PAUSED', + 'MSG_UNPAUSED', + 'MSG_MAINTENANCE', + 'MSG_SYNC_DONE', + 'MSG_REMINDER', + 'MSG_LEVELUP', + 'MSG_LEVELUP_ROLE', + 'REMINDER_OPTS', + 'TITLE', + 'ERR', + 'CD_MSG', + 'REMINDERS_UI', + 'SEND_UI', + 'PATCHNOTES_UI', + 'STATUS_UI', + 'CMD', + 'OPT', + 'HELP_CATEGORIES', + 'HELP_UI', + 'MEMBER_UI', + 'MEMBER_FIELDS', + 'ECONOMYSETUP_UI', + 'CHANNEL_UI', + 'BIRTHDAY_UI', + 'BIRTHDAY_MONTHS', + 'CHECK_UI', + 'WORK_JOBS', + 'BEG_LINES', + 'BEG_JAIL_LINES', + 'CRIME_WIN', + 'CRIME_LOSE', + 'QUEST_UI', + 'QUEST_DESCRIPTIONS', + 'SHOP_UI', + 'ITEM_DESCRIPTIONS', + 'JAILED_UI', + 'SHOP_BTN', + 'DAILY_UI', + 'STATS_UI', + 'PROFILE_UI', + 'BALANCE_UI', + 'COOLDOWNS_UI', + 'RANK_UI', + 'WORK_UI', + 'BEG_UI', + 'CRIME_UI', + 'ROB_UI', + 'GIVE_UI', + 'BUY_UI', + 'LEADERBOARD_UI', + 'REQUEST_UI', + 'ADMINVIEW_UI', + 'ADMIN', + 'SEASON', + 'PRESTIGE_SHOP_NAMES', + 'PRESTIGE_SHOP_DESCRIPTIONS', + 'PRESTIGE_UI', + 'SLOTS_TIERS', + 'ROULETTE', + 'HEIST_STORY', + 'HEIST_UI', + 'BJ', + 'JAILBREAK_UI', + 'SLOTS_UI', + 'RPS_UI', + 'RPS_CHOICES', + 'BJ_UI', + 'FISH_NAMES', + 'FISH_RARITY_NAMES', + 'FISH_RARITY_EMOJI', + 'FISH_JUNK_LINES', + 'FISH_UI', +] diff --git a/strings/admin.py b/strings/admin.py new file mode 100644 index 0000000..7dd90f5 --- /dev/null +++ b/strings/admin.py @@ -0,0 +1,125 @@ +"""Economy admin responses, season reset, and prestige system. + +Auto-split from the original strings.py; edit strings here. +""" + +from core.emoji import EMOJI as E + +__all__ = [ + 'ADMINVIEW_UI', + 'ADMIN', + 'SEASON', + 'PRESTIGE_SHOP_NAMES', + 'PRESTIGE_SHOP_DESCRIPTIONS', + 'PRESTIGE_UI', +] + +ADMINVIEW_UI: dict[str, str] = { + "title": "🔍 {name} - majandusandmed", + "banned_yes": "đŸš« JAH", + "banned_no": "✅ Ei", + "f_balance": "💰 Saldo", + "f_exp": "📊 EXP / Tase", + "f_streak": "đŸ”„ Streak", + "f_banned": "đŸš« Keelatud", + "f_jailed": "🚔 Vangis kuni", + "f_prestige": "đŸ”„ PrestiiĆŸ", + "f_items": "🎒 Esemed", + "f_uses": "🔱 Kasutused", + "f_fish": "🎣 Kala", + "f_last_daily": "⏱ Viimati daily", + "f_last_work": "⏱ Viimati work", + "f_last_crime": "⏱ Viimati crime", + "f_last_fish": "⏱ Viimati fish", + "footer": "ID: {uid}", + "exp_val": "{exp} EXP (Tase {level})", + "prestige_val": "PrestiiĆŸ {level} · {pp} PP", + "fish_val": "{caught} pĂŒĂŒtud · {inv} inventaris", +} + +# --------------------------------------------------------------------------- +# Admin command responses and DMs +# --------------------------------------------------------------------------- + +ADMIN: dict[str, str] = { + "coins_done": "{emoji} **{name}**: {verb} {coin} → saldo **{balance}**\n📝 PĂ”hjus: {reason}", + "coins_dm": "{emoji} Admin muutis sinu TipiCOINide saldot: **{verb} {coin}**\n📝 PĂ”hjus: *{reason}*\nUus saldo: **{balance} {coin}**", + "jail_done": "🚔 **{name}** on vangistatud {minutes} minutiks (vabaneb {ts}).\n📝 PĂ”hjus: {reason}", + "jail_dm": "🚔 Admin saatis sind vanglasse **{minutes} minutiks**.\n📝 PĂ”hjus: *{reason}*\nVabaneb {ts}.", + "unjail_done": "✅ **{name}** on vanglast vabastatud.", + "unjail_dm": "✅ Admin vabastas sind vanglast.", + "ban_done": "đŸš« **{name}** keelati majandussĂŒsteemis osaleda.\n📝 PĂ”hjus: {reason}", + "ban_dm": "đŸš« Sul keelati TipiBOTi majandussĂŒsteemis osalemine.\n📝 PĂ”hjus: *{reason}*", + "unban_done": "✅ **{name}** on majandussĂŒsteemi osalemise keelust eemaldatud.", + "unban_dm": "✅ Sinu TipiBOTi majandussĂŒsteemis osalemise keeld on tĂŒhistatud. Saad taas kĂ€ske kasutada.", + "reset_done": "đŸ—‘ïž **{name}** majandusandmed on lĂ€htestatud.\n📝 PĂ”hjus: {reason}", + "reset_dm": "đŸ—‘ïž Admin lĂ€htestas sinu TipiBOTi majandusandmed (saldo, esemed, streak).\n📝 PĂ”hjus: *{reason}*", + "exp_done": "{emoji} **{name}**: {verb} EXP → kokku **{exp} EXP** (Tase {level}).\n📝 PĂ”hjus: {reason}", + "exp_dm": "{emoji} Admin muutis sinu EXP-i: **{verb} EXP**\n📝 PĂ”hjus: *{reason}*\nUus EXP: **{exp}** (Tase {level})", + "item_given": "✅ **{item}** antud kasutajale **{name}** (tasuta).", + "item_removed":"đŸ—‘ïž **{item}** eemaldatud kasutajalt **{name}**.", + "item_invalid":"❌ Tundmatu ese: `{item_id}`. Kontrolli `/shop` eseme ID-d.", + "item_not_owned": "❌ **{name}** ei oma eset `{item_id}`.", + "item_dm_given": "✅ Admin andis sulle eseme: **{item}**.", + "item_dm_removed":"đŸ—‘ïž Admin eemaldas sult eseme: **{item}**.", +} + +# --------------------------------------------------------------------------- +# /adminseason strings +# --------------------------------------------------------------------------- + +SEASON: dict[str, str] = { + "top_header": "**Top mĂ€ngijad EXP jĂ€rgi:**", + "no_players": "Keegi ei teeninud EXP-i.", + "entry": "{prefix} {name} - {exp} EXP *(Tase {level})*", + "footer": "KĂ”igi EXP, mĂŒndid ja esemed lĂ€htestati. Uus hooaeg algab kohe!", + "done": "✅ Hooaeg lĂ”petatud - EXP, mĂŒndid ja esemed lĂ€htestatud.", +} + +# --------------------------------------------------------------------------- +# Prestige system strings +# --------------------------------------------------------------------------- + +PRESTIGE_SHOP_NAMES: dict[str, str] = { + "coin_mult": "MĂŒndiboost", + "exp_mult": "EXP-boost", + "daily_plus": "PĂ€evabonus+", + "work_plus": "Töötaja+", +} +PRESTIGE_SHOP_DESCRIPTIONS: dict[str, str] = { + "coin_mult": "KĂ”ik TipiCOINide teenimisallikad (daily, töö, kerja, kala) teenivad +8% rohkem iga taseme kohta. Max 5 taset → +40%.", + "exp_mult": "KĂ”ik EXP allikad teenivad +8% rohkem iga taseme kohta. Max 5 taset → +40%.", + "daily_plus": "PĂ€evase boonuse alussumma tĂ”useb +20% iga taseme kohta. Max 3 taset → +60%.", + "work_plus": "/work teenib +20% rohkem iga taseme kohta. Max 3 taset → +60%.", +} +PRESTIGE_UI: dict[str, str] = { + "confirm_desc": ( + "Oled tasemel **{level}** ({exp} EXP).\n\n" + "PrestiiĆŸi korral saad **{pp}** " + E["TipiFIRE"] + " ja kĂ”ik lĂ€htestub:\n" + "‱ Saldo, EXP, esemed, ooteajad\n\n" + "**Kalakogu jÀÀb alles!**\n\nKas oled kindel?" + ), + "btn_confirm": "đŸ”„ Jah, prestiiĆŸi!", + "btn_cancel": "❌ TĂŒhista", + "btn_tab_status": "⭐ Prestiiz", + "btn_tab_shop": "đŸ›ïž Uuendused", + "success_desc": ( + "Said **{pp}** " + E["TipiFIRE"] + "\n" + "PrestiiĆŸi tase: **{level}**\n" + "Kogutud PP: **{total_pp}** " + E["TipiFIRE"] + "\n\n" + "*KĂ”ik lĂ€htestati. Alusta otsast!*" + ), + "too_low_desc": "PrestiiĆŸi jaoks vajad taset **{required}** (sul on tase {level}).", + "shop_desc": "Sul on **{pp}** " + E["TipiFIRE"] + " · Vajuta nuppu uuenduse ostmiseks", + "shop_maxed": "✅ Max", + "shop_level_fmt": "Tase {cur}/{max}", + "shop_cost_fmt": "{cost} " + E["TipiFIRE"], + "buy_success_desc":"**{name}** uuendatud tasemele **{new_level}/{max_level}**!\nPP alles: **{pp}** " + E["TipiFIRE"], + "buy_no_pp": E["TipICRY"] + " Sul pole piisavalt PP. Sul on **{have}**, vajad **{need}** " + E["TipiFIRE"] + ".", + "buy_maxed": "❌ See uuendus on juba maksimumtasemel.", + "buy_not_found": "❌ Sellist uuendust ei leitud. Vaata `/prestigeshop`.", + "rank_line": E["TipiFIRE"] + " PrestiiĆŸ **{level}** · {pp} PP", + "rank_season": "🏆 Hooaja EXP: **{exp}**", + "btn_buy_upgrade": "{emoji} {name} +1 ({cost} PP)", + "status_footer": "⭐ PrestiiĆŸ {level} · {pp} PP", +} diff --git a/strings/commands.py b/strings/commands.py new file mode 100644 index 0000000..7cc66e9 --- /dev/null +++ b/strings/commands.py @@ -0,0 +1,223 @@ +"""Slash-command and /help metadata (descriptions, options, categories). + +Auto-split from the original strings.py; edit strings here. +""" + +from core.emoji import EMOJI as E + +__all__ = [ + 'CMD', + 'OPT', + 'HELP_CATEGORIES', + 'HELP_UI', +] + +# --------------------------------------------------------------------------- +# Slash command descriptions (shown in Discord's autocomplete) +# --------------------------------------------------------------------------- + +CMD: dict[str, str] = { + "ping": "Vaata, kas bot on ĂŒleval", + "help": "Nimekiri kĂ”ikidest kĂ€skudest kategooriate kaupa", + "status": "NĂ€ita boti staatust ja ressursside kasutust", + "birthdays": "NĂ€ita kĂ”igi liikmete sĂŒnnipĂ€evi kuu jĂ€rgi", + "check": "Laadi andmed, tĂ€ida ID'd ja sĂŒnkroniseeri kĂ”ik liikmed", + "sync": "SĂŒnkroniseeri kĂ€sklused Discordi serveriga", + "member": "NĂ€ita liikme andmeid tabelist", + "restart": "Tee taaskĂ€ivitus botile", + "shutdown": "LĂŒlita bot vĂ€lja (ilma taaskĂ€ivituseta)", + "pause": "Peata / jĂ€tka kĂ”ik kĂ€sklused (hooldusreĆŸiim)", + "send": "Saada sĂ”num valitud kanalisse", + "profile": "Vaata oma profiili: saldo, tase, esemed, statistika ja kalakogu", + "balance": "Vaata enda (vĂ”i kellegi teise) TipiCOINide saldot", + "daily": "VĂ”ta enda pĂ€evane TipiCOINi boonus", + "work": "Tööta ja teeni TipiCOINe (1h ooteaeg)", + "beg": "Kerja TipiCOINe (5min ooteaeg)", + "crime": "Proovi oma Ă”nne kriminaalse tegevusega (2h ooteaeg)", + "rob": "Proovi kelleltki TipiCOINe varastada", + "heist": "Alusta grupirööv pangahoidlasse (min 2 mĂ€ngijat, max 8, 4h serveri ooteaeg)", + "roulette": "Panusta TipiCOINe punasele, mustale vĂ”i rohelisele (1/37 vĂ”imalus, 14x payout)", + "give": "Anna TipiCOINe teisele mĂ€ngijale", + "leaderboard": "TipiBOTi edetabel - kes on kĂ”ige rikkam?", + "shop": "Sirvi TipiBOTi poodi", + "buy": "Osta ese TipiBOTi poodist", + "rps": "Kivi-paber-kÀÀrid mĂ”ne teise mĂ€ngija vastu", + "slots": "Proovi oma Ă”nne TipiBOTi mĂ€nguautomaadiga", + "request": "Kerja TipiCOINe teistelt mĂ€ngijatelt - nagu crowdfunding, aga halvem", + "reminders": "Halda DM meeldetuletusi - kĂ”ik on vaikimisi sees, lĂŒlita need siin vĂ€lja", + "cooldowns": "Vaata kĂ”ikide kĂ€skude ooteaegu", + "jailed": "Vaata, kes on praegu vanglas", + "rank": "Vaata oma EXP, taset ja edetabeli positsiooni", + "stats": "Vaata oma mĂ€ngustatistikat", + "jailbreak": "Proovi vanglast pĂ”geneda kasutades tĂ€ringuid", + "adminseason": "[Admin] LĂ”peta vĂ”istlus, teavita vĂ”itjaid ja lĂ€htesta EXP", + "admincoins": "[Admin] Anna vĂ”i vĂ”ta TipiCOINe kasutajale/kasutajalt", + "adminjail": "[Admin] Saada kasutaja vangi", + "adminunjail": "[Admin] Vabasta kasutaja vangist", + "adminban": "[Admin] Keela kasutajal majandussĂŒsteemis osaleda", + "adminunban": "[Admin] Eemalda majandussĂŒsteemi keeld kasutajalt", + "adminreset": "[Admin] LĂ€htesta kasutaja majandusandmed", + "adminview": "[Admin] Vaata kasutaja majandusandmeid", + "adminexp": "[Admin] Anna v\u00f5i v\u00f5ta EXP kasutajalt", + "adminitem": "[Admin] Anna v\u00f5i eemalda ese kasutajalt (tasuta)", + "allowchannel": "[Admin] Lisa kanal, kus bot vĂ”ib vastata kĂ€sklustele", + "denychannel": "[Admin] Eemalda kanal lubatud kanalite nimekirjast", + "channels": "[Admin] NĂ€ita lubatud kanalite nimekirja", + "economysetup": "[Admin] Loo ja sea korda majandussĂŒsteemi rollid", + "blackjack": "MĂ€ngi blackjacki TipiBOTi vastu", + "prestige": "PrestiiĆŸi (nĂ”uab taset 30) ja teeni PrestiiĆŸipunkte", + "prestigeshop": "Vaata prestiiĆŸipoodi ja sinu uuenduste taset", + "prestigebuy": "Osta prestiiĆŸiuuendus PrestiiĆŸipunktide eest", + "fish": "Mine kalastama (interaktiivne mĂ€ng, 2min ooteaeg)", + "fishbook": "Vaata oma kalakogu ja kogutud kalaliike", + "fishsell": "MĂŒĂŒ kalu oma inventarist", + "patchnotes": "Vaata TipiBOTi viimaseid muudatusi ja uuendusi", + "quests": "Vaata oma pĂ€eva- ja nĂ€dalaĂŒlesandeid ning nĂ”ua auhinnad", +} + +# --------------------------------------------------------------------------- +# Option descriptions (shown next to each parameter in Discord) +# --------------------------------------------------------------------------- + +OPT: dict[str, str] = { + "admin_kasutaja": "Kasutaja", + "admin_pĂ”hjus": "PĂ”hjus (saadetakse kasutajale DM kaudu)", + "admincoins_kogus": "Positiivne = anna, negatiivne = vĂ”ta", + "adminjail_minutid": "Vangistamise kestus minutites", + "balance_kasutaja": "MĂ€ngija, kelle saldot vaadata (vaikimisi sina)", + "roulette_panus": "Panus TipiCOINides ('all' = kogu saldo)", + "roulette_vĂ€rv": "Punane, must vĂ”i roheline", + "give_kasutaja": "Kellele annad?", + "give_summa": "Kui palju annad? ('all' = kogu saldo)", + "buy_ese": "Eseme nimi (vaata /shop)", + "rps_panus": "Valikuline TipiCOINide panus ('all' = kogu saldo)", + "rps_vastane": "VĂ€ljakutse teisele mĂ€ngijale (PvP)", + "slots_panus": "Panus TipiCOINides ('all' = kogu saldo)", + "request_summa": "Kui palju TipiCOINe vajad?", + "request_pĂ”hjus": "Miks sa vajad TipiCOINe?", + "request_sihtmĂ€rk": "Valikuline: kellelt tĂ€pselt palud (teised ei saa toetada)", + "send_kanal": "Kanal, kuhu sĂ”num saata", + "send_sĂ”num": "SĂ”num, mida saata", + "allowchannel_kanal": "Kanal, kus bot vĂ”ib vastata kĂ€sklustele", + "denychannel_kanal": "Kanal, kus bot ei vĂ”i vastata kĂ€sklustele", + "rob_sihtmĂ€rk": "Kellelt röövid?", + "member_user": "MĂ€ngija, kelle infot tahad vaadata", + "rank_kasutaja": "MĂ€ngija, kelle taset tahad vaadata (vaikimisi sina)", + "stats_kasutaja": "MĂ€ngija, kelle statistikat tahad vaadata (vaikimisi sina)", + "adminseason_top_n": "Kui palju mĂ€ngijaid vĂ”itis (vaikimisi 10)", + "blackjack_panus": "Panus TipiCOINides ('all' = kogu saldo)", + "prestigebuy_upgrade": "Uuenduse ID (vaata /prestigeshop)", + "fishbook_kasutaja": "MĂ€ngija, kelle kalakogu vaadata (vaikimisi sina)", + "profile_kasutaja": "MĂ€ngija, kelle profiili vaadata (vaikimisi sina)", + "adminexp_kogus": "Positiivne = anna, negatiivne = vĂ”ta", + "adminitem_ese": "Eseme ID (kasutatav sisse, vaata /shop)", + "adminitem_tegevus": "'anna' vĂ”i 'eemalda'", +} + +# --------------------------------------------------------------------------- +# /help categories +# --------------------------------------------------------------------------- + +HELP_CATEGORIES: dict[str, dict] = { + "ĂŒldine": { + "label": "đŸ€– Üldine", + "description": "Üldised kĂ€sklused", + "color": 0x57F287, + "fields": [ + ("/ping", "Vaata, kas bot on ĂŒleval"), + ("/status", "NĂ€ita boti staatust ja ressursside kasutust"), + ("/help", "Nimekiri kĂ”ikidest kĂ€skudest kategooriate kaupa"), + ("/birthdays", "NĂ€ita kĂ”igi liikmete sĂŒnnipĂ€evi kuu jĂ€rgi"), + ], + }, + "tipibot": { + "label": "đŸȘ™ TipiBOT", + "description": "TipiCOIN majandus", + "color": 0xF4C430, + "fields": [ + ("/profile [@user]", "Saldo, tase, EXP progress, prestiiz - kĂ”ik ĂŒhes kohas. Nupud: Esemed · Statistika · Kalakogu."), + ("/cooldowns", "Vaata kĂ”ikide kĂ€skude ooteaegu. NĂ€itab ka vangla ooteaega."), + ("/daily", "VĂ”ta enda pĂ€evane TipiCOINide boonus. 20h ooteaeg. Streak'i boonus: 3d=+50%, 7d=+100%, 14d=+200%."), + ("/work", "Tööta ja teeni TipiCOINe (1h ooteaeg)"), + ("/beg", "Kerja TipiCOINe (5min ooteaeg)"), + ("/crime", "Proovi oma Ă”nne kriminaalse tegevusega. 60% edu, 40% trahv + 30min vanglas. 2h ooteaeg."), + ("/rob @user", "Proovi kelleltki TipiCOINe varastada. 45% edu. EbaĂ”nnestumisel saad trahvi."), + ("/heist", "Alusta grupiröövi pangahoidlasse. Min 2 mĂ€ngijat, max 8. 5 min ĂŒhinemisaeg. Õnnestumisel jagatakse saak vĂ”rdselt - ebaĂ”nnestumisel 1h 30min vangis + trahv. 4h serveri ooteaeg (ei ole isiklik)."), + ("/jailbreak", "Proovi vanglas olles tĂ€ringuid visata, et duublit saada (3 katset). Duubli korral saad vabaks. EbaĂ”nnestumisel saad valida: maksa kautsjon (20-30% saldost, min 350 ⏥) vĂ”i jÀÀ vanglasse kuni aja lĂ”puni."), + ("/give @user ", "Anna TipiCOINe teisele mĂ€ngijale"), + ("/quests", "Vaata oma pĂ€eva- ja nĂ€dalaĂŒlesandeid ning nĂ”ua auhinnad (uueneb iga pĂ€ev/nĂ€dal)."), + ("/leaderboard", "TipiBOTi edetabel - kes on kĂ”ige rikkam?"), + ("/shop", "Sirvi TipiBOTi poodi"), + ("/buy ", "Osta ese TipiBOTi poodist"), + ("/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."), + ], + }, + "shop": { + "label": "đŸ›ïž Pood", + "description": "TipiBOTi poe esemed ja nende efektid", + "color": 0xF4C430, + "fields": [ + (f"{E['TipiHIIR']} MĂ€ngurihiir - 500 ⏥", "Teeni töötades 50% rohkem TipiCOINe."), + (f"{E['TipiMATT']} XL hiirematt - 600 ⏥", "Kerjamise ooteaeg 5min → 3min."), + (f"{E['TipiKLAPID']} KĂ”rvaklapid - 1200 ⏥", "PĂ€evase boonuse ooteaeg 20h → 18h."), + (f"{E['TipiPILET']} LAN pilet (2025) - 1200 ⏥", "PĂ€evane boonus on duubeldatud."), + (f"{E['TipiVAC']} Anticheat - 750 ⏥", "Röövimine sinu vastu ebaĂ”nnestub. PĂ€rast 2 kasutust pead ostma uue."), + (f"{E['TipiBULL']} Red Bull - 800 ⏥", "30% tĂ”enĂ€osus, et teenid töötades 3x rohkem."), + (f"{E['TipiLAP']} Botikoobas - 1500 ⏥", "RTX 5090 jooksutab botte 24/7. PĂ€evane boonus genereerib 5% intressi sinu saldo pealt."), + (f"{E['TipiLAUD']} Reguleeritav laud - 3500 ⏥ *(T2)*", "/work teenib 25% rohkem (stackib mĂ€ngurihiirega)."), + (f"{E['TipiSERVER']} Jellyfin server - 4000 ⏥ *(T2)*", "Röövimise edu tĂ”enĂ€osus 45% → 60%."), + (f"{E['TipiMIC']} Mikrofon - 2800 ⏥ *(T2)*", "Teeni 30% rohkem eduka /crime puhul."), + (f"{E['TipiKLAVA']} Mehhaaniline klaviatuur - 1800 ⏥ *(T2)*", "/beg teenib 2x rohkem."), + (f"{E['TipiMONITOR']} Ultralai monitor - 2500 ⏥ *(T2)*", "/work ooteaeg: 1h → 40min."), + (f"{E['TipiCAT']} CAT6 netikaabel - 3500 ⏥ *(T2)*", "/crime edu tĂ”enĂ€osus tĂ”useb 60% → 75%."), + (f"{E['TipiMONITOR2']} 360hz monitor - 7500 ⏥ *(T3)*", "MĂ€nguautomaadi jackpot 10x → 15x, kolmik 4x → 6x."), + (f"{E['TipiKARIKAS']} TipiLANi trofee - 6000 ⏥ *(T3)*", "Streak ei nulli, kui sa mĂ”ne pĂ€eva vahele jĂ€tad."), + (f"{E['TipiTOOL']} MĂ€nguritool - 9000 ⏥ *(T3)*", "/crime ebaĂ”nnestumine ei saada sind vanglasse."), + ], + }, + "games": { + "label": "🎼 MĂ€ngud", + "description": "LĂ”busad mĂ€ngud", + "color": 0x5865F2, + "fields": [ + ("/roulette ", "Panusta TipiCOINe punasele, mustale vĂ”i rohelisele (1/37 vĂ”imalus, 14x payout)"), + ("/rps [bet] [opponent]", "Kivi-paber-kÀÀrid mĂ”ne teise mĂ€ngija vastu"), + ("/slots ", "Proovi oma Ă”nne TipiBOTi mĂ€nguautomaadiga"), + ("/blackjack ", "MĂ€ngi blackjacki TipiBOTi vastu. Blackjack maksab 3:2. Kakskordistamine (double down) on vĂ”imalik vaid esimese kĂ€igu ajal."), + ], + }, + "admin": { + "label": "🔧 Admin", + "description": "Admin kĂ€sklused (peidetud tavaliste kasutajate eest)", + "color": 0xED4245, + "fields": [ + ("/check", "Laadi andmed, tĂ€ida ID'd ja sĂŒnkroniseeri kĂ”ik liikmed"), + ("/member @user", "NĂ€ita liikme andmeid tabelist"), + ("/sync", "SĂŒnkroniseeri kĂ€sklused Discordi serveriga"), + ("/restart", "Tee taaskĂ€ivitus botile"), + ("/send #channel message", "Saada sĂ”num valitud kanalisse"), + ("/admincoins @user ", "Anna vĂ”i vĂ”ta TipiCOINe kasutajale/kasutajalt"), + ("/adminjail @user ", "Saada kasutaja vangi"), + ("/adminunjail @user", "Vabasta kasutaja vangist"), + ("/adminban @user ", "Keela kasutajal majandussĂŒsteemis osaleda"), + ("/adminunban @user", "Eemalda majandussĂŒsteemi keeld kasutajalt"), + ("/adminreset @user ", "LĂ€htesta kasutaja majandusandmed"), + ("/adminview @user", "Vaata kasutaja majandusandmeid"), + ("/allowchannel #channel", "Lisa kanal, kus bot vĂ”ib vastata kĂ€sklustele"), + ("/denychannel #channel", "Eemalda kanal lubatud kanalite nimekirjast"), + ("/channels", "NĂ€ita lubatud kanalite nimekirja"), + ("/adminseason [top_n]", "LĂ”peta vĂ”istlus, teavita vĂ”itjaid ja lĂ€htesta EXP"), + ("/economysetup", "Loo ja sea korda majandussĂŒsteemi rollid (ECONOMY + taseme rollid) boti rolli alla"), + ], + }, +} + +# --------------------------------------------------------------------------- +# /help UI +# --------------------------------------------------------------------------- + +HELP_UI: dict[str, str] = { + "footer": "TipiBOT ‱ Vali kategooria allmenĂŒĂŒst", + "select_placeholder": "Vali kategooria...", +} diff --git a/strings/common.py b/strings/common.py new file mode 100644 index 0000000..965d007 --- /dev/null +++ b/strings/common.py @@ -0,0 +1,247 @@ +"""Shared / system-wide strings (messages, titles, errors, cooldowns). + +Auto-split from the original strings.py; edit strings here. +""" + +from core.emoji import EMOJI as E + +__all__ = [ + 'MSG_BANNED', + 'MSG_SPAM_JAIL', + 'MSG_PONG', + 'MSG_RESTART_DONE', + 'MSG_RESTARTING', + 'MSG_SHUTTING_DOWN', + 'MSG_PAUSED', + 'MSG_UNPAUSED', + 'MSG_MAINTENANCE', + 'MSG_SYNC_DONE', + 'MSG_REMINDER', + 'MSG_LEVELUP', + 'MSG_LEVELUP_ROLE', + 'REMINDER_OPTS', + 'TITLE', + 'ERR', + 'CD_MSG', + 'REMINDERS_UI', + 'SEND_UI', + 'PATCHNOTES_UI', + 'STATUS_UI', +] + +# --------------------------------------------------------------------------- +# Repeated system messages +# --------------------------------------------------------------------------- + +MSG_BANNED = "đŸš« Sul keelati TipiBOTi majandussĂŒsteemis osaleda." +MSG_SPAM_JAIL = "⚠ Liiga kiire! Automaatsed skriptid/spam on keelatud. Oled **30 minutit vangis**. Kasuta `/jailbreak`, et varem vĂ€lja pÀÀseda." +MSG_PONG = "🏓 Pong!" +MSG_RESTART_DONE = "✅ Bot on taaskĂ€ivitatud!" +MSG_RESTARTING = "🔄 TaaskĂ€ivitan..." +MSG_SHUTTING_DOWN = "⛔ LĂŒlitan boti vĂ€lja..." +MSG_PAUSED = "⏞ Bot on hooldusreĆŸiimis - kĂ”ik kĂ€sklused on peatatud." +MSG_UNPAUSED = "▶ HooldusreĆŸiim lĂ”petatud - kĂ€sklused on taas saadaval." +MSG_MAINTENANCE = "🔧 Bot on hetkel hoolduses. Proovi mĂ”ne hetke pĂ€rast uuesti." +MSG_SYNC_DONE = "✅ KĂ€sud sĂŒnkroniseeritud!" +MSG_REMINDER = "⏰ **/{cmd}** on taas kasutamiseks valmis!\n-# Meeldetuletuste haldamiseks kasuta serveris `/reminders`." +MSG_LEVELUP = "âŹ†ïž **{name}** jĂ”udis **{level}. tasemele**!{extra}" +MSG_LEVELUP_ROLE = " 🎊 Uus tiitel: **{role}**!" + +# --------------------------------------------------------------------------- +# /reminders dropdown options (cmd_key, label, description) +# --------------------------------------------------------------------------- + +REMINDER_OPTS: list[tuple[str, str, str]] = [ + ("daily", "📅 /daily", "PĂ€evane boonus (20t ooteaeg)"), + ("work", "đŸ’Œ /work", "Töö (1t ooteaeg, 40min monitoriga)"), + ("beg", "🙏 /beg", "Kerjamine (5min ooteaeg)"), + ("crime", "đŸŠč /crime", "Kuritegu (2t ooteaeg)"), + ("rob", "đŸ”« /rob", "Rööv (2t ooteaeg)"), + ("fish", "🎣 /fish", "KalapĂŒĂŒk (2min ooteaeg)"), +] + +# --------------------------------------------------------------------------- +# Embed titles +# --------------------------------------------------------------------------- + +TITLE: dict[str, str] = { + "daily": "📅 PĂ€evane boonus", + "work": "đŸ’Œ Töö", + "beg": "🙏 Kerjamine", + "crime_win": f"{E['TipiFIRE']} Kuritegu Ă”nnestus!", + "crime_fail": f"{E['TipiTROLL']} Vahele jĂ€id!", + "rob_win": f"{E['TipiFIRE']} Rööv Ă”nnestus!", + "rob_fail": f"{E['TipiTROLL']} Rööv ebaĂ”nnestus!", + "rob_anticheat": f"{E['TipiVAC']} Anticheat peatas sind!", + "jailbreak": "đŸŽČ Vanglast pĂ”genemine", + "jailbreak_free": f"đŸŽČ {E['TipiFIRE']} DUUBEL! Oled vaba!", + "jailbreak_fail": f"{E['TipICRY']} Kolm katset lĂ€bi!", + "jailbreak_miss": "đŸŽČ " + E["TipICRY"] + " Ei saanud duublit ({tries}/{max})", + "jailbreak_bail": "💾 Kautsjon", + "give": f"{E['TipiHEART']} TipiCOINi ĂŒlekanne", + "stats": "📊 MĂ€ngustatistika", + "leaderboard_coins": "đŸȘ™ TipiBOTi edetabel - MĂŒndid", + "leaderboard_exp": "📊 TipiBOTi edetabel - EXP / Tase", + "leaderboard_season": "🏆 TipiBOTi edetabel - Hooaja EXP", + "leaderboard_prestige": f"{E['TipiFIRE']} TipiBOTi edetabel - PrestiiĆŸ", + "leaderboard_wagered": "đŸŽČ TipiBOTi edetabel - HasartmĂ€ngud", + "leaderboard_fish": "🎣 TipiBOTi edetabel - KalapĂŒĂŒk", + "rps": "⚔ Kivi, Paber, KÀÀrid", + "rps_duel": "⚔ KPK duell", + "rps_duel_active": "⚔ KPK duell - kĂ€imas", + "rps_duel_done": "⚔ KPK duell - lĂ”ppenud", + "rps_duel_cancel": "⚔ KPK duell - tĂŒhistatud", + "rps_duel_expire": "⚔ KPK duell - aegus", + "rps_duel_decline": "⚔ KPK duell - keelduti", + "heist_lobby": "đŸ”« Grupirööv - kogunemine", + "heist_win": f"{E['TipiFIRE']} Grupirööv Ă”nnestus!", + "heist_fail": f"{E['TipiSKULL']} Grupirööv ebaĂ”nnestus!", + "heist_cancel": "đŸ”« Grupirööv tĂŒhistatud", + "request": f"{E['TipiHEART']} Rahataotlus", + "reminders": "⏰ Meeldetuletused", + "cooldowns": "⏱ Sinu ooteajad", + "adminseason": "🏆 Hooaeg lĂ”ppes!", + "economysetup": "⚙ Majanduse seadistamine", + "blackjack": "🃏 Blackjack", + "blackjack_bj": f"🃏 {E['TipiFIRE']} BLACKJACK!", + "blackjack_win": f"{E['TipiFIRE']} VĂ”itsid!", + "blackjack_lose": f"{E['TipiSKULL']} Kaotasid!", + "blackjack_bust": f"{E['TipiSKULL']} Üle 21 - kaotasid!", + "blackjack_push": "đŸ€ Viik!", + "blackjack_dbust": f"{E['TipiSKULL']} Üle 21 - mĂ”lemad kaotasid!", + "blackjack_dwin": f"{E['TipiFIRE']} Topeltpanus vĂ”itis!", + "prestige_confirm": "đŸ”„ PrestiiĆŸ - kinnita", + "prestige_success": E["TipiFIRE"] + " PrestiiĆŸ {level} saavutatud!", + "prestige_too_low": "❌ PrestiiĆŸ pole saadaval", + "prestige_shop": f"{E['TipiFIRE']} PrestiiĆŸipood", + "prestige_buy_ok": "✅ Uuendus ostetud!", + "fish_cast": "🎣 Otsid kala...", + "fish_bite": "🐟 KALA NÄKKAB!", + "fish_escape": "🎣 Kala pÀÀses!", + "fish_junk": "đŸ—‘ïž Ai ai ai...", + "fishbook": "📖 Kalakogu", + "quests": "🎯 Ülesanded", +} + +# --------------------------------------------------------------------------- +# Error / validation messages (use .format(**kwargs) for dynamic parts) +# --------------------------------------------------------------------------- + +ERR: dict[str, str] = { + "positive_bet": "❌ Panus peab olema positiivne.", + "positive_amount": "❌ Summa peab olema positiivne.", + "positive_duration": "❌ Aeg peab olema positiivne.", + "admincoins_zero": "Kogus ei tohi olla 0.", + "missing_perms": "❌ Sul on vaja **Rollide haldamise** Ă”igust selle kĂ€su kasutamiseks.", + "generic_error": "❌ Tekkis viga: ```{error}```", + "send_failed": "❌ SĂ”numi saatmine ebaĂ”nnestus: ```{error}```", + "rob_self": "❌ Sa ei saa iseennast röövida.", + "rob_bot": "❌ Botti ei saa röövida.", + "rob_no_house": "❌ Kassa pole veel avatud.", + "rob_house_blocked": "❌ Panga seif on liiga hĂ€sti kaitstud. Kasuta `/heist` selle rĂŒĂŒstamiseks.", + "rob_too_poor": "❌ **{name}** on liiga vaene röövimiseks.", + "rob_target_jailed": "❌ **{name}** on vanglas - vabad inimesed ei saa vanglas olevaid röövida.", + "heist_active": "❌ Serveris on juba aktiivne grupirööv kĂ€imas! Oota, kuni see lĂ”peb.", + "heist_full": "❌ Grupirööv on tĂ€is!", + "heist_min_players": "❌ Grupiröövi alustamiseks on vaja vĂ€hemalt **{min}** osalejat.", + "broke": E["TipICRY"] + " Sul pole piisavalt TipiCOINe. Saldo: {bal}", + "broke_need": E["TipICRY"] + " Sul pole piisavalt TipiCOINe. Vajad veel {need}.", + "item_owned": "❌ Sul on see ese juba olemas.", + "item_not_found": "❌ Eset ei leitud.", + "item_level_req": "🔒 Selle eseme ostmiseks vajad **taset {min_level}** (sul on tase {user_level}). Teeni EXP-id kĂ”iki kĂ€ske kasutades.", + "not_your_game": "❌ See pole sinu mĂ€ng!", + "game_in_progress": "❌ Sul on juba mĂ€ng kĂ€imas! LĂ”peta see enne.", + "not_your_challenge":"❌ See vĂ€ljakutse pole sulle!", + "not_your_menu": "❌ See ei ole sinu menĂŒĂŒ.", + "give_self": "❌ Sa ei saa iseendale TipiCOINe anda.", + "give_bot": "❌ Botile ei saa TipiCOINe anda.", + "give_jailed": "❌ Oled vangis - vanglas ei saa TipiCOINe anda. PÀÀsed vĂ€lja {ts}.", + "rps_self": "❌ Sa ei saa iseendale vĂ€ljakutset esitada.", + "rps_bot": "❌ Botid on KPK-is liiga head.", + "not_jailed": "❌ Sa pole praegu vangis.", + "not_in_leaderboard":"❌ Sind pole veel edetabelis.", + "admin_ban_bot": "❌ Botti ei saa bannida.", + "admin_reset_bot": "❌ Kassa andmeid ei saa lĂ€htestada.", + "member_not_found": "❌ **{name}** ei leitud tabelist.", + "request_self_fund": "❌ Sa ei saa oma taotlust ise rahastada.", + "request_self": "❌ Sa ei saa iseendalt anuda.", + "request_bot": "❌ Botid on teatavasti kitsid.", + "request_targeted": "❌ See taotlus on suunatud kasutajale **{name}**.", + "request_closed": "❌ See taotlus on juba rahastatud vĂ”i aegunud.", + "already_in_game": "❌ Sul on juba aktiivne mĂ€ng kĂ€imas!", + "invalid_amount": "❌ Sisesta kehtiv summa vĂ”i 'all'.", + "fund_range": "❌ Sisesta summa vahemikus 1-{max}.", + "channel_only": "❌ Boti kĂ€ske saab kasutada ainult nendes kanalites: {channels}", + "guild_only": "Seda kĂ€sku saab kasutada ainult serveris.", + "sheet_error": "❌ Tabeli laadimine ebaĂ”nnestus: ```{error}```", + "gamble_cooldown": "🎰 Oled just mĂ€nginud! Saad uuesti mĂ€ngida {ts}.", +} + +# --------------------------------------------------------------------------- +# Cooldown messages (use .format(ts=...) - ts is a Discord timestamp string) +# --------------------------------------------------------------------------- + +CD_MSG: dict[str, str] = { + "daily": "⏳ JĂ€rgmine boonus {ts}.", + "work": "⏳ Saad töötada {ts}.", + "beg": "⏳ Saad kerjata {ts}.", + "crime": "⏳ Saad uuesti proovida {ts}.", + "rob": "⏳ Saad uuesti röövida {ts}.", + "heist": "⏳ Saad uuesti heisti teha {ts}.", + "heist_global": "⏳ Pangahoidla alles kosub eelmisest röövist. JĂ€rgmine heist vĂ”imalik {ts}.", + "jailed": E["TipiTROLL"] + " Oled vangis! PÀÀsed vĂ€lja {ts}. Kasuta `/jailbreak`, et varem vĂ€lja pÀÀseda.", + "fish": "🎣 Saad uuesti kalastada {ts}.", +} +REMINDERS_UI: dict[str, str] = { + "select_placeholder": "Vali kĂ€sud, millest soovid meeldetuletust...", + "saved_on": "✅ Meeldetuletused sisse lĂŒlitatud: {names}", + "saved_off": "🔕 KĂ”ik meeldetuletused vĂ€lja lĂŒlitatud.", + "desc_active": "Praegu aktiivne: {status}\n\nMuuda valikut allpool.", + "desc_none": "Hetkel pole ĂŒhtegi meeldetuletust sisse lĂŒlitatud.\n\nVali allpool, millest soovid DM-i saada.", + "footer": "Bot DM-ib sulle, kui valitud kĂ€sk on taas kasutamiseks valmis.", +} + +# --------------------------------------------------------------------------- +# /send UI strings +# --------------------------------------------------------------------------- + +SEND_UI: dict[str, str] = { + "sent": "✅ SĂ”num saadetud kanalisse {channel}!", + "forbidden": "❌ Mul pole Ă”igust kanalisse {channel} kirjutada.", +} + +# --------------------------------------------------------------------------- +# /patchnotes UI strings +# --------------------------------------------------------------------------- + +PATCHNOTES_UI: dict[str, str] = { + "title": "📝 Muudatuste logi — {version}", + "footer": "Versioon {idx}/{total}", + "btn_newer": "◀ Uuem", + "btn_older": "Vanem ▶", + "select_placeholder": "Vali versioon
", + "empty_file": "â„č Muudatuste logi on hetkel tĂŒhi.", + "empty_version": "_(selle versiooni kohta mĂ€rkmeid pole)_", +} + +# --------------------------------------------------------------------------- +# /status UI +# --------------------------------------------------------------------------- + +STATUS_UI: dict[str, str] = { + "title": "đŸ–„ïž Boti olek", + "uptime_field": "🕐 Uptime", + "uptime_val": "{hours}t {minutes}m {seconds}s", + "latency_field": "📡 Latency", + "latency_val": "{ms} ms", + "ram_field": "🧠 RAM (RSS)", + "ram_val": "{mb} MB", + "cpu_field": "⚙ CPU", + "cpu_val": "{percent}%", + "tasks_field": "🔄 Async tasks", + "eco_players_field": "đŸ‘€ Eco players", + "members_cache_field": "📋 Liikmed (cache)", + "log_files_field": "📂 Log files", + "log_line": "`{name}` - {size_kb} KB", + "none": "-", +} diff --git a/strings/economy.py b/strings/economy.py new file mode 100644 index 0000000..2a20e50 --- /dev/null +++ b/strings/economy.py @@ -0,0 +1,443 @@ +"""Core economy: income, profile, shop, quests, leaderboard, requests. + +Auto-split from the original strings.py; edit strings here. +""" + +from core.emoji import EMOJI as E + +__all__ = [ + 'WORK_JOBS', + 'BEG_LINES', + 'BEG_JAIL_LINES', + 'CRIME_WIN', + 'CRIME_LOSE', + 'QUEST_UI', + 'QUEST_DESCRIPTIONS', + 'SHOP_UI', + 'ITEM_DESCRIPTIONS', + 'JAILED_UI', + 'SHOP_BTN', + 'DAILY_UI', + 'STATS_UI', + 'PROFILE_UI', + 'BALANCE_UI', + 'COOLDOWNS_UI', + 'RANK_UI', + 'WORK_UI', + 'BEG_UI', + 'CRIME_UI', + 'ROB_UI', + 'GIVE_UI', + 'BUY_UI', + 'LEADERBOARD_UI', + 'REQUEST_UI', +] + +# --------------------------------------------------------------------------- +# Flavour text +# --------------------------------------------------------------------------- + +WORK_JOBS: list[tuple[str, float]] = [ + ("paigaldasid aulas netikaableid", 1.0), + ("paigaldasid aulas voolukaableid", 1.0), + ("tĂ”id poest korraldajatele pitsat", 1.0), + ("konfigureerisid uue switchi", 1.0), + ("vedasid laudu aulasse", 1.0), + ("vedasid toole aulasse", 1.0), + ("paigaldasid aulasse vahekardinaid", 1.0), + ("aitasid ĂŒles seada turniiri lava", 1.0), + ("töötasid baaris", 1.0), + ("testisid helisĂŒsteemi \"Erootikapoodi\" blastides", 1.0), + ("juhendasid kĂŒlastajaid infolauas", 1.0), + ("seadistasid LAN serverit", 1.0), + ("paigaldasid monitore", 1.0), + ("ĂŒhendasid aulas kilpe", 1.0), + ("aitasid LANil osalejat", 1.0), + ("mĂŒĂŒsid energiajooke", 1.0), + ("tegid netikaableid", 1.0), + ("lahendasid IP-konflikti", 1.0), + ("parandasid kellegi arvutit", 1.0), + ("tegid korda striimi heli (mitte nagu PGL)", 1.2), + ("kirjutasid koodi miniturniiri jaoks", 1.2), + ("vĂ”itsid turniiri", 1.5), +] +BEG_LINES: list[str] = [ + "istusid kurva nĂ€oga oma monitori taga", + "palusid abi kellegi DMis", + "postitasid #abi kanalisse", + "vaatasid kadedusega teiste asju", + "kirjutasid /beg kolmandat korda jĂ€rjest", + "kĂŒsisid oma tiimiliikmelt laenu", + "vĂ”tsid kiirlaenu", + "mĂŒĂŒsid 3 sendi eest oma CSi skinne", + "korjasid kampuselt taarat", + "tegid TikToki live'i", + "refreshisid pangaĂ€ppi lootuses, et raha tekib", + "kirjutasid \"pls donate\" chatti", + "vaatasid oma tĂŒhja rahakotti", + "pĂŒĂŒdsid kedagi guilt-trippida", + "lubasid exposure'it raha eest", + "mĂ€ngisid kurba muusikat taustaks", + "teesklesid, et see on sotsiaalne eksperiment", + "kirjutasid emotsionaalse loo oma olukorrast", + "pakkusid vastu future equity't", + "tegite sĂ”braga koos kerjamisplaani", + "otsisid diivani vahelt sente", + "lubasid boostida kellegi ranki viieka eest", + "kĂŒsisid chatist, kas keegi teab kust tasuta V-Buckse saab", + "avastasid, et su krĂŒptoportfell on sĂŒgavas miinuses", +] +BEG_JAIL_LINES: list[str] = [ + "karjusid lĂ€bi trellide, et keegi mĂŒnte lĂ€bi lĂŒkkaks", + "kirjutasid kongi seina peale oma PayPali aadressi", + "lubasid, et maksad jĂ€rgmine kord vangi minnes tagasi", + "veensid valvurit, et oled tegelikult hea inimene", + "saatsid vanglast kĂ€sitsi kirjutatud kirja oma meeskonnale", + "pakkusid vahile tulevaste vihjete eest raha", + "koputasid morset vastu naaberkongi seina", + "ĂŒritasid vangla WiFit hĂ€kkida", + "lubasid teha tasuta IT-tööd vabanemise eest", + "ĂŒritasid lusikaga tunnelit kaevata", + "kirjutasid bug reporti vangla sĂŒsteemile", + "proovisid trellidest lĂ€bi pugeda, aga jĂ€id kinni", + "pakkusid vangivalvurile oma Steami kontot vabanemise eest", +] +CRIME_WIN: list[str] = [ + "hĂ€kkisid turniiri tulemuste tabelit", + "varastasid oma vastase hiire", + "varastasid oma vastase klaviatuuri", + "varastasid oma vastase kĂ”rvaklapid", + "varastasid oma vastase monitori", + "varastasid oma vastase mikrofoni", + "mĂŒĂŒsid vĂ”ltsitud LANi pileteid", + "laenatasid kellegi GPUd ja ei tagastanud seda", + "laenatasid Filmiklubilt AUX-kaablit ja ei tagastanud seda", + "kĂ€ivitasid DDoS rĂŒnnaku serverile", + "tegid oma shitcoin'idega pump and dump'i", + "kasutasid aimbotti ja keegi ei mĂ€rganud", + "exploitisid mĂ€ngu bugi enda kasuks", + "phishisid kellegi Steami konto", + "varastasid prize pooli raha", + "ĂŒhendasid enda krĂŒptokaevandaja serverisse", + "tegid petukĂ”nesid" +] +CRIME_LOSE: list[str] = [ + "jĂ€id turniiri adminile vahele", + "tĂ”mbasid oma arvuti kaabli pistikust vĂ€lja", + "kukutasid lahtise Red Bulli omale sĂŒlle", + "unustasid VPNi sisse lĂŒlitada", + "lagisid kriitilisel momendil", + "said banni otseĂŒlekande ajal", + "unustasid logid kustutada", + "sinu skript crashis valel hetkel", + "said reportitud mitme mĂ€ngija poolt", + "sinu krĂŒptokaevandaja sĂŒttis pĂ”lema", + "flashisid tervet oma tiimi", + "avastasid, et Windows otsustas keset mĂ€ngu uuendama hakata", + "jĂ€id politseile petukĂ”nedega vahele" +] + +# --------------------------------------------------------------------------- +# Quest system (/quests) +# --------------------------------------------------------------------------- + +QUEST_UI: dict[str, str] = { + "daily_header": "📅 PĂ€evaĂŒlesanded", + "weekly_header": "📆 NĂ€dalaĂŒlesanded", + "progress": "⏳ {progress}/{max}", + "ready": "✅ Valmis - nĂ”ua auhind!", + "completed": "🏆 NĂ”utud", + "reward": "🎁 {coins} ⏥ · {exp} EXP", + "empty": "Ühtegi ĂŒlesannet pole. Proovi hiljem uuesti!", + "claim_btn": "🎁 NĂ”ua auhinnad", + "claimed_msg": "🏆 NĂ”udsid {count} ĂŒlesande auhinnad: +{coins} · +{exp} EXP!", + "nothing": "Sul pole ĂŒhtegi valmis ĂŒlesannet, mida nĂ”uda.", + "error": "❌ Ülesannete laadimine ebaĂ”nnestus. Proovi hiljem uuesti.", +} + +# Estonian one-line description per quest id (keys match economy.QUESTS_*). +QUEST_DESCRIPTIONS: dict[str, str] = { + # Daily + "work3": "Tööta 3 korda (/work)", + "beg5": "Kerja 5 korda (/beg)", + "wager500": "Panusta kokku 500 ⏥ hasartmĂ€ngudes", + "fish2": "PĂŒĂŒa 2 kala (/fish)", + "crime1": "Soorita edukalt 1 kuritegu (/crime)", + "earn1000": "Teeni kokku 1 000 ⏥", + "give200": "Kingi teistele kokku 200 ⏥ (/give)", + # Weekly + "work20": "Tööta 20 korda (/work)", + "fish15": "PĂŒĂŒa 15 kala (/fish)", + "wager5000": "Panusta kokku 5 000 ⏥ hasartmĂ€ngudes", + "crime5": "Soorita edukalt 5 kuritegu (/crime)", + "heist1": "Osale 1 grupiröövis (/heist)", + "earn10000": "Teeni kokku 10 000 ⏥", +} + +# --------------------------------------------------------------------------- +# Shop UI strings +# --------------------------------------------------------------------------- + +SHOP_UI: dict[str, str] = { + "tier_1": "Tier 1 - Algaja", + "tier_2": "Tier 2 - Kogenud", + "tier_3": "Tier 3 - Legend", + "desc": "Saldo: {bal} · Osta kĂ€suga `/buy`", + "owned": "✅ Olemas", + "owned_uses_1": "✅ Olemas ({uses} kasutus jĂ€rel)", + "owned_uses_n": "✅ Olemas ({uses} kasutust jĂ€rel)", + "locked": "🔒 Tase {min_lvl} nĂ”utud *(sul on {user_lvl})*", +} + +# --------------------------------------------------------------------------- +# Shop item descriptions (keyed by SHOP dict key in economy.py) +# --------------------------------------------------------------------------- + +ITEM_DESCRIPTIONS: dict[str, str] = { + "gaming_hiir": "Koolist varastatud hiir? Ei, see on mĂ€ngurihiir. Teeni töötades 50% rohkem TipiCOINe.", + "hiirematt": "XXL suuruses, ainult parimast materjalist. Kerjamise ooteaeg 5min → 3min.", + "korvaklapid": "Noise-cancelling - kuuled ainult TipiCOINide kĂ”linat. PĂ€evase boonuse ooteaeg 20h → 18h + 25⏥ boonust.", + "lan_pass": "Ametlik TipiLANi pilet (2025). PĂ€evane boonus on duubeldatud.", + "energiajook": "Kolm Red Bulli jĂ€rjest. 30% tĂ”enĂ€osus, et teenid töötades 3x rohkem.", + "gaming_laptop": "RTX 5090 jooksutab botte 24/7. PĂ€evane boonus genereerib 5% intressi sinu saldo pealt.", + "anticheat": "VAC, EAC, Faceit AC ja BattlEye korraga. Röövimine sinu vastu ebaĂ”nnestub. **2 kasutust**, siis pead ostma uue.", + "reguleeritav_laud": "VĂ”id nii seista kui istuda - alati vĂ”idad. /work teenib 25% rohkem (stackib mĂ€ngurihiirega).", + "jellyfin": "Self-hosted meediaserver - oled suurfirmadest sĂ”ltumatu. Röövimise edu 45% → 60%. Grupiröövi Ă”nnestumisele +5%.", + "mikrofon": "Parem helikvaliteet teeb sind usutavamaks. Teeni 30% rohkem eduka /crime puhul.", + "klaviatuur": "KlĂ”binad kostuvad ĂŒle kogu saali. /beg teenib 2x rohkem.", + "monitor": "240Hz ja 27 tolli. /work ooteaeg: 1h → 40min.", + "cat6": "Gigabitine internet = ideaalne piraatluseks. /crime edu tĂ”enĂ€osus tĂ”useb 60% → 75%.", + "monitor_360": "360Hz, 1ms. MĂ€nguautomaadi jackpot 10x → 15x, kolmik 4x → 6x. HasartmĂ€ngude ooteaeg 30s → 25s.", + "karikas": "Ainult legendidele. Streak ei nulli, kui sa mĂ”ne pĂ€eva vahele jĂ€tad.", + "gaming_tool": "Nii mugav, et isegi admin ei saa sind ĂŒles. /crime ebaĂ”nnestumine ei saada sind vanglasse.", + "ussipurk": "Lakkumatu toiduga ussipurk - kalad ei saa vastu. Kalapyygi ooteaeg 2min → 90s.", + "kalavork": "Suurem vĂ”rk = suuremad kalad. KĂ”igi kalade haruldus tĂ”useb ĂŒhe astme vĂ”rra.", + "echolood": "TĂ€pne ehholood nĂ€itab kala tĂ€pset asukohta. Haukamise aken 2s → 3s.", +} +JAILED_UI: dict[str, str] = { + "title": "🔒 Praegu vanglas", + "empty": "KĂ”ik on vabad! Vanglas pole kedagi.", + "entry": "‱ {mention} - vabaneb ", + "footer": "{count} vang{plural}", +} +SHOP_BTN: dict[int, str] = { + 1: "Tier 1", + 2: "Tier 2", + 3: "Tier 3", +} + +# --------------------------------------------------------------------------- +# /daily embed strings +# --------------------------------------------------------------------------- + +DAILY_UI: dict[str, str] = { + "earned": "✅ Said {earned}!", + "interest": E["TipiLAP"] + " Bot Farm tootis: +{interest}", + "vip": f"{E['TipiPILET']} LAN pileti boonus rakendus!", + "footer": "Streak: {streak_str} · Saldo: {balance}", +} + +# --------------------------------------------------------------------------- +# /stats embed strings +# --------------------------------------------------------------------------- + +STATS_UI: dict[str, str] = { + "economy_field": "💰 Majandus", + "economy_val": "Tipp-saldo: {peak}\nEluaegne tulu: {earned}\nEluaegne kaotus: {lost}", + "work_field": "đŸ› ïž Töö & Kerja", + "work_val": "Töötanud: **{work}** korda\nKerjanud: **{beg}** korda", + "gamble_field": "đŸŽČ HasartmĂ€ngud", + "gamble_val": "Panustatud kokku: {wagered}\nSuurim vĂ”it: {win}\nSuurim kaotus: {loss}\nSlotsi jackpotid: **{jackpots}** korda", + "crime_field": "đŸŠč Kuritegevus", + "crime_val": "Kuriteod: **{crimes}** ({succeeded} Ă”nnestus)\nHeistid: **{heists}** ({heists_won} vĂ”ideti)\nVangi sattunud: **{jailed}** korda\nKautsjon makstud: {bail}", + "social_field": "đŸ€ Sotsiaalne", + "social_val": "Kingitud: {given}\nSaadud: {received}", + "records_field": "đŸ”„ Rekordid", + "records_val": "Pikim pĂ€evane streak: **{streak}** pĂ€eva", +} + +# --------------------------------------------------------------------------- +# /profile tabbed view +# --------------------------------------------------------------------------- + +PROFILE_UI: dict[str, str] = { + "btn_profile": "💰 Profiil", + "btn_items": "🎒 Esemed", + "btn_stats": "📊 Statistika", + "btn_fish": "🎣 Kalakogu", + "main_title": "💰 {name}", + "items_title": "🎒 {name} - Esemed", + "stats_title": "📊 {name} - Statistika", + "fish_title": "🎣 {name} - Kalakogu", + "items_empty": "Sul pole ĂŒhtegi eset.", + "f_balance": "💰 Saldo", + "f_level": "📊 Tase", + "f_streak": "đŸ”„ Streak", + "f_prestige": "⭐ Prestiiz", + "f_jail": "🚔 Vangis kuni", + "f_progress": "→ Tase {next}", + "progress_bar": "`{bar}` {done}/{needed} EXP", + "level_val": "Tase {level} - {role}", + "prestige_val":"⭐ P{level} · {pp} PP", + "footer_t1": "Tase 10 avab T2 poe · Tase 20 avab T3 poe", + "footer_t2": "T2 pood avatud · Tase 20 avab T3 poe", + "footer_t3": "T2 ja T3 pood avatud", +} + +# --------------------------------------------------------------------------- +# /balance embed strings +# --------------------------------------------------------------------------- + +BALANCE_UI: dict[str, str] = { + "saldo": "Saldo", + "streak": "PĂ€eva streak", + "streak_val": "đŸ”„ {streak}p", + "jailed_until": "🚔 Vangis kuni", + "items": "Esemed", + "uses_one": " *({uses} kasutus jĂ€rel)*", + "uses_many": " *({uses} kasutust jĂ€rel)*", +} + +# --------------------------------------------------------------------------- +# /cooldowns embed strings +# --------------------------------------------------------------------------- + +COOLDOWNS_UI: dict[str, str] = { + "ready": "✅ Valmis", + "daily_line": "📅 **/daily** {status}{note}", + "work_line": "đŸ’Œ **/work** {status}{note}", + "beg_line": "🙏 **/beg** {status}{note}", + "crime_line": "đŸŠč **/crime** {status}", + "rob_line": "đŸ”« **/rob** {status}", + "fish_line": "🎣 **/fish** {status}{note}", + "note_korvak": " *(kĂ”rvaklapid: 18t)*", + "note_monitor": " *(monitor: 40min)*", + "note_hiirematt": " *(hiirematt: 3min)*", + "note_ussipurk": " *(ussipurk: 90s)*", + "jailed": "\nïżœ **Vanglas** - vabaneb ", + "jail_expired": "\n🔓 Vangla lĂ”ppes", +} + +# --------------------------------------------------------------------------- +# /rank embed strings +# --------------------------------------------------------------------------- + +RANK_UI: dict[str, str] = { + "title": "📊 {name} - Tase {level}", + "field_title": "Tiitel", + "field_exp": "EXP", + "field_progress": "Progress → Tase {next}", + "progress_val": "`{bar}` {progress}/{needed} EXP", + "footer_t1": "Tase 10 avab T2 poe · Tase 20 avab T3 poe", + "footer_t2": "T2 pood avatud · Tase 20 avab T3 poe", + "footer_t3": "T2 ja T3 pood avatud", +} + +# --------------------------------------------------------------------------- +# /work embed strings +# --------------------------------------------------------------------------- + +WORK_UI: dict[str, str] = { + "desc": "Sa {job} ja teenisid {earned}!", + "redbull": f"\n{E['TipiBULL']} Red Bull aktiveerus - 3x boonus!", + "hiir": f"\n{E['TipiHIIR']} MĂ€ngurihiir: +50% palk", + "laud": f"\n{E['TipiLAUD']} Reguleeritav laud: +25% palk", + "balance": "\nSaldo: {balance}", +} + +# --------------------------------------------------------------------------- +# /beg embed strings +# --------------------------------------------------------------------------- + +BEG_UI: dict[str, str] = { + "desc": "Sa {text} ja said {earned}.", + "klaviatuur": f"{E['TipiKLAVA']} Mehhaaniline klaviatuur: 2x tulu", + "balance": "Saldo: {balance}", +} + +# --------------------------------------------------------------------------- +# /crime embed strings +# --------------------------------------------------------------------------- + +CRIME_UI: dict[str, str] = { + "win_desc": "Sa {text} ja teenisid {earned}!", + "fail_base": "Sa {text} ja said trahvi {fine}.", + "fail_jailed": "\n\ud83d\udd12 Oled vangis! P\u00e4\u00e4sed {ts}.", + "fail_shield": "\n\ud83d\udee1\ufe0f Gaming Tool hoidis sind vanglast!", + "mikrofon": f"\n{E['TipiMIC']} Mikrofon: +30% saak", + "cat6": f"\n{E['TipiCAT']} CAT6: 75% edu t\u00f5en\u00e4osus", + "balance": "\nSaldo: {balance}", +} + +# --------------------------------------------------------------------------- +# /rob embed strings +# --------------------------------------------------------------------------- + +ROB_UI: dict[str, str] = { + "jackpot_desc": "đŸ’„ **JACKPOT!** Murdsid TipiBOTi kassasse sisse ja varastasid {stolen}!\nSaldo: {balance}", + "win_desc": "Varastasid {stolen} kasutajalt **{name}**!\nSaldo: {balance}", + "anticheat_desc": "**{name}** kaitseb end Anticheati'ga - said trahvi {fine}.", + "anticheat_worn": "⚠ Sinu **Anticheat** on kulunud! Osta uus `/buy` kĂ€suga.", + "victim_dm": "💾 **{robber}** varastas sinult **{stolen}** mĂŒnti!", + "fail_desc": "JĂ€id vahele! Trahv: {fine}.\nSaldo: {balance}", +} + +# --------------------------------------------------------------------------- +# /give embed strings +# --------------------------------------------------------------------------- + +GIVE_UI: dict[str, str] = { + "desc": "**{giver}** andis {amount} kasutajale **{receiver}**.", +} + +# --------------------------------------------------------------------------- +# /buy embed strings +# --------------------------------------------------------------------------- + +BUY_UI: dict[str, str] = { + "title": "{emoji} {name} ostetud!", + "desc": "{description}\nUus saldo: {balance}", +} + +# --------------------------------------------------------------------------- +# /leaderboard UI strings +# --------------------------------------------------------------------------- + +LEADERBOARD_UI: dict[str, str] = { + "house_entry": "đŸ€– {name} *(maja)* - {balance}", + "house_default_name": "TipiBOT", + "no_entries": "Keegi ei ole veel punkte teeninud.", + "footer": "LehekĂŒlg {page}/{total} · {count} mĂ€ngijat", + "btn_coins": "đŸȘ™ MĂŒndid", + "btn_exp": "📊 EXP", + "btn_find_me": "📍 Mina", + "exp_entry": "{prefix} {name} - {exp} EXP *(Tase {level})*", + "unknown_user": "Kasutaja {uid}", + "btn_season": "🏆 Hooaeg", + "btn_prestige": "đŸ”„ PrestiiĆŸ", + "btn_wagered": "đŸŽČ HasartmĂ€ng", + "btn_fish": "🎣 Kalapyyk", + "season_entry": "{prefix} {name} - {exp} EXP *(PrestiiĆŸ {prestige})*", + "prestige_entry": "{prefix} {name} - PrestiiĆŸ **{prestige}** · {pp} PP", + "wagered_entry": "{prefix} {name} - {wagered} panustatud", + "fish_entry": "{prefix} {name} - {caught} kala", +} + +# --------------------------------------------------------------------------- +# /request UI strings +# --------------------------------------------------------------------------- + +REQUEST_UI: dict[str, str] = { + "modal_title": "Rahasta taotlust", + "modal_label": "Kui palju soovid panustada?", + "btn_fund": "Rahasta 💾", + "btn_funded": "Rahastatud ✅", + "btn_fund_remaining": "Rahasta 💾 ({remaining}⏥ jÀÀnud)", + "funded_line": "✅ **{name}** panustas {amount}", + "funded_full": "\n🎯 Taotlus tĂ€ielikult tĂ€idetud!", + "funded_partial": "\n💰 JÀÀnud: {remaining}", + "audience_all": "Avatud kĂ”igile rahastajatele 🌍", + "audience_targeted": "Suunatud: **{name}**", + "desc": "**{requester}** palub {amount}\n\n📋 **PĂ”hjus:** {reason}\n\n{audience}", + "footer": "Taotlus aegub 5 minuti pĂ€rast", +} diff --git a/strings/fishing.py b/strings/fishing.py new file mode 100644 index 0000000..850e193 --- /dev/null +++ b/strings/fishing.py @@ -0,0 +1,86 @@ +"""Fishing minigame: fish catalogue, rarities, and /fish UI. + +Auto-split from the original strings.py; edit strings here. +""" + +__all__ = [ + 'FISH_NAMES', + 'FISH_RARITY_NAMES', + 'FISH_RARITY_EMOJI', + 'FISH_JUNK_LINES', + 'FISH_UI', +] + +# --------------------------------------------------------------------------- +# Fishing system strings +# --------------------------------------------------------------------------- + +FISH_NAMES: dict[str, str] = { + "sarj": "SĂ€rg", + "ahven": "Ahven", + "koger": "Koger", + "viidikas": "Viidikas", + "latikas": "Latikas", + "karpkala": "Karpkala", + "linask": "Linask", + "haug": "Haug", + "angerjas": "Angerjas", + "siig": "Siig", + "forell": "Forell", + "koha": "Koha", + "tougjas": "TĂ”ugjas", + "lohe": "LĂ”he", + "vimb": "Vimb", +} +FISH_RARITY_NAMES: dict[str, str] = { + "common": "Tavaline", + "uncommon": "Ebatavaline", + "rare": "Haruldane", + "epic": "Eepiline", + "legendary": "Legendaarne", +} +FISH_RARITY_EMOJI: dict[str, str] = { + "common": "🐟", + "uncommon": "🐠", + "rare": "🎣", + "epic": "⭐", + "legendary": "🌟", +} +FISH_JUNK_LINES: list[str] = [ + "Sa saad... **vana saabas**. Klassika.", + "Õnnitlused, leidsid **kasutatud autorehvi**. Keskkond tĂ€nab sind... mitte.", + "Taas ĂŒks **klaaspudel** rohkem jĂ”es.", + "**Vana poes kĂ€imise kott**! Hoidis aega hĂ€sti.", + "**Roostes konserv** - ilma sildita. Parem mitte teada, mis sees on.", + "**Ummistunud drenaaĆŸitoru**. Keegi oli hoolimatu.", + "**TĂŒhi rahakott**. Kellegi pĂ€ev lĂ€ks halvemaks kui sinu oma.", + "**Vana CD-plaat** - Evanescence, 2003. Heas seisukorras.", + "Sa said **kivikese**. Ilus kivikene. Aga siiski kivikene.", + "**Kaotsi lĂ€inud droon**. GPS ei tööta, aku tĂŒhi.", +] +FISH_UI: dict[str, str] = { + "btn_wait": "🎣 Oota nĂ€kkamist...", + "btn_bite": "🐟 TÕMBA!", + "btn_sell": "💰 MĂŒĂŒ", + "btn_keep": "🎒 Hoia", + "cast_desc": "Viskad Ă”nge vette. Oota, kuni kala nĂ€kkab...\n\n-# Vajuta nuppu, kui kala nĂ€kkab!", + "bite_desc": "**KALA NÄKKAB!** TĂ”mba kiiresti! ⚡\n\n-# Sul on 2 sekundit!", + "escape_desc": "Liiga hilja - kala lipsas minema. Proovi jĂ€rgmine kord kiiremini!", + "junk_desc": "{text}\n\n-# Saldo: {balance}", + "catch_desc": "**{name}** · {weight}g · +{exp} EXP\n-# Kas mĂŒĂŒd kohe ({value}) vĂ”i hoiad inventaris?", + "catch_sold": "**{name}** · {weight}g\n\n+{coins} · +{exp} EXP\nSaldo: {balance}", + "catch_kept": "**{name}** · {weight}g lisatud inventarisse. *(+{exp} EXP)*", + "new_fish": "\n✹ **Uus kala kalakogusse lisatud!**", + "too_early": "❌ Kala pole veel nĂ€kkanud! Oota...", + "book_caught": "PĂŒĂŒtud kalaliike: **{caught}/{total}**", + "book_yes": "✅ {emoji} **{name}** *({rarity})* · {count}×{inv}", + "book_inv": " *(inventaris: {n})*", + "book_no": "❓ **???** *({rarity})*", + "book_footer": "LehekĂŒlg {page}/{total_pages} · {caught}/{total} liiki", + "book_empty": "Sa pole veel ĂŒhtegi kala pĂŒĂŒdnud! Kasuta `/fish`.", + "inv_empty": "Sinu kalainventaar on tĂŒhi! Kasuta `/fish` kala pĂŒĂŒdmiseks.", + "inv_header": "Sul on **{count}** kala inventaris *(kokku vÀÀrt {total_value})*", + "inv_entry": "{emoji} **{name}** · {weight}g · {value}", + "inv_sold_all": "MĂŒĂŒsid **{count}** kala kokku {coins} eest!\nSaldo: {balance}", + "inv_none": "Inventaaris pole midagi mĂŒĂŒa.", +} diff --git a/strings/games.py b/strings/games.py new file mode 100644 index 0000000..1cada4c --- /dev/null +++ b/strings/games.py @@ -0,0 +1,307 @@ +"""Minigames & gambling: slots, roulette, RPS, blackjack, heist, jailbreak. + +Auto-split from the original strings.py; edit strings here. +""" + +from core.emoji import EMOJI as E + +__all__ = [ + 'SLOTS_TIERS', + 'ROULETTE', + 'HEIST_STORY', + 'HEIST_UI', + 'BJ', + 'JAILBREAK_UI', + 'SLOTS_UI', + 'RPS_UI', + 'RPS_CHOICES', + 'BJ_UI', +] + +# --------------------------------------------------------------------------- +# /slots outcome strings (title, colour) +# --------------------------------------------------------------------------- + +SLOTS_TIERS: dict[str, tuple[str, int]] = { + "jackpot": (f"{E['TipiFIRE']} JACKPOT!!!", 0xF4C430), + "triple": ("🎰 Kolmik!", 0x57F287), + "pair": ("🎰 Paar", 0x99AAB5), + "miss": (f"{E['TipICRY']} Ei Ă”nnestunud", 0xED4245), +} + +# --------------------------------------------------------------------------- +# /roulette outcome strings +# --------------------------------------------------------------------------- + +ROULETTE: dict = { + "emoji": {"punane": "🔮", "must": "⚫", "roheline": "🟱"}, + "genitive": {"punane": "punase", "must": "musta", "roheline": "rohelise"}, + "win_title": "{emoji} VĂ”itsid!", + "lose_title": "{emoji} Kaotasid!", + "win_desc": "Ratas peatus **{genitive}** peal!{mult}\n+{change}\nSaldo: {balance}", + "lose_desc": "Ratas peatus **{genitive}** peal.\n-{change}\nSaldo: {balance}", + "spin_title": "🎰 Ratas keerleb...", + "spin_stop": "🎰 Ratas peatub...", + "spin_strip": "{s0} {s1} {s2} {s3} {s4}", +} + +# --------------------------------------------------------------------------- +# /heist narrative story lines +# Placeholders: {leader}, {member}, {names}, {vehicle} +# --------------------------------------------------------------------------- + +HEIST_STORY: dict = { + "vehicles": [ + "Ć koda", + "BMW", + "Lada", + "Mazda", + ], + "arrival": [ + "{leader} keerab nurga taga {vehicle} mootori kinni. Maskid ette. Keegi ei rÀÀgi.", + "{vehicle} peatub ĂŒhe kvartali kaugusel sihtmĂ€rgist. {leader}: *\"Pank on seal. Tegutseme plaani jĂ€rgi.\"*", + "Meeskond astub {vehicle}st vĂ€lja. {leader} kuulab kĂ”rvamonitori. *\"Kaks valvurit, ĂŒks pimeala. Liigume nĂŒĂŒd.\"*", + "{leader} jĂ€lgib {vehicle}st sissepÀÀsu. *\"Valvurite vahetus 40 sekundi pĂ€rast. See on meie ajaaken.\"*", + "Kell 3 öösel. TĂ€nav on tĂŒhi. {vehicle} seisab vĂ€ljas tĂŒhikĂ€igul. {leader} tĂ”mbab maski ette.", + "{vehicle} veereb aeglaselt vaiksesse pimedasse vahetĂ€navasse. {leader}: *\"Viimane kontroll. KĂ”ik valmis?\"*", + "{leader} koputab armatuurlauale. *\"Kui midagi lĂ€heb valesti, me lahkume kohe.\"* Vaikus vastuseks.", + "Tuul sahiseb mööda tĂŒhja tĂ€navat. {vehicle} uksed avanevad korraga.", + "{member} kontrollib relva. {leader} vaatab kella. *\"Me oleme graafikus.\"*", + "{vehicle} tuled kustuvad. Linn jÀÀb vaikseks. Nad liiguvad.", + ], + "entry_sneaky": [ + "{member} tĂ”mbab kloonitud kaarti kĂŒlgukse juures. Lukk klĂ”psab hÀÀleta lahti.", + "Meeskond libiseb sisse laadimisestakaadi kaudu, riietatud öisteks koristajateks.", + "{leader} on kaks nĂ€dalat valvurite graafikut pĂ€he Ă”ppinud. Röövlid kĂ”nnivad sisse vahetuse ajal.", + "VĂ”ltsitud alltöövĂ”tja kaardi abil saavad nad fuajeest lĂ€bi ilma lisakĂŒsimuseta.", + "{member} lĂŒlitab vĂ€likaamerad sĂŒlearvutist vĂ€lja. {leader} kĂ”nnib sisse, nagu see oleks tema oma maja.", + "{member} kasutab signaaliblokeerijat. HĂ€ired ei jĂ”ua kunagi sĂŒsteemi.", + "{leader} avab ventilatsiooniluugi. *\"LĂ€heme ĂŒlevalt.\"*", + "Turvamees haigutab. Sekund hiljem on ta seotud ja vaikselt nurka lohistatud.", + "{member} sĂŒstib lukku mikrokaamera. Mehhanism kaardistatakse sekunditega.", + ], + "entry_loud": [ + "{leader} lööb esiuksed lahti. *\"KÕIK PÕRANDALE - KOHE!\"*", + "Esmalt suitsugranaadid. Selleks ajaks, kui suits vaibub, on meeskond juba sees.", + "{leader} tulistab ĂŒhe lasu lakke. Haudvaikus. *\"Oleme siin seifi pĂ€rast! Kui teete koostööd, siis pÀÀsete elusana.\"*", + "*\"Kui keegi ei liigu, ei saa keegi ka viga.\"* {leader} omab ruumi tĂ€ielikku tĂ€helepanu.", + "{member} lĂŒkkab kilbist elektri vĂ€lja. Pimeduses juhib {leader} meeskonna mĂ€lu jĂ€rgi edasi.", + "*\"See on rööv! KĂ”ik pikali!\"* {leader} hÀÀl kajab lĂ€bi saali.", + "Alarm hakkab ulguma juba enne, kui nad tĂ€ielikult sisse jĂ”uavad.", + "Klaas puruneb. Inimesed karjuvad. TĂ€ielik kaos.", + ], + "inside": [ + "{names} liiguvad kiiresti lĂ€bi fuajee, sidudes turvamehi postide kĂŒlge kinni.", + "{member} katab vĂ€ljapÀÀsud. {leader} suundub otse seifi poole.", + "Üks kassapidajatest proovib kĂ€ivitada vaikset hĂ€iret, kuid {member} mĂ€rkab ta nihelemist ja peatab ta hoiatuslasuga.", + "{leader} hoiab töötajad rahulikuna samal ajal, kui teised suunduvad alumisele korrusele.", + "Turvakaamerad on tsĂŒklil. {names} on korrusel ĂŒksi.", + "{member} kontrollib kellaaega. *\"Me oleme 30 sekundit ees.\"*", + "{leader} annab kĂ€emĂ€rgi. Meeskond jaguneb ilma sĂ”nadeta.", + "Koridor on tĂŒhi. Liiga tĂŒhi. {leader} peatub hetkeks.", + "{names} liiguvad trepist alla, sammud summutatud.", + "Üks uks on lukus. {member} avab selle sekundiga.", + ], + "vault": [ + "Neljanda taseme ajalukuga seif. {member} tĂ”mbab puuri vĂ€lja. *\"Anna mulle kolm minutit.\"*", + "{leader} vaatab seifi ust. *\"DĂŒnamiit.\"* Keegi ei vaidle.", + "{member} ĂŒhendub seifi juhtpaneeliga. Vana tarkvara. ÜlevĂ”tmine vĂ”tab 90 sekundit.", + "Seifil on kĂ€sikombinatsiooni lukk. Hea, et {leader} veetis kuu aega juhatajaga, teenides tema usaldust.", + "{member} paigutab lĂ”hkeained. Meeskond astub tagasi. Üks kontrollitud plahvatus.", + "{leader} teeb magnetlukule tĂŒhistuse. Nagu Ă”pikust vĂ”etud. Mehhanism annab jĂ€rgi.", + "{member} higistab. *\"See pole standardlukk... anna mulle aega.\"*", + "{leader} kuulab vastu seifi ust. *\"Sees on liikumisandur.\"*", + "{member} lĂ”ikab lĂ€bi metallkihi nagu vĂ”id.", + "AjurĂŒnnak. {leader} meenutab skeemi ja leiab nĂ”rga koha.", + "{member} ĂŒhendab juhtmed ĂŒmber. SĂ€de. Vaikus. Lukk avaneb.", + ], + "vault_open": [ + "Uks avaneb. Kuhi-kuhja jĂ€rel raha, pĂ”randast laeni. TĂ€pselt nagu plaanitud.", + "*\"...jackpot.\"* {leader} piilub seifi sisse. Meeskond seisab hetkeks vaikuses.", + "{member} hakkab kotte tĂ€is laduma. {leader} piilub juba vĂ€ljapÀÀsu poole.", + "Puhas. Kiire. {names} on seifis ja tĂ€idavad kotte enne, kui tolm settib.", + "Seif on lahti. {leader} vĂ”tab hinge. *\"Okei. Kottidesse ja liikuma.\"*", + "{leader} naeratab esimest korda. *\"See oli seda vÀÀrt.\"*", + "Raha lĂ”hn tĂ€idab Ă”hu. {member}: *\"VĂ”tame kĂ”ik.\"*", + "{names} töötavad vaikides. Iga liigutus loeb.", + "Kotid tĂ€ituvad kiiremini kui oodatud.", + "{leader} pilk muutub tĂ”siseks. *\"Aeg otsas. Liigume.\"*", + ], + "police_inbound": [ + "Raadio krĂ”biseb. *\"KĂ”ik ĂŒksused, relvastatud rööv Keskpangas-\"*", + "VĂ€ljas on kuulda eemalt ulguvaid sireene. {leader}: *\"Meil on umbes neli minutit. Liikuma.\"*", + "Punased ja sinised tuled vilguvad ĂŒlemistest akendest lĂ€bi. Varualarm kĂ€ivitati.", + "Politsei helikopter skaneerib piirkonda proĆŸektoriga. Aken sulgub kiiresti.", + "{member} kontrollib politsei skĂ€nnerit. *\"Nad teavad. Kolm ĂŒksust, kaks minutit eemal.\"*", + "{member} kuulab raadiosidet. *\"Nad sulgevad kvartaleid.\"*", + "Sireenid lĂ€henevad kiiremini kui plaanitud.", + "Helikopteri valgus libiseb ĂŒle akna. Liiga lĂ€hedal.", + ], + "getaway_success": [ + "Adrenaliin. {names} jooksevad {vehicle} juurde. KĂ”ik sees. {leader} keerab mootori kĂ€ima.", + "{member} haarab roolist. {leader} on kĂ”rval. *\"LĂ€hme.\"* Rahulikult.", + "{leader} libistab {vehicle} kĂ”rvalteed pidi minema. Sireen kaugel taga. Nad on kiiremad.", + "Rohelised tuled. {vehicle} sĂ”idab vaikselt mööda parklas ootavast patrullautost. Keegi ei mĂ€rka.", + "{member} nĂ€itab teed. {leader} sĂ”idab vaikselt lĂ€bi tagakvartali. KĂ€ed lĂ”dvad.", + "{vehicle} rehvid vilisevad, kui nad pööravad kitsasse tĂ€navasse.", + "{leader} sĂ”idab ilma tuledeta. Ainult mĂ€lu juhib teda.", + "{member} vaatab kaarti. *\"Vasak, siis kohe parem!\"*", + "Mootor möirgab. Nad kaovad öösse.", + "{vehicle} libiseb lĂ€bi viimase rohelise tule.", + ], + "getaway_fail": [ + "{names} jooksevad {vehicle} juurde. Sireenid igalt poolt.", + "{member} haarab roolist. {leader} on kĂ”rval. *\"Mine! MINE!\"*", + "{vehicle} kihutab tĂ€navale, kuid helikopteri valgusvihk osutab neile. KĂ”ik on nĂ€htaval.", + "Kolm patrullautot jĂ”uavad nende taha. {member} vaatab ĂŒle Ă”la. *\"Nad on meil kannul.\"*", + "Raadio krĂ”biseb. *\"SihtmĂ€rk kinnitatud. Blokeeri Liivalaia tĂ€nav.\"*", + "{vehicle} ei kĂ€ivitu. {member}: *\"PĂ€riselt ka vĂ”i?!\"*", + "{member} komistab teel autoni. Tal oli pael lahti tulnud", + "{leader} avastab, et autot pole kuskil nĂ€ha. See pukseeriti Ă€ra." + ], + "escape_success": [ + "{names} murravad lĂ€bi perimeetri enne vĂ€ravate sulgemist. Puhas pĂ”genemine.", + "{leader} oli planeerinud kolm vĂ€ljumisteed. Vaja lĂ€ks ĂŒhte. {vehicle} kaob linna.", + "Kahe kvartali kaugusel asuv peibutushĂ€ire tĂ”mbab ĂŒksused eemale. {names} on kadunud enne, kui keegi seifi kontrollib.", + "Naelribad? Juba eemaldatud. Teeblokk? Vale tĂ€nav. {leader} mĂ”tles kĂ”igele.", + "{vehicle} sĂ”idab maanteele nagu poleks midagi juhtunud. {leader} hingab esimest korda tunnis vĂ€lja.", + "*\"Oleme vabad.\"* {member} piilub kardinate tagant vĂ€lja. TĂŒhi tĂ€nav. Nad tegid Ă€ra.", + "{names} kaovad sĂŒgavale linna nagu neid poleks kunagi olnud.", + "Raadio jÀÀb vaikseks. Keegi ei jĂ€lita enam.", + "{vehicle} jĂ€etakse maha. Uus plaan aktiveerub.", + "{leader}: *\"See oli liiga lihtne...\"*", + ], + "escape_fail": [ + "Patrullauto lĂ”ikab {vehicle} teekonna sillal Ă€ra. MĂ”lemalt poolt ĂŒmbritsetud. Ongi lĂ€bi.", + "{member} kukutab koti. Kolm sekundit kĂ”hklust - ja uksed on ĂŒmber piiratud.", + "Helikopter jĂ€lgib neid kuni pelgupaigani. KĂ”ik vĂ€ljapÀÀsud on kaetud.", + "{leader} arvutab vĂ”imalusi. *\"VĂ€ljapÀÀsu pole.\"* KĂ€ed lĂ€hevad ĂŒles.", + "Naelribad lĂ”huvad {vehicle} rehvid. Meeskond libiseb liikluse keskele seisma.", + "{vehicle} pĂ”rkub vastu teetĂ”ket. Mootor sureb. Igast suunast tuled.", + ], +} + +# --------------------------------------------------------------------------- +# /heist UI strings +# --------------------------------------------------------------------------- + +HEIST_UI: dict[str, str] = { + "names_duo": "**{a}** ja **{b}**", + "names_sep": " & ", + "names_crew": "**{leader}** ja meeskond", + "btn_join": "Ühine röövimisega đŸ”«", + "btn_start": "Alusta kohe ▶", + "already_joined": "Sa oled juba sees!", + "only_organizer": "Ainult heisti algataja saab heisti alustada.", + "lobby_desc": "**Osalejad ({n}/{max}):**\n{names}\n\nÕnnestumise tĂ”enĂ€osus: **{chance}%**\n\n*Ühinemisaken sulgub *", + "cancel_desc": "Ei piisanud osalejaid (vajad vĂ€hemalt **{min}**). Heist tĂŒhistati.", + "started_title": "đŸ”« Grupirööv kĂ€ib...", + "started_desc": "**{n}** osalejat alustasid heisti. JĂ€lgi allpool!", + "story_title": "đŸ”« Grupirööv", + "win_desc": "**Osalejad:**\n{names}\n\nSaak jagati vĂ”rdselt.\n**IgaĂŒks sai: +{payout}**", + "fail_desc": "**Osalejad:**\n{names}\n\nKĂ”ik osalejad saavad **1,5 tundi vangi** + trahv ~15% saldost.", +} + +# --------------------------------------------------------------------------- +# Blackjack in-game strings +# --------------------------------------------------------------------------- + +BJ: dict[str, str] = { + "dealing": "*Jagame kaardid...*", + "result_field": "Tulemus", + "push_result": "±0 (panus tagasi)", + "doubled_label": "💰 *Topeltpanus: {total}*", + "btn_hit": "🃏 VĂ”ta kaart", + "btn_stand": "✋ Seisa", + "btn_double": "💰 Kahekordista (+{bet}⏥)", + "btn_split": "✂ Split (+{bet}⏥)", +} + +# --------------------------------------------------------------------------- +# /jailbreak UI strings +# --------------------------------------------------------------------------- + +JAILBREAK_UI: dict[str, str] = { + "btn_roll": "đŸŽČ Viska tĂ€ringud ({try_}/{max})", + "rolling_desc": f"{E['TipiDICE']} *TĂ€ringud lendavad...*", + "free_desc": "{d1} {d2}\n\n✅ Viskasid duubli - pÀÀsesid vanglast!", + "miss_desc": "{d1} {d2}\n\n{left} katset jÀÀnud. Proovi uuesti!", + "intro_desc": "Oled vangis kuni {ts}.\n\nViska tĂ€ringuid ja proovi **duublit** saada - siis pÀÀsed tasuta vabaks!\nSul on **{tries} katset**. EbaĂ”nnestumisel saad valida: maksa kautsjon **(20–30% saldost, min 350 ⏥)** vĂ”i jÀÀ vanglasse kuni aja lĂ”puni.", + "already_bail": "Oled juba tĂ€ringuid visanud. Ainuke vĂ€ljapÀÀs on kautsjon.\n\n💰 **Kautsjon: {min} – {max}** (20–30% saldost)\nSaldo: {bal} VĂ€ljas: {ts}", + "already_broke": "Oled juba tĂ€ringuid visanud. Sul pole piisavalt raha kautsjoni maksmiseks.\n\n💰 Kautsjoni miinimum: **{min}** - sul on ainult {bal}\nVĂ€ljas: {ts}", + "bail_btn": "💾 Maksa kautsjon", + "bail_broke_desc": "❌ Sul pole piisavalt raha (min {min}).\nJÀÀd vanglasse kuni aja lĂ”puni! Saldo: {balance}", + "bail_paid_desc": "✅ Kautsjon makstud: **{fine}**\nOled vaba! Saldo: {balance}", + "bail_already_free": "✅ Oled juba vaba - kautsjonit ei vĂ”etud. Saldo: {balance}", + "fail_broke_desc": "{d1} {d2} - ei olnud duubel\n\n❌ Sul pole kautsjoni maksmiseks piisavalt raha (min 350 ⏥).\nJÀÀd vanglasse kuni aja lĂ”puni! Saldo: {balance}", + "fail_bail_desc": "{d1} {d2} - ei olnud duubel\n\nKautsjon makstud: **{fine}**\nSaldo: {balance}", + "fail_bail_offer": "{d1} {d2} - ei olnud duubel\n\n💰 **Kautsjon: {min} - {max}** (20-30% saldost)\nSaldo: {bal}\n\nSaad maksta kautsjoni vĂ”i jÀÀda vanglasse kuni aja lĂ”puni.", +} + +# --------------------------------------------------------------------------- +# /slots additional UI strings (titles/footers per outcome) +# --------------------------------------------------------------------------- + +SLOTS_UI: dict[str, str] = { + "playing": "🎰 MĂ€ngimas...", + "jackpot_footer": E["TipiKARIKAS"] + " Kolm karikat! +{change}", + "triple_footer": "✅ Kolm ĂŒhesugust! +{change}", + "pair_footer": "Kaks ĂŒhesugust! +{change}", + "miss_footer": "-{amount}", + "balance_line": "\nSaldo: {balance}", +} + +# --------------------------------------------------------------------------- +# /rps UI strings +# --------------------------------------------------------------------------- + +RPS_UI: dict[str, str] = { + "vs_bot_desc": "Tee oma valik! âŹ‡ïž", + "vs_bot_bet": "\nPanus: {bet}", + "result_tie": "đŸ€ Viik!", + "result_win": "🎉 Sina vĂ”itsid!", + "result_lose": "đŸ€– Bot vĂ”itis!", + "result_desc": "Sina: {player_pick} {player_name}\nBot: {bot_pick} {bot_name}\n\n**{result}**{bet_line}", + "bet_win": "\n+{amount} · Saldo: {balance}", + "bet_lose": "\n-{amount} · Saldo: {balance}", + "bet_tie": "\nViik - panus tagasi · Saldo: {balance}", + "challenge_desc": "{challenger} kutsub vĂ€lja {opponent}{bet}\n\n{opponent}, kas vĂ”tad vastu? âŹ‡ïž", + "challenge_bet": " · Panus: {bet}", + "challenge_footer": "VĂ€ljakutse aegub 60 sekundi pĂ€rast", + "duel_active_desc": "{a} vs {b}{bet}\n\nMĂ”lemad mĂ€ngijad valivad DM-is. ⏳", + "duel_active_bet": " · Panus: {bet}", + "duel_dm": "⚔ **KPK vs {opponent}**{bet}\nTee oma valik! âŹ‡ïž", + "duel_dm_bet": "\nPanus: {bet}", + "duel_waiting": "Valisid {choice} **{name}**. Ootame vastase valikut... ⏳", + "duel_result_a": "**⚔ KPK tulemus vs {opponent}**\nSinu valik: {pick_a} {name_a}\nVastase valik: {pick_b} {name_b}\n\n**{result}**{bet_line}\nSaldo: {balance}", + "duel_verdict_win": "🏆 **{name}** vĂ”itis!", + "duel_verdict_tie": "đŸ€ Viik!", + "duel_broke": "\n⚠ Makse ebaĂ”nnestus (ebapiisav saldo)", + "duel_expire_dm": "⏰ KPK duell aegus - keegi ei teinud valikut Ă”igeaegselt.", + "duel_expire_desc": "{a} vs {b}\n\nMĂ€ng aegus - valik jĂ€i tegemata.", + "duel_dm_fail": "Ei saanud DM saata: **{names}**. Kontrolli privaatsussĂ€tteid.", + "duel_decline": "**{name}** keeldus vĂ€ljakutsest.", + "duel_no_answer": "**{name}** ei vastanud vĂ€ljakutsele.", + "duel_insufficient": "{mention}-l pole piisavalt TipiCOINi panuseks.", + "duel_done_desc": "{a} {pick_a} vs {pick_b} {b}\n\n**{verdict}**\n\n{name_a}: {bal_a}\n{name_b}: {bal_b}", + "server_only": "Seda kĂ€sku saab kasutada ainult serveris.", + "btn_accept": "VĂ”ta vastu ✅", + "btn_decline": "Keeldu ❌", + "btn_rock": "đŸȘš Kivi", + "btn_paper": "📄 Paber", + "btn_scissors": "✂ KÀÀrid", +} +RPS_CHOICES: dict[str, str] = { + "đŸȘš": "Kivi", + "📄": "Paber", + "✂": "KÀÀrid", +} +BJ_UI: dict[str, str] = { + "dealer": "Dealer", + "player": "Sina", + "hand_n": "KĂ€si {n}", + "hand_active": "▶ **KĂ€si {n}**", + "hand_pending": "KĂ€si {n} *...*", + "bust": " đŸ’„", + "balance_line": " · Saldo: {balance}", +} diff --git a/strings/member.py b/strings/member.py new file mode 100644 index 0000000..edcfbbb --- /dev/null +++ b/strings/member.py @@ -0,0 +1,96 @@ +"""Member sync, /check, birthdays, channel + economy-setup admin UI. + +Auto-split from the original strings.py; edit strings here. +""" + +__all__ = [ + 'MEMBER_UI', + 'MEMBER_FIELDS', + 'ECONOMYSETUP_UI', + 'CHANNEL_UI', + 'BIRTHDAY_UI', + 'BIRTHDAY_MONTHS', + 'CHECK_UI', +] + +# --------------------------------------------------------------------------- +# /allowchannel /denychannel /channels UI strings +# --------------------------------------------------------------------------- + +MEMBER_UI: dict[str, str] = { + "title": "đŸ‘€ {name}", + "age_field": "Vanus", + "age_val": "{age}a", +} +MEMBER_FIELDS: list[tuple[str, str]] = [ + ("Nimi", "Nimi"), + ("Organisatsioon", "Organisatsioon"), + ("Valdkond", "Valdkond"), + ("Roll", "Roll"), + ("SĂŒnnipĂ€ev", "SĂŒnnipĂ€ev"), + ("Meil", "Meil"), + ("Telefon", "Telefon"), + ("Discord", "Discord"), + ("User ID", "Kasutaja ID"), + ("Discordis synced?", "SĂŒnkroniseeritud"), + ("Groupi lisatud?", "Grupis"), +] +ECONOMYSETUP_UI: dict[str, str] = { + "created_field": "✅ Loodud", + "existing_field": "♻ Juba olemas", + "footer": "Rollid on nĂŒĂŒd boti rolli all Ă”iges jĂ€rjekorras.", +} +CHANNEL_UI: dict[str, str] = { + "already_allowed": "â„č {channel} on juba lubatud kanalite nimekirjas.", + "added": "✅ {channel} lisatud. Bot vastab kĂ€skudele nĂŒĂŒd ainult lubatud kanalites.", + "not_in_list": "â„č {channel} pole lubatud kanalite nimekirjas.", + "removed": "✅ {channel} eemaldatud lubatud kanalite nimekirjast.", + "removed_last": "✅ {channel} eemaldatud. Lubatud kanalite nimekiri on nĂŒĂŒd tĂŒhi - bot vastab kĂ”ikides kanalites.", + "list_title": "📋 Lubatud kanalid", + "list_empty": "Lubatud kanalite nimekiri on **tĂŒhi** - bot vastab kĂ€skudele kĂ”ikides kanalites.\n\nKasuta `/allowchannel`, et piirata kindlatele kanalitele.", + "list_filled": "Bot vastab kĂ€skudele ainult nendes kanalites:\n\n{lines}\n\n-# Kasuta `/allowchannel` / `/denychannel` nimekirja muutmiseks.", +} + +# --------------------------------------------------------------------------- +# /birthdays UI +# --------------------------------------------------------------------------- + +BIRTHDAY_UI: dict[str, str] = { + "no_entries": "Sellel kuul sĂŒnnipĂ€evi ei ole.", + "today": "tĂ€na 🎉", + "tomorrow": "homme", + "in_days": "{days}p pĂ€rast", + "footer": "Leht {month}/12 · {month_name}", +} +BIRTHDAY_MONTHS: list[str] = [ + "Jaanuar", "Veebruar", "MĂ€rts", "Aprill", "Mai", "Juuni", + "Juuli", "August", "September", "Oktoober", "November", "Detsember", +] + +# --------------------------------------------------------------------------- +# /check summary strings +# --------------------------------------------------------------------------- + +CHECK_UI: dict[str, str] = { + "sheet_stats_header": "📊 **Tabeli statistika** - {total} liiget", + "stat_ok": "✅ **{label}**: kĂ”ik tĂ€idetud", + "stat_warn": "⚠ **{label}** puudub ({count}): {names}{more}", + "stat_more": " (+{count} veel)", + "stat_uid": "Kasutaja ID", + "stat_discord": "Discordi kasutajanimi", + "stat_bday": "SĂŒnnipĂ€ev", + "no_name": "(no name)", + "done": "**Kontroll lĂ”petatud!**", + "already_ok": "✅ Juba korras: {count}", + "fixed": "🔧 Parandatud: {count}", + "not_found": "❓ Pole tabelis: {count}", + "bday_pings": "🎂 SĂŒnnipĂ€eva teavitused: {count}", + "errors": "⚠ Vead: {count}", + "details_header": "**Üksikasjad:**", + "details_more": "... ja {count} rohkem", + "detail_error": "⚠ {error}", + "detail_nickname": "hĂŒĂŒdnimi", + "detail_roles_added": "+rollid: {roles}", + "detail_changed": "🔧 **{name}**: {parts}", + "ids_filled": "\n🔑 TĂ€ideti **{count}** puuduvat kasutaja ID-d.", +} From 191726721c9aa18b67db717a6c54bae21aa6d8a9 Mon Sep 17 00:00:00 2001 From: Rene Arumetsa Date: Wed, 19 Aug 2026 19:53:04 +0300 Subject: [PATCH 05/21] Update docs --- README.md | 10 +++++++- docs/DEV_NOTES.md | 60 +++++++++++++++++++++++----------------------- strings/economy.py | 2 +- 3 files changed, 40 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 23976c2..8b098cd 100644 --- a/README.md +++ b/README.md @@ -483,7 +483,15 @@ Every slash command invocation is logged with the user ID, display name, and all ``` ├── bot.py # Discord client, event handlers, shared helpers; wires command modules together -├── strings.py # All user-facing strings (command descriptions, help text, errors) +├── strings/ # All user-facing strings, split by domain; re-exported so `import strings` works unchanged +│ ├── __init__.py # Re-exports every name from the submodules +│ ├── common.py # System messages, embed TITLEs, ERR, CD_MSG, status/send/patchnotes/reminders +│ ├── commands.py # CMD + OPT descriptions, HELP_CATEGORIES, HELP_UI +│ ├── member.py # /check, member/birthday/channel/economy-setup UI +│ ├── economy.py # Income flavour + income/profile/shop/quests/leaderboard/request UI +│ ├── admin.py # Admin responses, season reset, prestige +│ ├── games.py # Slots, roulette, RPS, blackjack, heist, jailbreak +│ └── fishing.py # Fish catalogue, rarities, /fish UI ├── config.py # Environment variable loader ├── core/ │ ├── economy/ # TipiCOIN business logic (re-exported via core/economy/__init__.py) diff --git a/docs/DEV_NOTES.md b/docs/DEV_NOTES.md index 3ce8aeb..a2b98d6 100644 --- a/docs/DEV_NOTES.md +++ b/docs/DEV_NOTES.md @@ -9,7 +9,7 @@ The codebase is split into **`core/`** (domain logic), **`commands/`** (Discord | File | Purpose | |---|---| | `bot.py` | Discord client, event handlers (`on_ready`, `on_member_join`, ...), background tasks (presence rotation, daily birthday loop), shared helpers (`_award_exp`, `_maybe_remind`, `_parse_amount`, `_PAUSED`), and `register_*_commands(...)` wiring for every command module | -| `strings.py` | **Single source of truth for all user-facing text.** Edit here to change any message. | +| `strings/` | **Single source of truth for all user-facing text**, split into domain submodules (`common`, `commands`, `member`, `economy`, `admin`, `games`, `fishing`) and re-exported via `strings/__init__.py` so `import strings` / `strings.NAME` works unchanged. Edit the relevant submodule to change any message. | | `config.py` | Environment variables (TOKEN, GUILD_ID, PB_URL, etc.) | ### `core/` - domain logic, no Discord coupling @@ -60,16 +60,16 @@ Checklist - do all of these, in order: 1. **`core/economy.py`** - add the `do_` async function with cooldown check, logic, `_commit`, and `_txn` logging 2. **`core/economy.py`** - add the cooldown to `COOLDOWNS` dict if it has one 3. **`core/economy.py`** - add the EXP reward to `EXP_REWARDS` dict -4. **`strings.py` `CMD`** - add the slash command description -5. **`strings.py` `OPT`** - add any parameter descriptions -6. **`strings.py` `TITLE`** - add embed title(s) for success/fail states -7. **`strings.py` `ERR`** - add any error messages (banned, cooldown uses `CD_MSG`, jailed uses `CD_MSG["jailed"]`) -8. **`strings.py` `CD_MSG`** - add cooldown message if command has a cooldown -9. **`strings.py` `HELP_CATEGORIES["tipibot"]["fields"]`** - add the command to the help embed +4. **`strings/commands.py` `CMD`** - add the slash command description +5. **`strings/commands.py` `OPT`** - add any parameter descriptions +6. **`strings/common.py` `TITLE`** - add embed title(s) for success/fail states +7. **`strings/common.py` `ERR`** - add any error messages (banned, cooldown uses `CD_MSG`, jailed uses `CD_MSG["jailed"]`) +8. **`strings/common.py` `CD_MSG`** - add cooldown message if command has a cooldown +9. **`strings/commands.py` `HELP_CATEGORIES["tipibot"]["fields"]`** - add the command to the help embed 10. **`commands/economy__commands.py`** - inside `register_*_commands`, add `@tree.command(name="", ...)` `cmd_`; handle all `res["reason"]` cases 11. **`commands/economy__commands.py`** - call `maybe_remind(user_id, "")` if the command has a cooldown and reminders make sense (the helper is passed in via the `register_*` signature) 12. **`commands/economy__commands.py`** - call `await award_exp(interaction, economy.EXP_REWARDS[""])` on success -13. **`strings.py` `REMINDER_OPTS`** - add a reminder option if the command needs one +13. **`strings/common.py` `REMINDER_OPTS`** - add a reminder option if the command needs one 14. **`bot.py` `_maybe_remind`** - if the command has an item-modified cooldown, add an `elif` branch (this helper still lives in `bot.py` and is shared across all command modules) --- @@ -81,8 +81,8 @@ Checklist: 1. **`core/economy.py` `SHOP`** - add the item dict `{name, emoji, cost, description: strings.ITEM_DESCRIPTIONS["key"]}` 2. **`core/economy.py` `SHOP_TIERS`** - add the key to the correct tier list (1/2/3) 3. **`core/economy.py` `SHOP_LEVEL_REQ`** - add minimum level if it is T2 (≄10) or T3 (≄20) -4. **`strings.py` `ITEM_DESCRIPTIONS`** - add the item description (Estonian flavour + English effect) -5. **`strings.py` `HELP_CATEGORIES["shop"]["fields"]`** - add display entry (sorted by cost) +4. **`strings/economy.py` `ITEM_DESCRIPTIONS`** - add the item description (Estonian flavour + English effect) +5. **`strings/commands.py` `HELP_CATEGORIES["shop"]["fields"]`** - add display entry (sorted by cost) 6. If the item modifies a cooldown: - **`core/economy.py`** - add the `if "item" in user["items"]` branch in the relevant `do_` function - **`bot.py` `_maybe_remind`** - add `elif cmd == "" and "" in items:` branch with the new delay @@ -100,8 +100,8 @@ Checklist: ## Adding a New Admin Command -1. **`strings.py` `CMD`** - add `"[Admin] ..."` description -2. **`strings.py` `HELP_CATEGORIES["admin"]["fields"]`** - add the entry +1. **`strings/commands.py` `CMD`** - add `"[Admin] ..."` description +2. **`strings/commands.py` `HELP_CATEGORIES["admin"]["fields"]`** - add the entry 3. **`commands/economy_admin_commands.py`** (or `commands/ops_admin_commands.py` for non-economy ops) - add the handler with `@app_commands.default_permissions(manage_guild=True)` and `@app_commands.guild_only()` --- @@ -183,26 +183,26 @@ The `SHOP_LEVEL_REQ` dict in `core/economy.py` controls per-item lock thresholds --- -## strings.py Organisation +## strings/ Organisation -Imported as `import strings as S` everywhere. Dicts are read from `bot.py` and from every `commands/*.py` module. +Imported as `import strings as S` everywhere. `strings/` is a package: the names below live in domain submodules and are re-exported from `strings/__init__.py`, so `S.CMD`, `S.ERR`, etc. resolve unchanged regardless of which submodule they live in. Dicts are read from `bot.py` and from every `commands/*.py` module. Edit the submodule shown in the **Module** column. -| Section | Dict | Typical usage | -|---|---|---| -| Flavour text | `WORK_JOBS`, `BEG_LINES`, `CRIME_WIN`, `CRIME_LOSE` | Randomised descriptions | -| Command descriptions | `CMD["key"]` | `@tree.command(description=S.CMD["key"])` | -| Parameter descriptions | `OPT["key"]` | `@app_commands.describe(param=S.OPT["key"])` | -| Help embed | `HELP_CATEGORIES["cat"]` | `cmd_help` (in `bot.py`) | -| Banned message | `MSG_BANNED` | All banned checks | -| Maintenance mode | `MSG_MAINTENANCE` | Shown when `_PAUSED=True` in `bot.py` (toggled by `/pause` in `commands/ops_admin_commands.py`) | -| Reminder options | `REMINDER_OPTS` | `RemindersSelect` dropdown | -| Slots outcomes | `SLOTS_TIERS["tier"]` → `(title, color)` | `cmd_slots` (in `commands/economy_games_commands.py`) | -| Embed titles | `TITLE["key"]` | `discord.Embed(title=S.TITLE["key"])` | -| Error messages | `ERR["key"]` | `send_message(S.ERR["key"])` - use `.format(**kwargs)` for dynamic parts | -| Cooldown messages | `CD_MSG["cmd"].format(ts=cd_ts(...))` | Cooldown responses (`cd_ts` helper passed in by `bot.py`) | -| Shop UI | `SHOP_UI["key"]` | `_shop_embed` (in `commands/economy_support_commands.py`) | -| Item descriptions | `ITEM_DESCRIPTIONS["item_key"]` | `core/economy.py` `SHOP[key]["description"]` | -| Patch notes UI | `PATCHNOTES_UI["key"]` | `commands/info_commands.py` (`/patchnotes`) | +| Section | Dict | Module | Typical usage | +|---|---|---|---| +| Flavour text | `WORK_JOBS`, `BEG_LINES`, `CRIME_WIN`, `CRIME_LOSE` | `economy.py` | Randomised descriptions | +| Command descriptions | `CMD["key"]` | `commands.py` | `@tree.command(description=S.CMD["key"])` | +| Parameter descriptions | `OPT["key"]` | `commands.py` | `@app_commands.describe(param=S.OPT["key"])` | +| Help embed | `HELP_CATEGORIES["cat"]` | `commands.py` | `cmd_help` (in `bot.py`) | +| Banned message | `MSG_BANNED` | `common.py` | All banned checks | +| Maintenance mode | `MSG_MAINTENANCE` | `common.py` | Shown when `_PAUSED=True` in `bot.py` (toggled by `/pause` in `commands/ops_admin_commands.py`) | +| Reminder options | `REMINDER_OPTS` | `common.py` | `RemindersSelect` dropdown | +| Slots outcomes | `SLOTS_TIERS["tier"]` → `(title, color)` | `games.py` | `cmd_slots` (in `commands/economy_games_commands.py`) | +| Embed titles | `TITLE["key"]` | `common.py` | `discord.Embed(title=S.TITLE["key"])` | +| Error messages | `ERR["key"]` | `common.py` | `send_message(S.ERR["key"])` - use `.format(**kwargs)` for dynamic parts | +| Cooldown messages | `CD_MSG["cmd"].format(ts=cd_ts(...))` | `common.py` | Cooldown responses (`cd_ts` helper passed in by `bot.py`) | +| Shop UI | `SHOP_UI["key"]` | `economy.py` | `_shop_embed` (in `commands/economy_support_commands.py`) | +| Item descriptions | `ITEM_DESCRIPTIONS["item_key"]` | `economy.py` | `core/economy.py` `SHOP[key]["description"]` | +| Patch notes UI | `PATCHNOTES_UI["key"]` | `common.py` | `commands/info_commands.py` (`/patchnotes`) | --- diff --git a/strings/economy.py b/strings/economy.py index 2a20e50..1fdb01e 100644 --- a/strings/economy.py +++ b/strings/economy.py @@ -314,7 +314,7 @@ COOLDOWNS_UI: dict[str, str] = { "note_monitor": " *(monitor: 40min)*", "note_hiirematt": " *(hiirematt: 3min)*", "note_ussipurk": " *(ussipurk: 90s)*", - "jailed": "\nïżœ **Vanglas** - vabaneb ", + "jailed": "\n🔒 **Vanglas** - vabaneb ", "jail_expired": "\n🔓 Vangla lĂ”ppes", } From 254992c6429df3ebe7d1d511decee9fd673d046f Mon Sep 17 00:00:00 2001 From: Rene Arumetsa Date: Wed, 19 Aug 2026 20:02:47 +0300 Subject: [PATCH 06/21] docs: point core/economy references at the package submodules core/economy has long been a package (store/income/gambling/shop/levels/ jail/heist/prestige/fishing/quests/leaderboards/house/admin) rather than a single core/economy.py, but README.md and docs/DEV_NOTES.md still described it as one file in ~27 places. Point every reference at the real submodule, add a "Defined in" column to the constants quick-reference and a Module column to the strings table, and fix the stale FISH -> FISH_CATALOGUE name. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 4 ++-- docs/DEV_NOTES.md | 56 ++++++++++++++++++++++++----------------------- 2 files changed, 31 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 8b098cd..f400608 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,7 @@ If a member joins and their birthday is within `BIRTHDAY_WINDOW_DAYS` days, a bi ## TipiCOIN Economy -All economy data is stored in **PocketBase** (`economy_users` collection - see `core/pb_client.py`). The currency is **TipiCOIN** (⏥), displayed as a custom Discord emoji configured in `core/economy.py → COIN`. +All economy data is stored in **PocketBase** (`economy_users` collection - see `core/pb_client.py`). The currency is **TipiCOIN** (⏥), displayed as a custom Discord emoji configured in `core/economy/store.py → COIN`. --- @@ -457,7 +457,7 @@ All items are **permanent** once purchased **except Anticheat**, which expires a Commands that accept a coin amount (`/give`, `/roulette`, `/rps`, `/slots`, `/blackjack`) accept `"all"` as the amount to wager your entire balance. ### Custom emoji -Change `COIN` in `core/economy.py` to any Discord emoji string: +Change `COIN` in `core/economy/store.py` to any Discord emoji string: ```python COIN = "<:tipicoin:YOUR_EMOJI_ID>" ``` diff --git a/docs/DEV_NOTES.md b/docs/DEV_NOTES.md index a2b98d6..8fa1796 100644 --- a/docs/DEV_NOTES.md +++ b/docs/DEV_NOTES.md @@ -16,7 +16,7 @@ The codebase is split into **`core/`** (domain logic), **`commands/`** (Discord | File | Purpose | |---|---| -| `core/economy.py` | All economy business logic (`do_daily`, `do_work`, ...), data model, constants (SHOP, COOLDOWNS, LEVEL_ROLES, EXP_REWARDS, JAIL_DURATION, ...) | +| `core/economy/` | Economy business logic **package**, re-exported via `core/economy/__init__.py` so callers use `from core import economy` + attribute access (`economy.do_daily`, `economy.SHOP`, ...). Submodules: `store.py` (user records, per-user locks, `COOLDOWNS`, `JAIL_DURATION`, `COIN`, `get_user`/`_commit`/`_txn`), `income.py`, `gambling.py`, `fishing.py`, `jail.py`, `heist.py`, `prestige.py`, `shop.py`, `levels.py`, `quests.py`, `leaderboards.py`, `house.py`, `admin.py` | | `core/pb_client.py` | Async PocketBase REST client - auth token cache, CRUD on `economy_users` collection | | `core/sheets.py` | Google Sheets integration (member sync) | | `core/member_sync.py` | Birthday/member sync helpers | @@ -57,9 +57,9 @@ Pick the `commands/economy_*_commands.py` file that matches the new command's ca Checklist - do all of these, in order: -1. **`core/economy.py`** - add the `do_` async function with cooldown check, logic, `_commit`, and `_txn` logging -2. **`core/economy.py`** - add the cooldown to `COOLDOWNS` dict if it has one -3. **`core/economy.py`** - add the EXP reward to `EXP_REWARDS` dict +1. **`core/economy/.py`** (e.g. `income.py`, `gambling.py`, `fishing.py`) - add the `do_` async function with cooldown check, logic, `_commit`, and `_txn` logging (`get_user`/`_commit`/`_txn` live in `store.py`) +2. **`core/economy/store.py`** - add the cooldown to `COOLDOWNS` dict if it has one +3. **`core/economy/levels.py`** - add the EXP reward to `EXP_REWARDS` dict 4. **`strings/commands.py` `CMD`** - add the slash command description 5. **`strings/commands.py` `OPT`** - add any parameter descriptions 6. **`strings/common.py` `TITLE`** - add embed title(s) for success/fail states @@ -78,13 +78,13 @@ Checklist - do all of these, in order: Checklist: -1. **`core/economy.py` `SHOP`** - add the item dict `{name, emoji, cost, description: strings.ITEM_DESCRIPTIONS["key"]}` -2. **`core/economy.py` `SHOP_TIERS`** - add the key to the correct tier list (1/2/3) -3. **`core/economy.py` `SHOP_LEVEL_REQ`** - add minimum level if it is T2 (≄10) or T3 (≄20) +1. **`core/economy/shop.py` `SHOP`** - add the item dict `{name, emoji, cost, description: strings.ITEM_DESCRIPTIONS["key"]}` +2. **`core/economy/shop.py` `SHOP_TIERS`** - add the key to the correct tier list (1/2/3) +3. **`core/economy/shop.py` `SHOP_LEVEL_REQ`** - add minimum level if it is T2 (≄10) or T3 (≄20) 4. **`strings/economy.py` `ITEM_DESCRIPTIONS`** - add the item description (Estonian flavour + English effect) 5. **`strings/commands.py` `HELP_CATEGORIES["shop"]["fields"]`** - add display entry (sorted by cost) 6. If the item modifies a cooldown: - - **`core/economy.py`** - add the `if "item" in user["items"]` branch in the relevant `do_` function + - **`core/economy/.py`** - add the `if "item" in user["items"]` branch in the relevant `do_` function - **`bot.py` `_maybe_remind`** - add `elif cmd == "" and "" in items:` branch with the new delay - **`commands/economy_profile_commands.py` `cmd_cooldowns`** - add the item annotation to the relevant status line @@ -92,7 +92,7 @@ Checklist: ## Adding a New Level Role -1. **`core/economy.py` `LEVEL_ROLES`** - add `(min_level, "RoleName")` in descending level order (highest first) +1. **`core/economy/levels.py` `LEVEL_ROLES`** - add `(min_level, "RoleName")` in descending level order (highest first) 2. **`bot.py` `_ensure_level_role`** - no changes needed (uses `LEVEL_ROLES` dynamically) 3. Run **`/economysetup`** in the server to create the role and set its position @@ -110,7 +110,7 @@ Checklist: ### Storage -All economy state is stored in **PocketBase** (`economy_users` collection). `core/pb_client.py` owns all reads/writes. Each `do_*` function in `core/economy.py` calls `get_user()` → mutates the local dict → calls `_commit()`. `_commit` does a `PATCH` to PocketBase. +All economy state is stored in **PocketBase** (`economy_users` collection). `core/pb_client.py` owns all reads/writes. Each `do_*` function in `core/economy/` calls `get_user()` (from `store.py`) → mutates the local dict → calls `_commit()`. `_commit` does a `PATCH` to PocketBase. ### Currency & Income Sources @@ -141,10 +141,10 @@ Commands that accept a coin amount (`/give`, `/roulette`, `/rps`, `/slots`, `/bl - `/jailbreak`: 3 dice rolls, need doubles to escape free. On fail - bail = 20-30% of balance, min 350⏥. If balance < 350⏥, player stays jailed until timer. - **Blocked while jailed**: `/work`, `/beg`, `/crime`, `/rob`, `/give` (checked in `do_*` functions via `_is_jailed`) -### EXP Rewards (from `EXP_REWARDS` in `core/economy.py`) +### EXP Rewards (from `EXP_REWARDS` in `core/economy/levels.py`) EXP is awarded on every successful command use. Level formula: `level = max(1, floor(sqrt(exp / 6)))` (see `get_level` / `exp_for_level`). Thresholds: Level 5 = 150 EXP, Level 10 = 600, Level 20 = 2 400, Level 30 = 5 400. -Gambling EXP is bet-scaled via `gamble_exp(bet)`; fish EXP is per-species in `FISH` (common 2–3, uncommon 6–7, rare 10, epic 14–15, legendary 25). +Gambling EXP is bet-scaled via `gamble_exp(bet)`; fish EXP is per-species in `FISH_CATALOGUE` (common 2–3, uncommon 6–7, rare 10, epic 14–15, legendary 25). --- @@ -179,7 +179,7 @@ Role assignment: | T3 | 20 | monitor_360, karikas, gaming_tool | Shop display is sorted by cost (ascending) within each tier. -The `SHOP_LEVEL_REQ` dict in `core/economy.py` controls per-item lock thresholds. +The `SHOP_LEVEL_REQ` dict in `core/economy/shop.py` controls per-item lock thresholds. --- @@ -201,27 +201,29 @@ Imported as `import strings as S` everywhere. `strings/` is a package: the names | Error messages | `ERR["key"]` | `common.py` | `send_message(S.ERR["key"])` - use `.format(**kwargs)` for dynamic parts | | Cooldown messages | `CD_MSG["cmd"].format(ts=cd_ts(...))` | `common.py` | Cooldown responses (`cd_ts` helper passed in by `bot.py`) | | Shop UI | `SHOP_UI["key"]` | `economy.py` | `_shop_embed` (in `commands/economy_support_commands.py`) | -| Item descriptions | `ITEM_DESCRIPTIONS["item_key"]` | `economy.py` | `core/economy.py` `SHOP[key]["description"]` | +| Item descriptions | `ITEM_DESCRIPTIONS["item_key"]` | `economy.py` | `core/economy/shop.py` `SHOP[key]["description"]` | | Patch notes UI | `PATCHNOTES_UI["key"]` | `common.py` | `commands/info_commands.py` (`/patchnotes`) | --- ## Constants Location Quick-Reference -| Constant | File | Description | +All are re-exported from `core/economy/__init__.py`, so code reads them as `economy.` regardless of which submodule defines them. Edit the file in the **Defined in** column. + +| Constant | Defined in | Description | |---|---|---| -| `SHOP` | `core/economy.py` | All shop items (name, emoji, cost, description) | -| `SHOP_TIERS` | `core/economy.py` | Which items are in T1/T2/T3 | -| `SHOP_LEVEL_REQ` | `core/economy.py` | Min level per item | -| `COOLDOWNS` | `core/economy.py` | Base cooldown per command | -| `JAIL_DURATION` | `core/economy.py` | How long jail lasts | -| `LEVEL_ROLES` | `core/economy.py` | `[(min_level, "RoleName"), ...]` highest first | -| `ECONOMY_ROLE` | `core/economy.py` | Name of the base economy participation role | -| `EXP_REWARDS` | `core/economy.py` | EXP per command | -| `FISH` | `core/economy.py` | Fish species table (rarity, weight, coins, exp) | -| `HOUSE_ID` | `core/economy.py` | Bot's user ID (house account for /rob) | -| `MIN_BAIL` | `core/economy.py` | Minimum bail payment (350⏥) | -| `COIN` | `core/economy.py` | The coin emoji string | +| `SHOP` | `core/economy/shop.py` | All shop items (name, emoji, cost, description) | +| `SHOP_TIERS` | `core/economy/shop.py` | Which items are in T1/T2/T3 | +| `SHOP_LEVEL_REQ` | `core/economy/shop.py` | Min level per item | +| `COOLDOWNS` | `core/economy/store.py` | Base cooldown per command | +| `JAIL_DURATION` | `core/economy/store.py` | How long jail lasts | +| `LEVEL_ROLES` | `core/economy/levels.py` | `[(min_level, "RoleName"), ...]` highest first | +| `ECONOMY_ROLE` | `core/economy/levels.py` | Name of the base economy participation role | +| `EXP_REWARDS` | `core/economy/levels.py` | EXP per command | +| `FISH_CATALOGUE` | `core/economy/fishing.py` | Fish species table (rarity, weight, coins, exp) | +| `HOUSE_ID` | `core/economy/house.py` | Bot's user ID (house account for /rob) | +| `MIN_BAIL` | `core/economy/jail.py` | Minimum bail payment (350⏥) | +| `COIN` | `core/economy/store.py` | The coin emoji string | | `_PAUSED` | `bot.py` | In-memory maintenance flag; toggled by `/pause`; blocks all non-admin commands | --- From b9b4c7c4c7b54a7d0b069ae49d0fa501ab761c57 Mon Sep 17 00:00:00 2001 From: Rene Arumetsa Date: Wed, 19 Aug 2026 20:02:55 +0300 Subject: [PATCH 07/21] test(strings): guard that every string is re-exported from the package strings/ is split into domain submodules whose names are re-exported from strings/__init__.py. Adding a constant to a submodule and forgetting to re-export it - or shadowing a name across two submodules - would only surface as a runtime crash in a command. This test asserts every submodule __all__ entry is reachable as strings.NAME, that no name is defined twice, and that strings.__all__ matches the union of the submodules. Submodules are auto-discovered, so a new one is covered without editing the test. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_strings.py | 77 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 tests/test_strings.py diff --git a/tests/test_strings.py b/tests/test_strings.py new file mode 100644 index 0000000..ce8c671 --- /dev/null +++ b/tests/test_strings.py @@ -0,0 +1,77 @@ +"""Guard tests for the strings/ package. + +strings/ is split into domain submodules whose names are re-exported from +strings/__init__.py so callers keep using `strings.NAME`. It's easy to add a +constant to a submodule and forget to re-export it (or to shadow a name across +two submodules) - both would only surface as a runtime crash in a command. +These tests catch that at test time instead. +""" + +import importlib +import pkgutil + +import strings + +# Auto-discover submodules so a newly added one is covered without editing this. +SUBMODULES = sorted(m.name for m in pkgutil.iter_modules(strings.__path__)) + + +def _submodule(name): + return importlib.import_module(f"strings.{name}") + + +class TestStringsPackage: + def test_submodules_discovered(self): + # Sanity: the split actually produced multiple domain modules. + assert len(SUBMODULES) >= 2, SUBMODULES + + def test_every_submodule_declares_all(self): + for name in SUBMODULES: + mod = _submodule(name) + assert hasattr(mod, "__all__"), f"strings.{name} is missing __all__" + + def test_all_entries_exist_in_their_submodule(self): + for name in SUBMODULES: + mod = _submodule(name) + for const in mod.__all__: + assert hasattr(mod, const), ( + f"{const} is listed in strings.{name}.__all__ " + f"but not defined in that module" + ) + + def test_every_name_is_reexported_from_package(self): + for name in SUBMODULES: + mod = _submodule(name) + for const in mod.__all__: + assert hasattr(strings, const), ( + f"{const} is defined in strings.{name} but not re-exported " + f"from strings/__init__.py - add it to the imports there" + ) + assert getattr(strings, const) is getattr(mod, const), ( + f"strings.{const} is not the same object as strings.{name}.{const}" + ) + + def test_no_name_defined_in_two_submodules(self): + origin = {} + for name in SUBMODULES: + for const in _submodule(name).__all__: + assert const not in origin, ( + f"{const} is defined in both strings.{origin[const]} " + f"and strings.{name}" + ) + origin[const] = name + + def test_package_all_matches_submodule_union(self): + union = set() + for name in SUBMODULES: + union |= set(_submodule(name).__all__) + assert set(strings.__all__) == union, { + "missing_from_package_all": sorted(union - set(strings.__all__)), + "extra_in_package_all": sorted(set(strings.__all__) - union), + } + + def test_public_constants_all_declared(self): + # Every UPPER_CASE constant exposed on the package is accounted for in + # __all__ (E, the emoji helper, is an implementation detail, not a string). + public = {n for n in vars(strings) if n.isupper() and n != "E"} + assert public == set(strings.__all__) From a2e1601d0e056bd3d14b91dc752ecc6c55e469f7 Mon Sep 17 00:00:00 2001 From: Rene Arumetsa Date: Wed, 19 Aug 2026 20:32:36 +0300 Subject: [PATCH 08/21] feat(economy): add /consumables shop of repeatable, expiring boosts The economy had many coin faucets but almost no sinks: the /shop is one-time ownership, and gambling/rob fines route to the house (which players drain back via jackpots and heists), so they recirculate rather than destroy coins. Result: steady inflation. Add a consumables shop as a true recurring sink - buying destroys the coins and grants a temporary boost, so there's always something to spend on after gear is maxed: - Energiajook XL (500) - 1h of 2x earnings on /work, /beg, /crime - XP jook (500) - 1h of 2x EXP - Kohv (300) - instantly clears all cooldowns Timed buffs live in a new active_buffs field ({kind: expiry_iso}), pruned on read; rebuying extends the timer. Effects hook where they belong: earn_mult in income.do_work/do_beg/do_crime, exp_buff_mult in levels.award_exp; kohv is self-contained. New /consumables command browses the menu (with active buffs) or buys a boost. Covered by 9 tests. Note: active_buffs is a new PocketBase field - run scripts/sync_pb_schema.py before deploying or buffs won't persist. Co-Authored-By: Claude Opus 4.8 (1M context) --- commands/economy_support_commands.py | 75 ++++++++++++++ core/economy/__init__.py | 5 +- core/economy/consumables.py | 146 +++++++++++++++++++++++++++ core/economy/income.py | 6 +- core/economy/levels.py | 3 +- core/economy/store.py | 2 + docs/PATCHNOTES.md | 5 + strings/__init__.py | 4 + strings/commands.py | 2 + strings/economy.py | 26 +++++ tests/test_consumables.py | 102 +++++++++++++++++++ 11 files changed, 371 insertions(+), 5 deletions(-) create mode 100644 core/economy/consumables.py create mode 100644 tests/test_consumables.py diff --git a/commands/economy_support_commands.py b/commands/economy_support_commands.py index 5d11e56..f003096 100644 --- a/commands/economy_support_commands.py +++ b/commands/economy_support_commands.py @@ -163,6 +163,81 @@ def register_economy_support_commands( await interaction.response.send_message(embed=embed, view=view) view.message = await interaction.original_response() + # -- /consumables ------------------------------------------------------- + def _consumables_embed(user_data: dict) -> discord.Embed: + embed = discord.Embed( + title=S.CONSUMABLES_UI["title"], + description=S.CONSUMABLES_UI["desc"].format(bal=coin(user_data["balance"])), + color=0xF4C430, + ) + active = economy.active_buffs(user_data) + if active: + lines = [ + S.CONSUMABLES_UI["buff_line"].format( + name=S.CONSUMABLES_UI[f"kind_{kind}"], + time=economy.format_td(economy.buff_remaining(user_data, kind)), + ) + for kind in active + ] + buff_value = "\n".join(lines) + else: + buff_value = S.CONSUMABLES_UI["active_none"] + embed.add_field(name=S.CONSUMABLES_UI["active_header"], value=buff_value, inline=False) + for cons in economy.CONSUMABLES.values(): + embed.add_field( + name=f"{cons['emoji']} {cons['name']} · {cons['cost']} {economy.COIN}", + value=cons["description"], + inline=False, + ) + return embed + + @tree.command(name="consumables", description=S.CMD["consumables"]) + @app_commands.describe(ese=S.OPT["consumable_ese"]) + @app_commands.choices( + ese=[ + app_commands.Choice(name=f"{c['name']} ({c['cost']} TipiCOINi)", value=cid) + for cid, c in economy.CONSUMABLES.items() + ] + ) + async def cmd_consumables( + interaction: discord.Interaction, + ese: app_commands.Choice[str] | None = None, + ): + if ese is None: + data = await economy.get_user(interaction.user.id) + await interaction.response.send_message( + embed=_consumables_embed(data), ephemeral=True + ) + return + + res = await economy.do_buy_consumable(interaction.user.id, ese.value) + if not res["ok"]: + if res["reason"] == "banned": + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + elif res["reason"] == "insufficient": + await interaction.response.send_message( + S.ERR["broke_need"].format(need=coin(res["need"])), ephemeral=True + ) + else: + await interaction.response.send_message(S.ERR["item_not_found"], ephemeral=True) + return + + cons = res["consumable"] + if res["instant"]: + desc = S.CONSUMABLES_UI["bought_instant"].format(balance=coin(res["balance"])) + else: + key = "bought_extended" if res["extended"] else "bought_buff" + desc = S.CONSUMABLES_UI[key].format( + time=economy.format_td(res["remaining"]), + balance=coin(res["balance"]), + ) + embed = discord.Embed( + title=S.CONSUMABLES_UI["bought_title"].format(emoji=cons["emoji"], name=cons["name"]), + description=desc, + color=0x57F287, + ) + await interaction.response.send_message(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 eb47512..52bb322 100644 --- a/core/economy/__init__.py +++ b/core/economy/__init__.py @@ -18,6 +18,7 @@ from .house import * from .house import _credit_house, _house_record_id from .levels import * from .shop import * +from .consumables import * from .fishing import * from .quests import * from .quests import _ensure_quests, _pick_quests, _quest_view @@ -30,6 +31,6 @@ from .heist import * from .admin import * from . import ( # noqa: E402 (submodules addressable as economy.store etc.) - admin, fishing, gambling, heist, house, income, jail, leaderboards, - levels, prestige, quests, shop, store, + admin, consumables, fishing, gambling, heist, house, income, jail, + leaderboards, levels, prestige, quests, shop, store, ) diff --git a/core/economy/consumables.py b/core/economy/consumables.py new file mode 100644 index 0000000..09f5aae --- /dev/null +++ b/core/economy/consumables.py @@ -0,0 +1,146 @@ +"""Consumables: repeatable, expiring boosts - a recurring coin sink. + +Unlike SHOP items (permanent, bought once), consumables are bought over and +over. Buying either activates a timed buff (stored in user["active_buffs"] as +{kind: expiry_iso}) or applies an instant effect, and the coins are destroyed - +so this keeps the shop relevant, and the economy draining, after a player has +maxed out permanent gear. + +Effect hooks live where the effect belongs: + - "earn" buff -> income.do_work / do_beg / do_crime (via earn_mult) + - "exp" buff -> levels.award_exp (via exp_buff_mult) + - instant kohv -> handled entirely here (wipes cooldown timestamps) +""" + +from __future__ import annotations + +from datetime import timedelta +from typing import TypedDict + +import strings + +from ..pb_client import DatabaseError +from ..emoji import EMOJI as E +from .store import _commit, _locked_by, _now, _parse_dt, _txn, get_user + + +class Consumable(TypedDict): + name: str + emoji: str + cost: int + description: str + kind: str # active_buffs key ("earn"/"exp"), or "instant" + duration_min: int # buff lifetime in minutes (0 for instant effects) + + +CONSUMABLES: dict[str, Consumable] = { + "energy_xl": { + "name": "Energiajook XL", + "emoji": E["TipiBULL"], + "cost": 500, + "description": strings.CONSUMABLE_DESCRIPTIONS["energy_xl"], + "kind": "earn", + "duration_min": 60, + }, + "xp_potion": { + "name": "XP jook", + "emoji": "✹", + "cost": 500, + "description": strings.CONSUMABLE_DESCRIPTIONS["xp_potion"], + "kind": "exp", + "duration_min": 60, + }, + "kohv": { + "name": "Kohv", + "emoji": "☕", + "cost": 300, + "description": strings.CONSUMABLE_DESCRIPTIONS["kohv"], + "kind": "instant", + "duration_min": 0, + }, +} + +# Multiplier granted while a buff of each kind is active. +_BUFF_MULT: dict[str, float] = {"earn": 2.0, "exp": 2.0} + +# Cooldown timestamps an instant "kohv" clears. +_COOLDOWN_FIELDS = ("last_work", "last_beg", "last_crime", "last_rob", "last_fish") + + +# --------------------------------------------------------------------------- +# Buff inspection helpers (pure - safe to call while holding a user lock) +# --------------------------------------------------------------------------- +def active_buffs(user) -> dict[str, str]: + """Return {kind: expiry_iso} for buffs that have not expired yet.""" + buffs = user.get("active_buffs") or {} + now = _now() + out: dict[str, str] = {} + for kind, expiry in buffs.items(): + dt = _parse_dt(expiry) + if dt is not None and dt > now: + out[kind] = expiry + return out + + +def buff_remaining(user, kind: str) -> timedelta | None: + """Remaining time on a buff kind, or None if it is inactive.""" + expiry = active_buffs(user).get(kind) + if expiry is None: + return None + return _parse_dt(expiry) - _now() + + +def earn_mult(user) -> float: + """Earnings multiplier from an active 'earn' buff (1.0 if none).""" + return _BUFF_MULT["earn"] if "earn" in active_buffs(user) else 1.0 + + +def exp_buff_mult(user) -> float: + """EXP multiplier from an active 'exp' buff (1.0 if none).""" + return _BUFF_MULT["exp"] if "exp" in active_buffs(user) else 1.0 + + +# --------------------------------------------------------------------------- +# /consumables purchase +# --------------------------------------------------------------------------- +@_locked_by(0) +async def do_buy_consumable(user_id: int, cons_id: str) -> dict: + if cons_id not in CONSUMABLES: + return {"ok": False, "reason": "not_found"} + 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"} + + cons = CONSUMABLES[cons_id] + if user["balance"] < cons["cost"]: + return {"ok": False, "reason": "insufficient", "need": cons["cost"] - user["balance"]} + + user["balance"] -= cons["cost"] + + extended = False + if cons["kind"] == "instant": + 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 + + await _commit(user_id, user) + _txn("BUY_CONSUMABLE", user=user_id, item=cons_id, cost=f"-{cons['cost']}", bal=user["balance"]) + + return { + "ok": True, + "consumable": cons, + "balance": user["balance"], + "instant": cons["kind"] == "instant", + "extended": extended, + "remaining": None if cons["kind"] == "instant" else buff_remaining(user, cons["kind"]), + } diff --git a/core/economy/income.py b/core/economy/income.py index b0b1e43..8c5f265 100644 --- a/core/economy/income.py +++ b/core/economy/income.py @@ -14,6 +14,7 @@ from .store import ( _is_jailed, _locked_by, _log, _now, _prestige_mult, _txn, get_user, ) from .house import _credit_house +from .consumables import earn_mult # --------------------------------------------------------------------------- @@ -126,7 +127,7 @@ async def do_work(user_id: int) -> dict: work_plus_level = (user.get("prestige_upgrades") or {}).get("work_plus", 0) work_plus_mult = 1.0 + work_plus_level * PRESTIGE_SHOP["work_plus"]["effect"] coin_mult, _ = _prestige_mult(user) - earned = int(base * job_mult * worker_mult * desk_mult * (3.0 if lucky else 1.0) * work_plus_mult * coin_mult) + earned = int(base * job_mult * worker_mult * desk_mult * (3.0 if lucky else 1.0) * work_plus_mult * coin_mult * earn_mult(user)) user["balance"] += earned user["last_work"] = _now().isoformat() user["work_count"] = user.get("work_count", 0) + 1 @@ -169,7 +170,7 @@ async def do_beg(user_id: int) -> dict: jailed = bool(_is_jailed(user)) beg_mult = 2 if "klaviatuur" in user["items"] else 1 coin_mult, _ = _prestige_mult(user) - earned = int(random.randint(10, 40) * beg_mult * coin_mult) + earned = int(random.randint(10, 40) * beg_mult * coin_mult * earn_mult(user)) user["balance"] += earned user["last_beg"] = _now().isoformat() user["beg_count"] = user.get("beg_count", 0) + 1 @@ -217,6 +218,7 @@ async def do_crime(user_id: int) -> dict: earned = random.randint(200, 500) if "mikrofon" in user["items"]: earned = int(earned * 1.3) + earned = int(earned * earn_mult(user)) user["balance"] += earned user["crimes_succeeded"] = user.get("crimes_succeeded", 0) + 1 user["lifetime_earned"] = user.get("lifetime_earned", 0) + earned diff --git a/core/economy/levels.py b/core/economy/levels.py index 0067379..5a965d0 100644 --- a/core/economy/levels.py +++ b/core/economy/levels.py @@ -5,6 +5,7 @@ from __future__ import annotations import math from .store import _locked_by, _prestige_mult, get_user, _commit +from .consumables import exp_buff_mult # --------------------------------------------------------------------------- @@ -69,7 +70,7 @@ async def award_exp(user_id: int, amount: int) -> dict: """Add EXP to a user. Applies prestige exp_mult. Returns old_level, new_level, total exp.""" user = await get_user(user_id) _, exp_mult = _prestige_mult(user) - gained = max(1, int(amount * exp_mult)) + gained = max(1, int(amount * exp_mult * exp_buff_mult(user))) old_exp = user.get("exp", 0) new_exp = old_exp + gained old_level = get_level(old_exp) diff --git a/core/economy/store.py b/core/economy/store.py index 70a1ec4..5f4d43b 100644 --- a/core/economy/store.py +++ b/core/economy/store.py @@ -106,6 +106,7 @@ class UserData(TypedDict, total=False): last_streak_date: str | None # ISO date "YYYY-MM-DD" items: list[str] item_uses: dict # {item_id: remaining_uses} for consumables + active_buffs: dict # {buff_kind: expiry_iso} for timed consumable boosts jailed_until: str | None # ISO datetime or None jailbreak_used: bool reminders: list[str] # command names user wants DM reminders for @@ -159,6 +160,7 @@ def _default_user() -> UserData: "last_streak_date": None, "items": [], "item_uses": {}, + "active_buffs": {}, "jailed_until": None, "jailbreak_used": False, "reminders": ["daily", "work", "beg", "crime", "rob"], diff --git a/docs/PATCHNOTES.md b/docs/PATCHNOTES.md index 64aed7f..0306e73 100644 --- a/docs/PATCHNOTES.md +++ b/docs/PATCHNOTES.md @@ -3,6 +3,11 @@ Here you'll find an overview of TipiBOT updates. Latest changes are at the top. Format each version with a `## ` header (e.g. `## v0.1.0 — 2026-05-03`). +## v0.3.0 — 2026-08-19 + +- Added `/consumables` — a shop of repeatable, temporary boosts you can buy over and over again (unlike the permanent gear in `/shop`): **Energiajook XL** (1 hour of 2× earnings from `/work`, `/beg` and `/crime`), **XP jook** (1 hour of 2× EXP), and **Kohv** (instantly clears all your cooldowns). Run `/consumables` with no option to browse the menu and see which boosts are still ticking, or pick one to buy and activate it. Buying the same boost again extends its timer instead of wasting it. +- Fixed a broken icon on the "jailed" line in `/cooldowns` + ## v0.2.0 — 2026-07-22 - Added `/quests` — daily and weekly quests with a "claim rewards" button. Three daily quests refresh every day and two weekly quests refresh every week; every player gets their own personal set that rotates over time. Complete objectives like working, fishing, wagering, or pulling off crimes to earn TipiCOIN and EXP. diff --git a/strings/__init__.py b/strings/__init__.py index 5f34e6c..4c65c3a 100644 --- a/strings/__init__.py +++ b/strings/__init__.py @@ -56,6 +56,8 @@ from .economy import ( QUEST_DESCRIPTIONS, SHOP_UI, ITEM_DESCRIPTIONS, + CONSUMABLES_UI, + CONSUMABLE_DESCRIPTIONS, JAILED_UI, SHOP_BTN, DAILY_UI, @@ -146,6 +148,8 @@ __all__ = [ 'QUEST_DESCRIPTIONS', 'SHOP_UI', 'ITEM_DESCRIPTIONS', + 'CONSUMABLES_UI', + 'CONSUMABLE_DESCRIPTIONS', 'JAILED_UI', 'SHOP_BTN', 'DAILY_UI', diff --git a/strings/commands.py b/strings/commands.py index 7cc66e9..e3a9a28 100644 --- a/strings/commands.py +++ b/strings/commands.py @@ -73,6 +73,7 @@ CMD: dict[str, str] = { "fishsell": "MĂŒĂŒ kalu oma inventarist", "patchnotes": "Vaata TipiBOTi viimaseid muudatusi ja uuendusi", "quests": "Vaata oma pĂ€eva- ja nĂ€dalaĂŒlesandeid ning nĂ”ua auhinnad", + "consumables": "Sirvi ja osta turgutusi (korduvostetavad boostid)", } # --------------------------------------------------------------------------- @@ -90,6 +91,7 @@ OPT: dict[str, str] = { "give_kasutaja": "Kellele annad?", "give_summa": "Kui palju annad? ('all' = kogu saldo)", "buy_ese": "Eseme nimi (vaata /shop)", + "consumable_ese": "Turgutus, mida osta (tĂŒhjaks jĂ€ttes nĂ€eb menĂŒĂŒd)", "rps_panus": "Valikuline TipiCOINide panus ('all' = kogu saldo)", "rps_vastane": "VĂ€ljakutse teisele mĂ€ngijale (PvP)", "slots_panus": "Panus TipiCOINides ('all' = kogu saldo)", diff --git a/strings/economy.py b/strings/economy.py index 1fdb01e..f8e4557 100644 --- a/strings/economy.py +++ b/strings/economy.py @@ -15,6 +15,8 @@ __all__ = [ 'QUEST_DESCRIPTIONS', 'SHOP_UI', 'ITEM_DESCRIPTIONS', + 'CONSUMABLES_UI', + 'CONSUMABLE_DESCRIPTIONS', 'JAILED_UI', 'SHOP_BTN', 'DAILY_UI', @@ -214,6 +216,30 @@ ITEM_DESCRIPTIONS: dict[str, str] = { "kalavork": "Suurem vĂ”rk = suuremad kalad. KĂ”igi kalade haruldus tĂ”useb ĂŒhe astme vĂ”rra.", "echolood": "TĂ€pne ehholood nĂ€itab kala tĂ€pset asukohta. Haukamise aken 2s → 3s.", } + +# --------------------------------------------------------------------------- +# Consumables (repeatable, expiring boosts - a recurring coin sink) +# --------------------------------------------------------------------------- +CONSUMABLE_DESCRIPTIONS: dict[str, str] = { + "energy_xl": "Topeltannus kofeiini. **1 tund**: /work, /beg ja /crime teenivad **2x** rohkem.", + "xp_potion": "Kahtlane roheline jook. **1 tund**: kĂ”ik EXP-allikad annavad **2x** rohkem.", + "kohv": "Kange kohv Ă€ratab su ĂŒles. Nullib kohe kĂ”ik ooteajad (work, beg, crime, rob, fish).", +} + +CONSUMABLES_UI: dict[str, str] = { + "title": "☕ Turgutused", + "desc": "Ühekordsed, korduvostetavad boostid. Saldo: {bal} · Osta `/consumables `", + "active_header": "⏳ Aktiivsed boostid", + "active_none": "Ühtegi boosti pole aktiivne.", + "buff_line": "{name} - veel **{time}**", + "kind_earn": "⚡ 2x tulu", + "kind_exp": "✹ 2x EXP", + "bought_title": "{emoji} {name} ostetud!", + "bought_buff": "Boost on aktiivne **{time}**.\nUus saldo: {balance}", + "bought_extended": "Boosti pikendati - aktiivne veel **{time}**.\nUus saldo: {balance}", + "bought_instant": "☕ KĂ”ik ooteajad nullitud!\nUus saldo: {balance}", +} + JAILED_UI: dict[str, str] = { "title": "🔒 Praegu vanglas", "empty": "KĂ”ik on vabad! Vanglas pole kedagi.", diff --git a/tests/test_consumables.py b/tests/test_consumables.py new file mode 100644 index 0000000..17047d2 --- /dev/null +++ b/tests/test_consumables.py @@ -0,0 +1,102 @@ +"""Tests for the consumables shop (repeatable, expiring coin sink).""" + +from datetime import datetime, timedelta, timezone + +from core import economy + +from conftest import run + +UID = 4242 + + +def _fixed_now(monkeypatch, dt: datetime): + # _clock is the time seam shared by every economy submodule + monkeypatch.setattr(economy.store, "_clock", lambda: dt) + return dt + + +def _fund(fake_pb, amount: int) -> None: + run(economy.get_user(UID)) + fake_pb.record_for(UID)["balance"] = amount + + +class TestBuy: + def test_buy_earn_buff_deducts_and_activates(self, fake_pb): + _fund(fake_pb, 1000) + res = run(economy.do_buy_consumable(UID, "energy_xl")) + assert res["ok"] and not res["instant"] + assert res["balance"] == 500 # 1000 - 500 cost + user = run(economy.get_user(UID)) + assert economy.earn_mult(user) == 2.0 + assert economy.exp_buff_mult(user) == 1.0 + + def test_insufficient_funds_rejected(self, fake_pb): + _fund(fake_pb, 100) + res = run(economy.do_buy_consumable(UID, "energy_xl")) + assert not res["ok"] and res["reason"] == "insufficient" + assert res["need"] == 400 + assert run(economy.get_user(UID))["balance"] == 100 # unchanged + + def test_banned_rejected(self, fake_pb): + _fund(fake_pb, 1000) + fake_pb.record_for(UID)["eco_banned"] = True + res = run(economy.do_buy_consumable(UID, "energy_xl")) + assert not res["ok"] and res["reason"] == "banned" + + def test_unknown_consumable(self, fake_pb): + _fund(fake_pb, 1000) + res = run(economy.do_buy_consumable(UID, "nope")) + assert not res["ok"] and res["reason"] == "not_found" + + +class TestBuffLifecycle: + def test_buff_expires(self, fake_pb, monkeypatch): + t0 = _fixed_now(monkeypatch, datetime(2026, 8, 19, 12, tzinfo=timezone.utc)) + _fund(fake_pb, 1000) + run(economy.do_buy_consumable(UID, "energy_xl")) + assert economy.earn_mult(run(economy.get_user(UID))) == 2.0 + # 61 minutes later the 60-minute buff is gone + _fixed_now(monkeypatch, t0 + timedelta(minutes=61)) + assert economy.earn_mult(run(economy.get_user(UID))) == 1.0 + + def test_rebuy_extends_duration(self, fake_pb, monkeypatch): + t0 = _fixed_now(monkeypatch, datetime(2026, 8, 19, 12, tzinfo=timezone.utc)) + _fund(fake_pb, 2000) + run(economy.do_buy_consumable(UID, "energy_xl")) # expiry = t0 + 60m + _fixed_now(monkeypatch, t0 + timedelta(minutes=30)) + res = run(economy.do_buy_consumable(UID, "energy_xl")) # extends, not resets + assert res["extended"] is True + # remaining should be ~90m (30m left + 60m added), not 60m + assert res["remaining"] > timedelta(minutes=85) + + +class TestEffects: + def test_earn_buff_doubles_work(self, fake_pb, monkeypatch): + import random + _fixed_now(monkeypatch, datetime(2026, 8, 19, 12, tzinfo=timezone.utc)) + _fund(fake_pb, 1000) + monkeypatch.setattr(random, "randint", lambda a, b: 50) + monkeypatch.setattr(random, "choice", lambda seq: seq[0]) + monkeypatch.setattr(random, "random", lambda: 0.99) # no energiajook luck + base = run(economy.do_work(UID))["earned"] + fake_pb.record_for(UID)["last_work"] = None # clear cooldown + run(economy.do_buy_consumable(UID, "energy_xl")) + boosted = run(economy.do_work(UID))["earned"] + assert boosted == base * 2 + + def test_exp_buff_doubles_award(self, fake_pb): + _fund(fake_pb, 1000) + run(economy.do_buy_consumable(UID, "xp_potion")) + res = run(economy.award_exp(UID, 10)) + assert res["gained"] == 20 # 10 * 2x exp buff + + def test_kohv_clears_cooldowns(self, fake_pb, monkeypatch): + t0 = _fixed_now(monkeypatch, datetime(2026, 8, 19, 12, tzinfo=timezone.utc)) + _fund(fake_pb, 1000) + rec = fake_pb.record_for(UID) + rec["last_work"] = t0.isoformat() # on cooldown + res = run(economy.do_buy_consumable(UID, "kohv")) + assert res["ok"] and res["instant"] + user = run(economy.get_user(UID)) + assert user["last_work"] is None # cooldown wiped -> /work is ready + assert economy.store._cooldown_remaining(user, "work") is None From 037628d24fadd8337d0122f70d687b6adc73b710 Mon Sep 17 00:00:00 2001 From: Rene Arumetsa Date: Fri, 4 Sep 2026 02:09:25 +0300 Subject: [PATCH 09/21] feat(economy): add vanity shop and harden economy money-safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vanity shop (/vanity): cosmetic badges/titles as a pure whale coin sink - purchases burn coins (not credited to the house) and equip a badge shown on /profile. New vanity_owned/vanity_active fields, schema sync, and tests. Money-safety and robustness fixes from a codebase review: - _parse_amount now rejects negative amounts. Every bet/give/request flows through it, so a negative value can no longer mint coins on a loss/transfer path that trusts the caller's sign (all call sites already guarded <= 0; this closes the source). - do_blackjack_payout no longer raises on a DB failure. The stake was already deducted in do_blackjack_bet, so it now logs critical with the owed amount (for admin reconciliation) and returns db_error; all payout call sites render a clear "payout failed" notice instead of crashing the interaction. - Instant "kohv" consumable now cancels the pending reminder DMs for the cooldowns it wipes (via new INSTANT_RESET_COMMANDS), so no stale/duplicate reminders fire. - Renamed the misleadingly-named _refund_user_safe -> _debit_house_safe (it debits the house) and dropped its ignored first arg. - Added __all__ to vanity.py and consumables.py so `import *` no longer leaks incidental imports into the economy namespace. - Documented KĂ”rvaklapid's +25 coin daily bonus in README and DEV_NOTES. Tests: blackjack payout DB-failure safety and INSTANT_RESET_COMMANDS lockstep. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT --- README.md | 4 +- bot.py | 11 +++- commands/economy_games_commands.py | 31 +++++---- commands/economy_profile_commands.py | 11 +++- commands/economy_support_commands.py | 73 +++++++++++++++++++++ core/economy/__init__.py | 3 +- core/economy/consumables.py | 16 +++++ core/economy/gambling.py | 25 +++++-- core/economy/heist.py | 4 +- core/economy/house.py | 7 +- core/economy/store.py | 4 ++ core/economy/vanity.py | 98 ++++++++++++++++++++++++++++ docs/DEV_NOTES.md | 2 +- scripts/sync_pb_schema.py | 4 +- strings/__init__.py | 2 + strings/commands.py | 2 + strings/common.py | 1 + strings/economy.py | 17 +++++ tests/test_consumables.py | 7 ++ tests/test_money_safety_fixes.py | 24 +++++++ tests/test_vanity.py | 78 ++++++++++++++++++++++ 21 files changed, 393 insertions(+), 31 deletions(-) create mode 100644 core/economy/vanity.py create mode 100644 tests/test_vanity.py diff --git a/README.md b/README.md index f400608..9c07cbe 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,7 @@ The house is listed at **#0** on the leaderboard. Players can attempt to rob it | Command | Cooldown | Base payout | Notes | |---|---|---|---| -| `/daily` | 20h | 150 ⏥ | Streak multiplier applied (see below). KĂ”rvaklapid reduces cooldown to 18h. LAN pilet doubles the reward. Bot Farm adds 5% interest on your balance (capped at 500 ⏥/day). Prestige daily_plus adds +20% base per upgrade level. | +| `/daily` | 20h | 150 ⏥ | Streak multiplier applied (see below). KĂ”rvaklapid reduces cooldown to 18h and adds +25 ⏥. LAN pilet doubles the reward. Bot Farm adds 5% interest on your balance (capped at 500 ⏥/day). Prestige daily_plus adds +20% base per upgrade level. | | `/work` | 1h | 15–75 ⏥ | Random job flavour text. MĂ€ngurihiir +50%, Reguleeritav laud +25% (stacks). Red Bull: 30% chance of ×3. Ultralai monitor reduces cooldown to 40min. Prestige work_plus adds +20% per upgrade level. | | `/beg` | 5min | 10–40 ⏥ | Hiirematt reduces cooldown to 3min. Mehaaniline klaviatuur multiplies earnings ×2. | | `/crime` | 2h | 200–500 ⏥ | 60% success rate (75% with Cat6 kaabel). +30% earnings with Mikrofon on win. Fail = fine + 30min jail. Gaming tool skips jail on fail. | @@ -424,7 +424,7 @@ All items are **permanent** once purchased **except Anticheat**, which expires a | Hiirematt | 600 ⏥ | `/beg` cooldown 5min → 3min | | Red Bull | 800 ⏥ | `/work` has 30% chance to earn ×3 | | Anticheat | 1 000 ⏥ | Rob attempts against you fail and fine the robber. **2 uses**, then repurchase. | -| KĂ”rvaklapid | 1 200 ⏥ | `/daily` cooldown 20h → 18h | +| KĂ”rvaklapid | 1 200 ⏥ | `/daily` cooldown 20h → 18h and +25 ⏥ bonus | | LAN pilet | 1 200 ⏥ | `/daily` reward ×2 | | Bot Farm | 1 500 ⏥ | `/daily` adds 5% interest on balance (capped at 500 ⏥/day) | diff --git a/bot.py b/bot.py index b0f8a7e..932eee8 100644 --- a/bot.py +++ b/bot.py @@ -688,8 +688,10 @@ register_prestige_commands( def _parse_amount(value: str, balance: int) -> tuple[int | None, str | None]: """Parse an amount string; 'all' resolves to the user's full balance. - Accepts plain integers and valid thousand-separated numbers (1,000 / 1.000 / 1 000). - Rejects decimals and ambiguous inputs like 1,1 or 1.5. + Accepts plain non-negative integers and valid thousand-separated numbers + (1,000 / 1.000 / 1 000). Rejects decimals, ambiguous inputs like 1,1 or 1.5, + and negative amounts (a negative bet/give would mint coins on loss/transfer + paths that trust the caller's sign). Returns (amount, None) on success or (None, error_msg) on failure.""" v = value.strip() if v.lower() == "all": @@ -698,9 +700,12 @@ def _parse_amount(value: str, balance: int) -> tuple[int | None, str | None]: if re.fullmatch(r'\d{1,3}([,. ]\d{3})*', v): v = re.sub(r'[,. ]', '', v) try: - return int(v), None + amount = int(v) except ValueError: return None, S.ERR["invalid_amount"] + if amount < 0: + return None, S.ERR["invalid_amount"] + return amount, None # --------------------------------------------------------------------------- diff --git a/commands/economy_games_commands.py b/commands/economy_games_commands.py index e798420..3f0dbfb 100644 --- a/commands/economy_games_commands.py +++ b/commands/economy_games_commands.py @@ -926,13 +926,17 @@ def register_economy_games_commands( embed = self._cur_embed(game_over=True, hand_results=hand_results) embed.title = S.TITLE[title_key] embed.color = color + if res.get("ok"): + result_line = result_str + S.BJ_UI["balance_line"].format(balance=coin(res["balance"])) + else: + result_line = result_str + "\n" + S.ERR["payout_failed"] embed.add_field( name=S.BJ["result_field"], - value=result_str + S.BJ_UI["balance_line"].format(balance=coin(res["balance"])), + value=result_line, inline=False, ) await self.message.edit(embed=embed, view=self) - if total_payout > total_invested: + if res.get("ok") and total_payout > total_invested: asyncio.create_task(award_exp(interaction, economy.gamble_exp(total_invested))) async def _do_dealer_reveal(self, interaction: discord.Interaction) -> None: @@ -1150,32 +1154,37 @@ def register_economy_games_commands( await asyncio.sleep(_BJ_DEAL_DELAY) if _bj_is_blackjack(dealer_hand): push_res = await economy.do_blackjack_payout(interaction.user.id, bet, bet) + push_line = ( + S.BJ["push_result"] + S.BJ_UI["balance_line"].format(balance=coin(push_res["balance"])) + if push_res.get("ok") + else S.BJ["push_result"] + "\n" + S.ERR["payout_failed"] + ) embed = _bj_embed( player_hand, dealer_hand, S.TITLE["blackjack_push"], 0x99AAB5, hide_dealer=False, - result_field=( - S.BJ["result_field"], - S.BJ["push_result"] + S.BJ_UI["balance_line"].format(balance=coin(push_res["balance"])), - ), + result_field=(S.BJ["result_field"], push_line), ) else: payout = bet + int(bet * 1.5) bj_res = await economy.do_blackjack_payout(interaction.user.id, payout, bet) + bj_line = ( + f"+{coin(payout)}" + S.BJ_UI["balance_line"].format(balance=coin(bj_res["balance"])) + if bj_res.get("ok") + else f"+{coin(payout)}" + "\n" + S.ERR["payout_failed"] + ) embed = _bj_embed( player_hand, dealer_hand, S.TITLE["blackjack_bj"], 0xF4C430, hide_dealer=False, - result_field=( - S.BJ["result_field"], - f"+{coin(payout)}" + S.BJ_UI["balance_line"].format(balance=coin(bj_res["balance"])), - ), + result_field=(S.BJ["result_field"], bj_line), ) - asyncio.create_task(award_exp(interaction, economy.gamble_exp(bet))) + if bj_res.get("ok"): + asyncio.create_task(award_exp(interaction, economy.gamble_exp(bet))) active_games.discard(interaction.user.id) await msg.edit(embed=embed) return diff --git a/commands/economy_profile_commands.py b/commands/economy_profile_commands.py index 976f561..c47ec9d 100644 --- a/commands/economy_profile_commands.py +++ b/commands/economy_profile_commands.py @@ -29,10 +29,15 @@ def register_economy_profile_commands( pct = progress / needed if needed > 0 else 1.0 filled = int(pct * 12) bar = "█" * filled + "░" * (12 - filled) - embed = discord.Embed( - title=S.PROFILE_UI["main_title"].format(name=target.display_name), - color=0xF4C430, + badge = economy.vanity_badge(data) + title = ( + f"{badge[0]} {target.display_name}" + if badge + else S.PROFILE_UI["main_title"].format(name=target.display_name) ) + embed = discord.Embed(title=title, color=0xF4C430) + if badge: + embed.set_author(name=badge[1]) embed.add_field(name=S.PROFILE_UI["f_balance"], value=coin(data.get("balance", 0)), inline=True) embed.add_field( name=S.PROFILE_UI["f_level"], diff --git a/commands/economy_support_commands.py b/commands/economy_support_commands.py index f003096..76778bd 100644 --- a/commands/economy_support_commands.py +++ b/commands/economy_support_commands.py @@ -224,6 +224,10 @@ def register_economy_support_commands( cons = res["consumable"] if res["instant"]: + # Kohv wiped these cooldowns, so any reminder DM already scheduled for + # them is now stale - cancel it (the next command run reschedules). + for cmd in economy.INSTANT_RESET_COMMANDS: + cancel_reminder_task(interaction.user.id, cmd) desc = S.CONSUMABLES_UI["bought_instant"].format(balance=coin(res["balance"])) else: key = "bought_extended" if res["extended"] else "bought_buff" @@ -238,6 +242,75 @@ def register_economy_support_commands( ) await interaction.response.send_message(embed=embed) + # -- /vanity ------------------------------------------------------------ + def _vanity_embed(user_data: dict) -> discord.Embed: + owned = set(user_data.get("vanity_owned") or []) + active = user_data.get("vanity_active") + embed = discord.Embed( + title=S.VANITY_UI["title"], + description=S.VANITY_UI["desc"].format(bal=coin(user_data.get("balance", 0))), + color=0xF4C430, + ) + for vid, v in economy.VANITY.items(): + if vid == active: + status = S.VANITY_UI["line_active"] + elif vid in owned: + status = S.VANITY_UI["line_owned"] + else: + status = f"{v['cost']} {economy.COIN}" + embed.add_field( + name=f"{v['emoji']} {v['name']} · {status}", + value=S.VANITY_UI["entry_title"].format(title=v["title"]), + inline=False, + ) + embed.set_footer(text=S.VANITY_UI["footer"]) + return embed + + @tree.command(name="vanity", description=S.CMD["vanity"]) + @app_commands.describe(ese=S.OPT["vanity_ese"]) + @app_commands.choices( + ese=[ + app_commands.Choice(name=f"{v['emoji']} {v['title']} ({v['cost']} TipiCOINi)", value=vid) + for vid, v in economy.VANITY.items() + ] + + [app_commands.Choice(name=S.VANITY_UI["none_choice"], value=economy.vanity.NONE_ID)] + ) + async def cmd_vanity( + interaction: discord.Interaction, + ese: app_commands.Choice[str] | None = None, + ): + if ese is None: + data = await economy.get_user(interaction.user.id) + await interaction.response.send_message( + embed=_vanity_embed(data), ephemeral=True + ) + return + + res = await economy.do_vanity_select(interaction.user.id, ese.value) + if not res["ok"]: + if res["reason"] == "banned": + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + elif res["reason"] == "insufficient": + await interaction.response.send_message( + S.ERR["broke_need"].format(need=coin(res["need"])), ephemeral=True + ) + else: + await interaction.response.send_message(S.ERR["item_not_found"], ephemeral=True) + return + + action = res["action"] + if action == "unequipped": + await interaction.response.send_message(S.VANITY_UI["unequipped"], ephemeral=True) + return + v = res["vanity"] + if action == "bought": + msg = S.VANITY_UI["bought"].format( + emoji=v["emoji"], title=v["title"], balance=coin(res["balance"]) + ) + else: + msg = S.VANITY_UI["equipped"].format(emoji=v["emoji"], title=v["title"]) + await interaction.response.send_message(msg) + 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 52bb322..be93144 100644 --- a/core/economy/__init__.py +++ b/core/economy/__init__.py @@ -19,6 +19,7 @@ from .house import _credit_house, _house_record_id from .levels import * from .shop import * from .consumables import * +from .vanity import * from .fishing import * from .quests import * from .quests import _ensure_quests, _pick_quests, _quest_view @@ -32,5 +33,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, + leaderboards, levels, prestige, quests, shop, store, vanity, ) diff --git a/core/economy/consumables.py b/core/economy/consumables.py index 09f5aae..74e7150 100644 --- a/core/economy/consumables.py +++ b/core/economy/consumables.py @@ -23,6 +23,17 @@ from ..pb_client import DatabaseError from ..emoji import EMOJI as E from .store import _commit, _locked_by, _now, _parse_dt, _txn, get_user +__all__ = [ + "Consumable", + "CONSUMABLES", + "INSTANT_RESET_COMMANDS", + "active_buffs", + "buff_remaining", + "earn_mult", + "exp_buff_mult", + "do_buy_consumable", +] + class Consumable(TypedDict): name: str @@ -66,6 +77,11 @@ _BUFF_MULT: dict[str, float] = {"earn": 2.0, "exp": 2.0} # Cooldown timestamps an instant "kohv" clears. _COOLDOWN_FIELDS = ("last_work", "last_beg", "last_crime", "last_rob", "last_fish") +# The slash-command names whose cooldowns an instant "kohv" clears. The Discord +# layer uses these to cancel any pending reminder DMs, since the cooldowns they +# were scheduled for no longer exist. +INSTANT_RESET_COMMANDS = tuple(f.removeprefix("last_") for f in _COOLDOWN_FIELDS) + # --------------------------------------------------------------------------- # Buff inspection helpers (pure - safe to call while holding a user lock) diff --git a/core/economy/gambling.py b/core/economy/gambling.py index b0dc6f3..c89a72d 100644 --- a/core/economy/gambling.py +++ b/core/economy/gambling.py @@ -6,7 +6,7 @@ import random from ..pb_client import DatabaseError from ..emoji import EMOJI as E -from .store import _commit, _is_jailed, _locked_by, _txn, get_user +from .store import _commit, _is_jailed, _locked_by, _log, _txn, get_user from .house import _credit_house @@ -254,8 +254,15 @@ async def do_blackjack_bet(user_id: int, bet: int) -> dict: @_locked_by(0) async def do_blackjack_payout(user_id: int, payout: int, total_invested: int = 0) -> dict: - """Credit the net payout. House receives the difference when payout < total_invested.""" - user = await get_user(user_id) + """Credit the net payout. House receives the difference when payout < total_invested. + + The stake was already deducted in do_blackjack_bet, so a DB failure here must + not raise through the interaction handler and swallow the player's winnings: + report db_error like every other mutation so the caller can surface it.""" + try: + user = await get_user(user_id) + except DatabaseError: + return {"ok": False, "reason": "db_error"} user["balance"] += payout user["balance"] = max(0, user["balance"]) user["total_wagered"] = user.get("total_wagered", 0) + total_invested @@ -267,7 +274,17 @@ async def do_blackjack_payout(user_id: int, payout: int, total_invested: int = 0 elif net < 0: user["lifetime_lost"] = user.get("lifetime_lost", 0) + abs(net) user["biggest_loss"] = max(user.get("biggest_loss", 0), abs(net)) - await _commit(user_id, user) + try: + await _commit(user_id, user) + except DatabaseError: + # The stake is already gone (deducted in do_blackjack_bet) and this credit + # did not persist. Nothing to roll back locally - log the owed amount so an + # admin can reconcile with /admincoins. + _log.critical( + "blackjack payout commit failed for %s: owed payout=%s (invested=%s)", + user_id, payout, total_invested, + ) + return {"ok": False, "reason": "db_error"} house_gain = total_invested - payout if house_gain > 0: await _credit_house(house_gain) diff --git a/core/economy/heist.py b/core/economy/heist.py index ff0f75e..2651477 100644 --- a/core/economy/heist.py +++ b/core/economy/heist.py @@ -8,7 +8,7 @@ from .. import pb_client from ..pb_client import DatabaseError from . import house from .store import HEIST_JAIL, _commit, _is_jailed, _now, _txn, _user_lock, get_user -from .house import _credit_house, _refund_house_safe, _refund_user_safe +from .house import _credit_house, _refund_house_safe, _debit_house_safe # --------------------------------------------------------------------------- @@ -98,6 +98,6 @@ async def do_heist_resolve(user_ids: list[int], success: bool) -> dict: if success and payout_each > 0: await _refund_house_safe(payout_each, "heist_win_compensate", uid) elif not success and fine_credited: - await _refund_user_safe(house.HOUSE_ID, fine if 'fine' in locals() else 0, "heist_fail_compensate", uid) + await _debit_house_safe(fine if 'fine' in locals() else 0, "heist_fail_compensate", uid) return {"ok": True, "payout_each": payout_each, "success": success, "failed_users": failed_users} diff --git a/core/economy/house.py b/core/economy/house.py index 4ee14ba..9a7bd46 100644 --- a/core/economy/house.py +++ b/core/economy/house.py @@ -75,9 +75,10 @@ async def _refund_house_safe(amount: int, context: str, related_uid: int) -> Non ) -async def _refund_user_safe(_unused_house_id, amount: int, context: str, uid: int) -> None: - """Best-effort atomic debit of `amount` from the house (compensates a failed - user fine). Logs critical if it fails.""" +async def _debit_house_safe(amount: int, context: str, uid: int) -> None: + """Best-effort atomic debit of `amount` from the house. Compensates a fine + that was credited to the house but whose matching user debit failed to persist + (the coins must be pulled back out of the house). Logs critical if it fails.""" if HOUSE_ID is None or amount <= 0: return try: diff --git a/core/economy/store.py b/core/economy/store.py index 5f4d43b..7742334 100644 --- a/core/economy/store.py +++ b/core/economy/store.py @@ -107,6 +107,8 @@ class UserData(TypedDict, total=False): items: list[str] item_uses: dict # {item_id: remaining_uses} for consumables active_buffs: dict # {buff_kind: expiry_iso} for timed consumable boosts + vanity_owned: list[str] # cosmetic badge ids the user has purchased + vanity_active: str | None # currently-equipped vanity badge id (shown on /profile) jailed_until: str | None # ISO datetime or None jailbreak_used: bool reminders: list[str] # command names user wants DM reminders for @@ -161,6 +163,8 @@ def _default_user() -> UserData: "items": [], "item_uses": {}, "active_buffs": {}, + "vanity_owned": [], + "vanity_active": None, "jailed_until": None, "jailbreak_used": False, "reminders": ["daily", "work", "beg", "crime", "rob"], diff --git a/core/economy/vanity.py b/core/economy/vanity.py new file mode 100644 index 0000000..41c79d6 --- /dev/null +++ b/core/economy/vanity.py @@ -0,0 +1,98 @@ +"""Vanity shop: cosmetic badges/titles - a pure status sink for the wealthy. + +No gameplay effect whatsoever. Buying burns the coins (they are NOT credited +to the house, so they leave circulation for good) and unlocks a badge the +player can equip; the equipped badge shows on /profile. Prices scale steeply +on purpose - this is where the richest players dump coins for bragging rights, +draining the top end of the economy where inflation hurts most. + +A single entry point, do_vanity_select, handles buy / equip / unequip so the +whole feature fits one slash command. +""" + +from __future__ import annotations + +from typing import TypedDict + +from ..pb_client import DatabaseError +from .store import _commit, _locked_by, _txn, get_user + +__all__ = [ + "Vanity", + "VANITY", + "NONE_ID", + "vanity_badge", + "do_vanity_select", +] + + +class Vanity(TypedDict): + name: str # short shop name + emoji: str # the badge shown next to the player + title: str # the flavour title shown on /profile + cost: int + + +# Ordered cheapest -> priciest: a LAN/gaming status ladder from casual couch +# gamer to LAN legend. The top rungs are the deliberate whale sink. +VANITY: dict[str, Vanity] = { + "couch": {"name": "Sohvapadi", "emoji": "🎼", "title": "SohvasĂ”dur", "cost": 5_000}, + "cables": {"name": "VĂ”rgukaabel", "emoji": "🔌", "title": "Kaablihaldur", "cost": 8_000}, + "discord": {"name": "Peakomplekt", "emoji": "🎧", "title": "Discordi Admin", "cost": 12_000}, + "aimbot": {"name": "Kahtlane Hiir", "emoji": "đŸ–±ïž", "title": "Aimbot Kahtlusalune", "cost": 18_000}, + "sniper": {"name": "360Hz Monitor", "emoji": "🎯", "title": "SnaipripĂŒss", "cost": 25_000}, + "champ": {"name": "Meistrikarikas", "emoji": "🏆", "title": "TurniirivĂ”itja", "cost": 40_000}, + "legend": {"name": "MĂ€ngurijaam", "emoji": "đŸ–„ïž", "title": "LAN Legend", "cost": 75_000}, +} + +# Sentinel choice value that unequips the active badge. +NONE_ID = "none" + + +def vanity_badge(user) -> tuple[str, str] | None: + """Return (emoji, title) for the user's equipped badge, or None.""" + vid = user.get("vanity_active") + vanity = VANITY.get(vid) if vid else None + return (vanity["emoji"], vanity["title"]) if vanity else None + + +@_locked_by(0) +async def do_vanity_select(user_id: int, vanity_id: str) -> dict: + """Buy (if unowned), equip (if owned), or unequip (NONE_ID) a badge.""" + 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 vanity_id == NONE_ID: + user["vanity_active"] = None + await _commit(user_id, user) + _txn("VANITY_UNEQUIP", user=user_id) + return {"ok": True, "action": "unequipped"} + + if vanity_id not in VANITY: + return {"ok": False, "reason": "not_found"} + + owned = list(user.get("vanity_owned") or []) + vanity = VANITY[vanity_id] + + # Already owned -> just equip it (free). + if vanity_id in owned: + user["vanity_active"] = vanity_id + await _commit(user_id, user) + _txn("VANITY_EQUIP", user=user_id, item=vanity_id) + return {"ok": True, "action": "equipped", "vanity": vanity} + + # Not owned -> purchase (burns the coins) and auto-equip. + if user["balance"] < vanity["cost"]: + return {"ok": False, "reason": "insufficient", "need": vanity["cost"] - user["balance"]} + + user["balance"] -= vanity["cost"] # burned: not credited to the house + owned.append(vanity_id) + user["vanity_owned"] = owned + user["vanity_active"] = vanity_id + await _commit(user_id, user) + _txn("VANITY_BUY", user=user_id, item=vanity_id, cost=f"-{vanity['cost']}", bal=user["balance"]) + return {"ok": True, "action": "bought", "vanity": vanity, "balance": user["balance"]} diff --git a/docs/DEV_NOTES.md b/docs/DEV_NOTES.md index 8fa1796..c00a2cb 100644 --- a/docs/DEV_NOTES.md +++ b/docs/DEV_NOTES.md @@ -116,7 +116,7 @@ All economy state is stored in **PocketBase** (`economy_users` collection). `cor | Command | Cooldown | Base Earn | Notes | |---|---|---|---| -| `/daily` | 20h (18h w/ korvaklapid) | 150⏥ | ×streak multiplier, ×2 w/ lan_pass, +5% interest w/ gaming_laptop | +| `/daily` | 20h (18h w/ korvaklapid) | 150⏥ | ×streak multiplier, ×2 w/ lan_pass, +25⏥ w/ korvaklapid, +5% interest w/ gaming_laptop | | `/work` | 1h (40min w/ monitor) | 15-75⏥ | ×1.5 w/ gaming_hiir, ×1.25 w/ reguleeritav_laud, ×3 30% chance w/ energiajook | | `/beg` | 5min (3min w/ hiirematt) | 10-40⏥ | ×2 w/ klaviatuur | | `/crime` | 2h | 200-500⏥ win | 60% success (75% w/ cat6), +30% w/ mikrofon; fail = fine + jail | diff --git a/scripts/sync_pb_schema.py b/scripts/sync_pb_schema.py index 39e7d29..6e31cdf 100644 --- a/scripts/sync_pb_schema.py +++ b/scripts/sync_pb_schema.py @@ -34,10 +34,12 @@ from core import economy # noqa: E402 PB_URL = config.PB_URL COLLECTIONS = sorted({config.PB_ECONOMY_COLLECTION_DEV, config.PB_ECONOMY_COLLECTION_ECONOMY}) -# _default_user() keys whose default is None, all ISO date/datetime strings +# _default_user() keys with a None/str default that PocketBase stores as text +# (ISO date/datetime strings, plus the vanity badge id). _TEXT_FIELDS = { "last_daily", "last_work", "last_beg", "last_crime", "last_rob", "last_heist", "last_fish", "last_streak_date", "jailed_until", + "vanity_active", } diff --git a/strings/__init__.py b/strings/__init__.py index 4c65c3a..8bcac59 100644 --- a/strings/__init__.py +++ b/strings/__init__.py @@ -58,6 +58,7 @@ from .economy import ( ITEM_DESCRIPTIONS, CONSUMABLES_UI, CONSUMABLE_DESCRIPTIONS, + VANITY_UI, JAILED_UI, SHOP_BTN, DAILY_UI, @@ -150,6 +151,7 @@ __all__ = [ 'ITEM_DESCRIPTIONS', 'CONSUMABLES_UI', 'CONSUMABLE_DESCRIPTIONS', + 'VANITY_UI', 'JAILED_UI', 'SHOP_BTN', 'DAILY_UI', diff --git a/strings/commands.py b/strings/commands.py index e3a9a28..bbd176e 100644 --- a/strings/commands.py +++ b/strings/commands.py @@ -74,6 +74,7 @@ CMD: dict[str, str] = { "patchnotes": "Vaata TipiBOTi viimaseid muudatusi ja uuendusi", "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", } # --------------------------------------------------------------------------- @@ -92,6 +93,7 @@ OPT: dict[str, str] = { "give_summa": "Kui palju annad? ('all' = kogu saldo)", "buy_ese": "Eseme nimi (vaata /shop)", "consumable_ese": "Turgutus, mida osta (tĂŒhjaks jĂ€ttes nĂ€eb menĂŒĂŒd)", + "vanity_ese": "Tiitel, mida osta vĂ”i kanda (tĂŒhjaks jĂ€ttes nĂ€eb poodi)", "rps_panus": "Valikuline TipiCOINide panus ('all' = kogu saldo)", "rps_vastane": "VĂ€ljakutse teisele mĂ€ngijale (PvP)", "slots_panus": "Panus TipiCOINides ('all' = kogu saldo)", diff --git a/strings/common.py b/strings/common.py index 965d007..99b3b5f 100644 --- a/strings/common.py +++ b/strings/common.py @@ -175,6 +175,7 @@ ERR: dict[str, str] = { "guild_only": "Seda kĂ€sku saab kasutada ainult serveris.", "sheet_error": "❌ Tabeli laadimine ebaĂ”nnestus: ```{error}```", "gamble_cooldown": "🎰 Oled just mĂ€nginud! Saad uuesti mĂ€ngida {ts}.", + "payout_failed": "⚠ Tehniline viga vĂ”idu vĂ€ljamaksmisel - see on logitud ja admin taastab su TipiCOINid. Vabandame!", } # --------------------------------------------------------------------------- diff --git a/strings/economy.py b/strings/economy.py index f8e4557..14e18e8 100644 --- a/strings/economy.py +++ b/strings/economy.py @@ -17,6 +17,7 @@ __all__ = [ 'ITEM_DESCRIPTIONS', 'CONSUMABLES_UI', 'CONSUMABLE_DESCRIPTIONS', + 'VANITY_UI', 'JAILED_UI', 'SHOP_BTN', 'DAILY_UI', @@ -240,6 +241,22 @@ CONSUMABLES_UI: dict[str, str] = { "bought_instant": "☕ KĂ”ik ooteajad nullitud!\nUus saldo: {balance}", } +# --------------------------------------------------------------------------- +# Vanity shop (cosmetic badges/titles - a pure status sink, no gameplay effect) +# --------------------------------------------------------------------------- +VANITY_UI: dict[str, str] = { + "title": "👑 Staatusepood", + "desc": "Puhtalt uhkuse pĂ€rast - mĂ€rgid ja tiitlid ilma igasuguse mĂ€nguefektita. Kantud mĂ€rk paistab su `/profile`-l.\nSaldo: {bal}", + "line_owned": "✅ Olemas", + "line_active": "⭐ Kantud", + "entry_title": "„{title}“", + "footer": "Osta vĂ”i kanna: /vanity · „Eemalda mĂ€rk“ vĂ”tab tiitli maha", + "none_choice": "❌ Eemalda mĂ€rk", + "bought": "{emoji} Ostsid tiitli **{title}** ja panid selle kohe kandma!\nUus saldo: {balance}", + "equipped": "{emoji} Kannad nĂŒĂŒd tiitlit **{title}**.", + "unequipped": "MĂ€rk eemaldatud - su profiil on jĂ€lle tavaline.", +} + JAILED_UI: dict[str, str] = { "title": "🔒 Praegu vanglas", "empty": "KĂ”ik on vabad! Vanglas pole kedagi.", diff --git a/tests/test_consumables.py b/tests/test_consumables.py index 17047d2..c078f6b 100644 --- a/tests/test_consumables.py +++ b/tests/test_consumables.py @@ -100,3 +100,10 @@ class TestEffects: user = run(economy.get_user(UID)) assert user["last_work"] is None # cooldown wiped -> /work is ready assert economy.store._cooldown_remaining(user, "work") is None + + def test_instant_reset_commands_match_cleared_cooldowns(self): + # The Discord layer cancels reminder DMs for exactly these commands after a + # kohv, so the list must stay in lockstep with the cooldown fields it wipes. + expected = tuple(f.removeprefix("last_") for f in economy.consumables._COOLDOWN_FIELDS) + assert economy.INSTANT_RESET_COMMANDS == expected + assert economy.INSTANT_RESET_COMMANDS == ("work", "beg", "crime", "rob", "fish") diff --git a/tests/test_money_safety_fixes.py b/tests/test_money_safety_fixes.py index 39ccdfd..be28a63 100644 --- a/tests/test_money_safety_fixes.py +++ b/tests/test_money_safety_fixes.py @@ -10,6 +10,7 @@ Covers the two pure-logic fixes: from datetime import datetime, timedelta, timezone from core import economy +from core.pb_client import DatabaseError from conftest import run @@ -89,3 +90,26 @@ class TestBailIdempotency: self._jail(fake_pb, now, balance=1000) res = run(economy.do_bail(UID)) assert res["ok"] and res["fine"] >= economy.MIN_BAIL + + +class TestBlackjackPayoutSafety: + """do_blackjack_payout must not raise on a DB failure - the stake was already + deducted in do_blackjack_bet, so a raised exception would blow up the + interaction handler and swallow the outcome. It reports db_error instead.""" + + async def _boom(self, *args, **kwargs): + raise DatabaseError("simulated PocketBase outage") + + def test_payout_returns_db_error_when_read_fails(self, fake_pb, monkeypatch): + monkeypatch.setattr(economy.gambling, "get_user", self._boom) + res = run(economy.do_blackjack_payout(UID, payout=200, total_invested=100)) + assert res == {"ok": False, "reason": "db_error"} + + def test_payout_returns_db_error_when_commit_fails(self, fake_pb, monkeypatch): + run(economy.get_user(UID)) + fake_pb.record_for(UID)["balance"] = 500 + monkeypatch.setattr(economy.gambling, "_commit", self._boom) + res = run(economy.do_blackjack_payout(UID, payout=200, total_invested=100)) + assert res == {"ok": False, "reason": "db_error"} + # The failed credit did not persist; balance is untouched (no partial win). + assert fake_pb.record_for(UID)["balance"] == 500 diff --git a/tests/test_vanity.py b/tests/test_vanity.py new file mode 100644 index 0000000..fdd010c --- /dev/null +++ b/tests/test_vanity.py @@ -0,0 +1,78 @@ +"""Tests for the vanity shop (cosmetic status sink, no gameplay effect).""" + +from core import economy + +from conftest import run + +UID = 7777 + + +def _fund(fake_pb, amount: int) -> None: + run(economy.get_user(UID)) + fake_pb.record_for(UID)["balance"] = amount + + +class TestBuy: + def test_buy_burns_coins_owns_and_equips(self, fake_pb): + _fund(fake_pb, 10_000) + cost = economy.VANITY["couch"]["cost"] + res = run(economy.do_vanity_select(UID, "couch")) + assert res["ok"] and res["action"] == "bought" + assert res["balance"] == 10_000 - cost + user = run(economy.get_user(UID)) + assert "couch" in user["vanity_owned"] + assert user["vanity_active"] == "couch" + assert economy.vanity_badge(user) == ("🎼", "SohvasĂ”dur") + + def test_coins_are_destroyed_not_sent_to_house(self, fake_pb, monkeypatch): + _fund(fake_pb, 10_000) + credited = [] + monkeypatch.setattr(economy.house, "_credit_house", lambda amt: credited.append(amt)) + run(economy.do_vanity_select(UID, "couch")) + assert credited == [] # nothing recirculated to the house + + def test_insufficient_funds_rejected(self, fake_pb): + _fund(fake_pb, 100) + res = run(economy.do_vanity_select(UID, "legend")) + assert not res["ok"] and res["reason"] == "insufficient" + assert res["need"] == economy.VANITY["legend"]["cost"] - 100 + assert run(economy.get_user(UID))["balance"] == 100 # unchanged + + def test_banned_rejected(self, fake_pb): + _fund(fake_pb, 10_000) + fake_pb.record_for(UID)["eco_banned"] = True + res = run(economy.do_vanity_select(UID, "couch")) + assert not res["ok"] and res["reason"] == "banned" + + def test_unknown_badge(self, fake_pb): + _fund(fake_pb, 10_000) + res = run(economy.do_vanity_select(UID, "nope")) + assert not res["ok"] and res["reason"] == "not_found" + + +class TestEquip: + def test_equip_owned_is_free(self, fake_pb): + _fund(fake_pb, 20_000) + run(economy.do_vanity_select(UID, "couch")) # buy + equip couch + run(economy.do_vanity_select(UID, "cables")) # buy + equip cables + bal_before = run(economy.get_user(UID))["balance"] + res = run(economy.do_vanity_select(UID, "couch")) # re-equip owned couch + assert res["ok"] and res["action"] == "equipped" + user = run(economy.get_user(UID)) + assert user["vanity_active"] == "couch" + assert user["balance"] == bal_before # no re-charge + + def test_unequip_clears_badge(self, fake_pb): + _fund(fake_pb, 10_000) + run(economy.do_vanity_select(UID, "couch")) + res = run(economy.do_vanity_select(UID, economy.vanity.NONE_ID)) + assert res["ok"] and res["action"] == "unequipped" + user = run(economy.get_user(UID)) + assert user["vanity_active"] is None + assert economy.vanity_badge(user) is None + assert "couch" in user["vanity_owned"] # still owned, just not worn + + +def test_no_badge_by_default(fake_pb): + user = run(economy.get_user(UID)) + assert economy.vanity_badge(user) is None From 7a28617657259227839705537586f73cc62e346d Mon Sep 17 00:00:00 2001 From: Rene Arumetsa Date: Fri, 4 Sep 2026 02:09:33 +0300 Subject: [PATCH 10/21] docs: add CLAUDE.md guidance for Claude Code Architecture overview, dev/test commands, the dual-profile and re-export patterns, the economy locking/PocketBase-schema footguns, and the test harness. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT --- CLAUDE.md | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a4c3cf1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,66 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +Discord bot for the TipiLAN community (`discord.py`). Two responsibilities: **member management** (syncs nicknames/roles from a Google Sheet, birthday announcements) and the **TipiCOIN economy** (a large game economy stored in PocketBase). User-facing text is **Estonian**; only `docs/PATCHNOTES.md` content is English. + +See `README.md` for the full feature/gameplay spec and `docs/DEV_NOTES.md` for the developer reference (add-a-command / add-a-shop-item checklists, constants). + +## Commands + +```bash +# Run tests (pure logic + economy flows against an in-memory PocketBase fake) +python -m pytest tests/ -q + +# Single file / single test +python -m pytest tests/test_economy_pure.py -q +python -m pytest tests/test_economy_flows.py::TestDaily::test_streak -q + +# Run the bot (requires .env; PocketBase must already be running) +BOT_PROFILE=dev python bot.py # or BOT_PROFILE=economy +``` + +Deps: `pip install -r requirements-dev.txt` (dev = runtime + pytest). CI (`.gitea/workflows/deploy.yml`) runs `pytest tests/ -q` on push to `master`, then deploys to the host by restarting `tipibot_dev` (canary) and `tipibot_eco` systemd units. The bot runs on host `tipilan-bots:/root/tipibot`; the local checkout has no `.env`/live DB. + +## Architecture + +Three layers, deliberately decoupled: + +- **`core/`** — domain logic, **no Discord objects**. `core/economy/` is the economy package. +- **`commands/`** — one Discord slash-command group per file. Each exposes `register__commands(tree, bot, ...)`. +- **`bot.py`** — thin wiring layer: Discord client, event handlers (`on_ready`, `on_member_join`), background tasks, shared helpers, and it calls every `register_*_commands(...)` on startup, passing shared helpers down (`coin`, `cd_ts`, `award_exp`, `maybe_remind`, `parse_amount`). + +### Dual-profile design (dev vs economy) + +`BOT_PROFILE` env var (`dev` | `economy`) selects **everything** at import time in `config.py`: which Discord token, guild ID, birthday channel, and **PocketBase collection** (`economy_users_dev` vs `economy_users_prod`) are used. Legacy non-suffixed env vars act as fallbacks. Logs and `data/` are also namespaced per profile (`logs//`, `data//`). `/check`, `/member`, `/birthdays` are **dev-profile only**. When adding config, follow the `_DEV` / `_ECONOMY` + legacy-fallback pattern. + +### The re-export pattern (important — two places) + +Both `strings/` and `core/economy/` are packages split into submodules but **re-exported flat** through their `__init__.py`, so callers use `import strings as S; S.NAME` and `from core import economy; economy.do_daily(...)` unchanged. + +- **Edit the submodule**, not the `__init__`. Strings live in `strings/{common,commands,member,economy,admin,games,fishing}.py`; economy logic in `core/economy/{store,income,gambling,fishing,jail,heist,prestige,shop,levels,quests,leaderboards,house,admin}.py`. +- `tests/test_strings.py` guards that every submodule name is re-exported — a new string that isn't re-exported fails CI. +- Caveat (see `tests/conftest.py:93`): the re-exported `economy.house` mutable state is a snapshot; live house state is owned by `economy.house`. Patch the submodule, not the alias. + +### Economy persistence & concurrency (`core/economy/store.py`) + +- Every mutation is a **read-modify-write**: `get_user(id)` → mutate the `UserData` dict → `await _commit(id, user)`. Money/EXP changes must also call `_txn(...)` for the transaction log. +- **Per-user async locks** serialize commits. Public mutating functions are decorated with `@_locked_by()`. Two hard rules to avoid deadlock/lost-writes: + 1. A `@_locked_by` function must **never call another `@_locked_by` function**. + 2. House balance changes go through `_credit_house` (an atomic PocketBase increment, no lock) so they're safe to call while holding user locks. + Multi-user functions (`do_give`, `do_rob`) list both positions and acquire locks in sorted order. +- **PocketBase silently drops writes to fields not in the collection schema** — this is the #1 footgun. Adding any new persisted field means adding it to `_default_user()` **and** running `python scripts/sync_pb_schema.py` (reconciles both dev + prod collections). `store.missing_schema_fields()` / `/status` surface drift. + +### Command result convention + +`core.economy` functions return a dict with a `"reason"` key describing the outcome (success, `"cooldown"`, `"jailed"`, `"banned"`, insufficient funds, ...). The command handler in `commands/` maps every possible `res["reason"]` to a string/embed. On success it calls `award_exp(interaction, economy.EXP_REWARDS[""])` and `maybe_remind(...)` where relevant. + +## Adding an economy command or shop item + +Follow the exact ordered checklists in `docs/DEV_NOTES.md` ("Adding a New Economy Command", "Adding a New Shop Item"). They enumerate every touchpoint across `core/economy/`, `strings/`, `commands/`, and `bot.py` (cooldowns, EXP rewards, help embed, reminders, item-modified cooldown branches). Missing a step generally means a silently broken feature rather than an error. + +## Tests + +No `pytest-asyncio`. Async tests wrap coroutines with the `run(coro)` helper from `tests/conftest.py` (which is `asyncio.run`). The `fake_pb` fixture monkeypatches `core.pb_client` with an in-memory `FakePocketBase` that mimics real PocketBase behaviour — including atomic `field+`/`field-` increments and **silently dropping writes to fields missing from the schema** (use `fake_pb_without_quest_fields` to simulate pre-migration schema drift). Prefer testing `core/` logic directly; there's no Discord in the test path. From eb7346b85182d6dbeb55fd18866a1c57f03e5637 Mon Sep 17 00:00:00 2001 From: Rene Arumetsa Date: Fri, 4 Sep 2026 02:26:07 +0300 Subject: [PATCH 11/21] fix(economy): handle db_error in commands, dedupe cooldowns, single leaderboard scan Three robustness/perf fixes from the review: 1. db_error UX: economy core returns {"ok": False, "reason": "db_error"} on a PocketBase outage, but no handler expected it - deferred commands hung on "thinking..." and others showed a misleading "you're broke". Added a shared reply_db_error helper (commands/_replies.py) + S.ERR["db_error"], and wired a db_error branch into every handler that can receive it (daily/work/beg/crime/ rob/give/buy/roulette/slots/blackjack/heist/fish/prestige/vanity/consumables/ request-funding). Also fixed a latent KeyError in vs-bot RPS that read res["balance"] without checking res["ok"]. 2. Deduped the item->cooldown mapping that was copied in do_daily/do_work/do_beg, do_fish_start, _maybe_remind and _restore_reminders. Single source of truth: store.ITEM_COOLDOWNS + effective_cooldown(cmd, items). 3. /leaderboard did six full-collection scans (one per tab). Added get_all_leaderboards() which scans once and builds all six views in memory. Tests: effective_cooldown cases, and get_all_leaderboards matches the individual queries + scans the collection exactly once. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT --- bot.py | 24 ++----------- commands/_replies.py | 25 ++++++++++++++ commands/economy_extra_commands.py | 29 +++++++++++----- commands/economy_fish_commands.py | 5 +++ commands/economy_games_commands.py | 26 +++++++++++++- commands/economy_income_commands.py | 17 ++++++++++ commands/economy_prestige_commands.py | 11 ++++++ commands/economy_support_commands.py | 11 ++++++ core/economy/fishing.py | 7 ++-- core/economy/income.py | 13 +++---- core/economy/leaderboards.py | 29 ++++++++++++++++ core/economy/store.py | 20 +++++++++++ strings/common.py | 1 + tests/test_economy_pure.py | 23 +++++++++++++ tests/test_leaderboards.py | 49 +++++++++++++++++++++++++++ 15 files changed, 249 insertions(+), 41 deletions(-) create mode 100644 commands/_replies.py create mode 100644 tests/test_leaderboards.py diff --git a/bot.py b/bot.py index 932eee8..d066e92 100644 --- a/bot.py +++ b/bot.py @@ -772,17 +772,7 @@ async def _restore_reminders() -> None: last_str = user.get(last_key) if not last_str: continue - items = user.get("items", []) - if cmd == "work" and "monitor" in items: - cooldown = datetime.timedelta(minutes=40) - elif cmd == "beg" and "hiirematt" in items: - cooldown = datetime.timedelta(minutes=3) - elif cmd == "daily" and "korvaklapid" in items: - cooldown = datetime.timedelta(hours=18) - elif cmd == "fish" and "ussipurk" in items: - cooldown = datetime.timedelta(seconds=90) - else: - cooldown = economy.COOLDOWNS.get(cmd) + cooldown = economy.effective_cooldown(cmd, user.get("items", [])) if not cooldown: continue last_dt = datetime.datetime.fromisoformat(last_str) @@ -801,17 +791,7 @@ async def _maybe_remind(user_id: int, cmd: str) -> None: user_data = await economy.get_user(user_id) if cmd not in user_data.get("reminders", []): return - items = set(user_data.get("items", [])) - if cmd == "work" and "monitor" in items: - delay = datetime.timedelta(minutes=40) - elif cmd == "beg" and "hiirematt" in items: - delay = datetime.timedelta(minutes=3) - elif cmd == "daily" and "korvaklapid" in items: - delay = datetime.timedelta(hours=18) - elif cmd == "fish" and "ussipurk" in items: - delay = datetime.timedelta(seconds=90) - else: - delay = economy.COOLDOWNS.get(cmd, datetime.timedelta(hours=1)) + delay = economy.effective_cooldown(cmd, user_data.get("items", [])) or datetime.timedelta(hours=1) _schedule_reminder(user_id, cmd, delay) diff --git a/commands/_replies.py b/commands/_replies.py new file mode 100644 index 0000000..e37e542 --- /dev/null +++ b/commands/_replies.py @@ -0,0 +1,25 @@ +"""Shared reply helpers for command handlers.""" + +from __future__ import annotations + +import discord + +import strings as S + + +async def reply_db_error(interaction: discord.Interaction) -> None: + """Tell the user the database is unavailable. + + Economy core functions return {"ok": False, "reason": "db_error"} on a + PocketBase outage. Without this, handlers either fall through silently (a + deferred interaction hangs on "thinking...") or show a misleading "you're + broke" message. Works whether or not the interaction was already deferred. + """ + msg = S.ERR["db_error"] + try: + if interaction.response.is_done(): + await interaction.followup.send(msg, ephemeral=True) + else: + await interaction.response.send_message(msg, ephemeral=True) + except discord.HTTPException: + pass diff --git a/commands/economy_extra_commands.py b/commands/economy_extra_commands.py index 6b8356f..c846ef4 100644 --- a/commands/economy_extra_commands.py +++ b/commands/economy_extra_commands.py @@ -13,6 +13,8 @@ from core import economy from core.emoji import EMOJI as E import strings as S +from ._replies import reply_db_error + def register_economy_extra_commands( tree: app_commands.CommandTree, @@ -120,6 +122,9 @@ def register_economy_extra_commands( return res = await economy.do_heist_check(interaction.user.id) if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + return if res["reason"] == "banned": await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "jailed": @@ -270,6 +275,9 @@ def register_economy_extra_commands( return res = await economy.do_heist_check(interaction.user.id) if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + return if res["reason"] == "banned": await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "jailed": @@ -518,6 +526,9 @@ def register_economy_extra_commands( res = await economy.do_give(interaction.user.id, kasutaja.id, summa_int) if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + return if res["reason"] == "banned": await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "jailed": @@ -751,14 +762,13 @@ def register_economy_extra_commands( @tree.command(name="leaderboard", description=S.CMD["leaderboard"]) async def cmd_leaderboard(interaction: discord.Interaction): await interaction.response.defer() - coins_raw, exp_raw, season_raw, prestige_raw, wagered_raw, fish_raw = await asyncio.gather( - economy.get_leaderboard(top_n=None), - economy.get_leaderboard_exp(top_n=None), - economy.get_leaderboard_season_exp(top_n=None), - economy.get_leaderboard_prestige(top_n=None), - economy.get_leaderboard_wagered(top_n=None), - economy.get_leaderboard_fish(top_n=None), - ) + lbs = await economy.get_all_leaderboards() # single collection scan for all six tabs + coins_raw = lbs["coins"] + exp_raw = lbs["exp"] + season_raw = lbs["season"] + prestige_raw = lbs["prestige"] + wagered_raw = lbs["wagered"] + fish_raw = lbs["fish"] house_entry = None regular = [] @@ -865,6 +875,9 @@ def register_economy_extra_commands( async def cmd_buy(interaction: discord.Interaction, ese: app_commands.Choice[str]): res = await economy.do_buy(interaction.user.id, ese.value) if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + return if res["reason"] == "banned": await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "owned": diff --git a/commands/economy_fish_commands.py b/commands/economy_fish_commands.py index 9c9c81d..dfd6bda 100644 --- a/commands/economy_fish_commands.py +++ b/commands/economy_fish_commands.py @@ -10,6 +10,8 @@ from discord import app_commands from core import economy import strings as S +from ._replies import reply_db_error + def register_economy_fish_commands( tree: app_commands.CommandTree, @@ -218,6 +220,9 @@ def register_economy_fish_commands( res = await economy.do_fish_start(interaction.user.id) if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + return if res["reason"] == "banned": await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "cooldown": diff --git a/commands/economy_games_commands.py b/commands/economy_games_commands.py index 3f0dbfb..7b80251 100644 --- a/commands/economy_games_commands.py +++ b/commands/economy_games_commands.py @@ -12,6 +12,8 @@ from core import economy from core.emoji import EMOJI as E import strings as S +from ._replies import reply_db_error + def register_economy_games_commands( tree: app_commands.CommandTree, @@ -116,6 +118,9 @@ def register_economy_games_commands( res = await economy.do_roulette(interaction.user.id, panus_int, vĂ€rv.value) if not res["ok"]: active_games.discard(interaction.user.id) + if res["reason"] == "db_error": + await reply_db_error(interaction) + return if res["reason"] == "banned": await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "jailed": @@ -202,7 +207,14 @@ def register_economy_games_commands( bet_line = "" if self.bet > 0: res = await economy.do_game_bet(interaction.user.id, self.bet, outcome) - if outcome == "win": + if not res.get("ok"): + if res.get("reason") == "db_error": + await reply_db_error(interaction) + return + # Player was jailed/went broke since the duel started - show the + # result without a bet line rather than crashing on res["balance"]. + bet_line = "" + elif outcome == "win": bet_line = S.RPS_UI["bet_win"].format(amount=coin(self.bet), balance=coin(res["balance"])) elif outcome == "lose": bet_line = S.RPS_UI["bet_lose"].format(amount=coin(self.bet), balance=coin(res["balance"])) @@ -652,6 +664,9 @@ def register_economy_games_commands( res = await economy.do_slots(interaction.user.id, panus_int) if not res["ok"]: active_games.discard(interaction.user.id) + if res["reason"] == "db_error": + await reply_db_error(interaction) + return if res["reason"] == "banned": await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) return @@ -1017,6 +1032,9 @@ def register_economy_games_commands( try: res = await economy.do_blackjack_bet(self.user_id, self.bet) if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + return await interaction.response.send_message( S.ERR["broke"].format(bal=coin(res.get("balance", 0))), ephemeral=True ) @@ -1042,6 +1060,9 @@ def register_economy_games_commands( try: res = await economy.do_blackjack_bet(self.user_id, self.bet) if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + return await interaction.response.send_message( S.ERR["broke"].format(bal=coin(res.get("balance", 0))), ephemeral=True ) @@ -1101,6 +1122,9 @@ def register_economy_games_commands( res = await economy.do_blackjack_bet(interaction.user.id, bet) if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + return if res["reason"] == "banned": await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "jailed": diff --git a/commands/economy_income_commands.py b/commands/economy_income_commands.py index c6b1088..be69575 100644 --- a/commands/economy_income_commands.py +++ b/commands/economy_income_commands.py @@ -10,6 +10,8 @@ from discord import app_commands from core import economy import strings as S +from ._replies import reply_db_error + def register_economy_income_commands( tree: app_commands.CommandTree, @@ -25,6 +27,9 @@ def register_economy_income_commands( await interaction.response.defer() res = await economy.do_daily(interaction.user.id) if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + return if res["reason"] == "banned": await interaction.followup.send(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "cooldown": @@ -63,6 +68,9 @@ def register_economy_income_commands( await interaction.response.defer() res = await economy.do_work(interaction.user.id) if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + return if res["reason"] == "banned": await interaction.followup.send(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "cooldown": @@ -97,6 +105,9 @@ def register_economy_income_commands( await interaction.response.defer() res = await economy.do_beg(interaction.user.id) if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + return if res["reason"] == "banned": await interaction.followup.send(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "cooldown": @@ -128,6 +139,9 @@ def register_economy_income_commands( await interaction.response.defer() res = await economy.do_crime(interaction.user.id) if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + return if res["reason"] == "banned": await interaction.followup.send(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "cooldown": @@ -187,6 +201,9 @@ def register_economy_income_commands( await interaction.response.defer() res = await economy.do_rob(interaction.user.id, sihtmĂ€rk.id) if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + return if res["reason"] == "banned": await interaction.followup.send(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "cooldown": diff --git a/commands/economy_prestige_commands.py b/commands/economy_prestige_commands.py index f90af46..3a4b217 100644 --- a/commands/economy_prestige_commands.py +++ b/commands/economy_prestige_commands.py @@ -9,6 +9,8 @@ from discord import app_commands from core import economy import strings as S +from ._replies import reply_db_error + def register_prestige_commands( tree: app_commands.CommandTree, @@ -127,6 +129,9 @@ def register_prestige_commands( return await interaction.response.defer() res = await economy.do_prestige(self.user_id) + if not res["ok"] and res.get("reason") == "db_error": + await reply_db_error(interaction) + return self.clear_items() if not res["ok"]: embed = discord.Embed( @@ -166,6 +171,9 @@ def register_prestige_commands( await interaction.response.defer() res = await economy.do_prestige_buy(self.user_id, upgrade_id) if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + return if res["reason"] == "insufficient_pp": err = S.PRESTIGE_UI["buy_no_pp"].format(have=res["have"], need=res["need"]) elif res["reason"] == "maxed": @@ -221,6 +229,9 @@ def register_prestige_commands( return res = await economy.do_prestige_buy(interaction.user.id, upgrade.strip().lower()) if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + return if res["reason"] == "banned": await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "not_found": diff --git a/commands/economy_support_commands.py b/commands/economy_support_commands.py index 76778bd..60862ae 100644 --- a/commands/economy_support_commands.py +++ b/commands/economy_support_commands.py @@ -8,6 +8,8 @@ from discord import app_commands from core import economy import strings as S +from ._replies import reply_db_error + def register_economy_support_commands( tree: app_commands.CommandTree, @@ -47,6 +49,9 @@ def register_economy_support_commands( res = await economy.do_give(interaction.user.id, self._view.requester.id, amount) if not res["ok"]: self._view.remaining += amount # roll back the reservation + if res["reason"] == "db_error": + await reply_db_error(interaction) + return data = await economy.get_user(interaction.user.id) await interaction.response.send_message( S.ERR["broke"].format(bal=coin(data["balance"])), ephemeral=True @@ -212,6 +217,9 @@ def register_economy_support_commands( res = await economy.do_buy_consumable(interaction.user.id, ese.value) if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + return if res["reason"] == "banned": await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "insufficient": @@ -288,6 +296,9 @@ def register_economy_support_commands( res = await economy.do_vanity_select(interaction.user.id, ese.value) if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + return if res["reason"] == "banned": await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "insufficient": diff --git a/core/economy/fishing.py b/core/economy/fishing.py index 970a54a..1e44752 100644 --- a/core/economy/fishing.py +++ b/core/economy/fishing.py @@ -3,12 +3,11 @@ from __future__ import annotations import random -from datetime import timedelta from ..pb_client import DatabaseError from .store import ( - COOLDOWNS, _cooldown_remaining, _commit, _is_jailed, _locked_by, _now, - _prestige_mult, _txn, get_user, + _cooldown_remaining, _commit, _is_jailed, _locked_by, _now, + _prestige_mult, _txn, effective_cooldown, get_user, ) @@ -81,7 +80,7 @@ async def do_fish_start(user_id: int) -> dict: if jail := _is_jailed(user): return {"ok": False, "reason": "jailed", "remaining": jail} - fish_cd = timedelta(seconds=90) if "ussipurk" in user["items"] else COOLDOWNS["fish"] + fish_cd = effective_cooldown("fish", user["items"]) if cd := _cooldown_remaining(user, "fish", override_cd=fish_cd): return {"ok": False, "reason": "cooldown", "remaining": cd} diff --git a/core/economy/income.py b/core/economy/income.py index 8c5f265..92698e9 100644 --- a/core/economy/income.py +++ b/core/economy/income.py @@ -3,15 +3,16 @@ from __future__ import annotations import random -from datetime import date, timedelta +from datetime import date import strings from ..pb_client import DatabaseError from . import house from .store import ( - COOLDOWNS, JAIL_DURATION, PRESTIGE_SHOP, _commit, _cooldown_remaining, - _is_jailed, _locked_by, _log, _now, _prestige_mult, _txn, get_user, + JAIL_DURATION, PRESTIGE_SHOP, _commit, _cooldown_remaining, + _is_jailed, _locked_by, _log, _now, _prestige_mult, _txn, effective_cooldown, + get_user, ) from .house import _credit_house from .consumables import earn_mult @@ -29,7 +30,7 @@ async def do_daily(user_id: int) -> dict: if user.get("eco_banned"): return {"ok": False, "reason": "banned"} - daily_cd = timedelta(hours=18) if "korvaklapid" in user["items"] else COOLDOWNS["daily"] + daily_cd = effective_cooldown("daily", user["items"]) if cd := _cooldown_remaining(user, "daily", override_cd=daily_cd): return {"ok": False, "reason": "cooldown", "remaining": cd} @@ -109,7 +110,7 @@ async def do_work(user_id: int) -> dict: if user.get("eco_banned"): return {"ok": False, "reason": "banned"} - work_cd = timedelta(minutes=40) if "monitor" in user["items"] else COOLDOWNS["work"] + work_cd = effective_cooldown("work", user["items"]) if cd := _cooldown_remaining(user, "work", override_cd=work_cd): return {"ok": False, "reason": "cooldown", "remaining": cd} if jail := _is_jailed(user): @@ -163,7 +164,7 @@ async def do_beg(user_id: int) -> dict: if user.get("eco_banned"): return {"ok": False, "reason": "banned"} - beg_cd = timedelta(minutes=3) if "hiirematt" in user["items"] else COOLDOWNS["beg"] + beg_cd = effective_cooldown("beg", user["items"]) if cd := _cooldown_remaining(user, "beg", override_cd=beg_cd): return {"ok": False, "reason": "cooldown", "remaining": cd} diff --git a/core/economy/leaderboards.py b/core/economy/leaderboards.py index 7b963fa..55be52f 100644 --- a/core/economy/leaderboards.py +++ b/core/economy/leaderboards.py @@ -80,3 +80,32 @@ async def get_leaderboard_fish(top_n: int | None = 10) -> list[tuple[str, int]]: reverse=True, ) return result if top_n is None else result[:top_n] + + +async def get_all_leaderboards() -> dict[str, list[tuple]]: + """Build every leaderboard view from a SINGLE collection scan. + + /leaderboard shows six tabs; calling each get_leaderboard_* separately would + read the whole collection six times. This reads once and sorts in memory, + returning the same tuple shapes the individual functions produce (unbounded - + the command paginates).""" + records = await pb_client.list_all_records() + users = [r for r in records if r.get("user_id")] + + def desc(keyfn) -> list[dict]: + return sorted(users, key=keyfn, reverse=True) + + return { + "coins": [(r["user_id"], r.get("balance", 0)) + for r in desc(lambda r: r.get("balance", 0))], + "exp": [(r["user_id"], r.get("exp", 0), get_level(r.get("exp", 0))) + for r in desc(lambda r: r.get("exp", 0))], + "season": [(r["user_id"], r.get("season_total_exp", 0), r.get("prestige_level", 0)) + for r in desc(lambda r: r.get("season_total_exp", 0))], + "prestige": [(r["user_id"], r.get("prestige_level", 0), r.get("prestige_points", 0)) + for r in desc(lambda r: (r.get("prestige_level", 0), r.get("prestige_points", 0)))], + "wagered": [(r["user_id"], r.get("total_wagered", 0)) + for r in desc(lambda r: r.get("total_wagered", 0))], + "fish": [(r["user_id"], r.get("total_fish_caught", 0)) + for r in desc(lambda r: r.get("total_fish_caught", 0))], + } diff --git a/core/economy/store.py b/core/economy/store.py index 7742334..d1f8e5d 100644 --- a/core/economy/store.py +++ b/core/economy/store.py @@ -87,6 +87,26 @@ COOLDOWNS: dict[str, timedelta] = { "fish": timedelta(minutes=2), } +# Items that shorten a command's cooldown: command -> (item_id, reduced cooldown). +# Single source of truth so the cooldown check (do_*), the reminder scheduler +# (_maybe_remind) and the restart restore (_restore_reminders) never drift apart. +ITEM_COOLDOWNS: dict[str, tuple[str, timedelta]] = { + "work": ("monitor", timedelta(minutes=40)), + "beg": ("hiirematt", timedelta(minutes=3)), + "daily": ("korvaklapid", timedelta(hours=18)), + "fish": ("ussipurk", timedelta(seconds=90)), +} + + +def effective_cooldown(cmd: str, items) -> timedelta | None: + """The cooldown for `cmd` given the user's owned `items`, applying any + item-based reduction. Returns None for commands with no cooldown.""" + override = ITEM_COOLDOWNS.get(cmd) + if override is not None and override[0] in items: + return override[1] + return COOLDOWNS.get(cmd) + + JAIL_DURATION = timedelta(minutes=30) HEIST_JAIL = timedelta(hours=1, minutes=30) diff --git a/strings/common.py b/strings/common.py index 99b3b5f..4308af8 100644 --- a/strings/common.py +++ b/strings/common.py @@ -176,6 +176,7 @@ ERR: dict[str, str] = { "sheet_error": "❌ Tabeli laadimine ebaĂ”nnestus: ```{error}```", "gamble_cooldown": "🎰 Oled just mĂ€nginud! Saad uuesti mĂ€ngida {ts}.", "payout_failed": "⚠ Tehniline viga vĂ”idu vĂ€ljamaksmisel - see on logitud ja admin taastab su TipiCOINid. Vabandame!", + "db_error": "⚠ Andmebaas ei vasta praegu. Proovi hetke pĂ€rast uuesti.", } # --------------------------------------------------------------------------- diff --git a/tests/test_economy_pure.py b/tests/test_economy_pure.py index 5d72d30..09682cc 100644 --- a/tests/test_economy_pure.py +++ b/tests/test_economy_pure.py @@ -35,6 +35,29 @@ class TestLevels: assert economy.level_role_name(99) == "TipiLEGEND" +class TestEffectiveCooldown: + def test_default_when_no_item(self): + assert economy.effective_cooldown("work", []) == economy.COOLDOWNS["work"] + assert economy.effective_cooldown("daily", []) == economy.COOLDOWNS["daily"] + + def test_item_reduces_cooldown(self): + assert economy.effective_cooldown("work", ["monitor"]) == timedelta(minutes=40) + assert economy.effective_cooldown("beg", ["hiirematt"]) == timedelta(minutes=3) + assert economy.effective_cooldown("daily", ["korvaklapid"]) == timedelta(hours=18) + assert economy.effective_cooldown("fish", ["ussipurk"]) == timedelta(seconds=90) + + def test_unrelated_item_does_not_reduce(self): + assert economy.effective_cooldown("work", ["hiirematt"]) == economy.COOLDOWNS["work"] + + def test_command_without_cooldown_returns_none(self): + assert economy.effective_cooldown("unknown", []) is None + + def test_every_item_cooldown_beats_its_base(self): + # An item-reduced cooldown must always be shorter than the base. + for cmd, (item, reduced) in economy.store.ITEM_COOLDOWNS.items(): + assert reduced < economy.COOLDOWNS[cmd] + + class TestGambleExp: def test_tiers(self): assert economy.gamble_exp(0) == 0 diff --git a/tests/test_leaderboards.py b/tests/test_leaderboards.py new file mode 100644 index 0000000..f150442 --- /dev/null +++ b/tests/test_leaderboards.py @@ -0,0 +1,49 @@ +"""Tests for leaderboard queries, incl. the single-scan combined builder.""" + +from core import economy, pb_client + +from conftest import run + + +def _seed(fake_pb, n: int) -> None: + for i in range(1, n + 1): + run(economy.get_user(1000 + i)) + rec = fake_pb.record_for(1000 + i) + rec["balance"] = i * 100 + rec["exp"] = i * 50 + rec["season_total_exp"] = i * 10 + rec["prestige_level"] = i % 3 + rec["prestige_points"] = i + rec["total_wagered"] = i * 7 + rec["total_fish_caught"] = n - i # inverse order, to catch sort mistakes + + +class TestGetAllLeaderboards: + def test_matches_individual_queries(self, fake_pb): + _seed(fake_pb, 5) + combined = run(economy.get_all_leaderboards()) + assert combined["coins"] == run(economy.get_leaderboard(top_n=None)) + assert combined["exp"] == run(economy.get_leaderboard_exp(top_n=None)) + assert combined["season"] == run(economy.get_leaderboard_season_exp(top_n=None)) + assert combined["prestige"] == run(economy.get_leaderboard_prestige(top_n=None)) + assert combined["wagered"] == run(economy.get_leaderboard_wagered(top_n=None)) + assert combined["fish"] == run(economy.get_leaderboard_fish(top_n=None)) + + def test_scans_collection_once(self, fake_pb, monkeypatch): + _seed(fake_pb, 3) + calls = {"n": 0} + real = pb_client.list_all_records + + async def counting(): + calls["n"] += 1 + return await real() + + monkeypatch.setattr(pb_client, "list_all_records", counting) + run(economy.get_all_leaderboards()) + assert calls["n"] == 1 # not six + + def test_fish_sorted_descending(self, fake_pb): + _seed(fake_pb, 4) + fish = run(economy.get_all_leaderboards())["fish"] + counts = [c for _, c in fish] + assert counts == sorted(counts, reverse=True) From 4cd13503a75a8cf6bb65252e752315e2e9f959e3 Mon Sep 17 00:00:00 2001 From: Rene Arumetsa Date: Fri, 4 Sep 2026 02:28:19 +0300 Subject: [PATCH 12/21] feat(status): show money-supply metric in /status Adds economy.get_economy_stats() (single-scan total coins, house balance, player-held coins, player count) and a "Rahavaru" field to /status, so admins can see whether the sinks are keeping pace with minted income. Also removed a duplicate bot_admin_check import. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT --- commands/ops_admin_commands.py | 15 ++++++++++++++- core/economy/leaderboards.py | 28 ++++++++++++++++++++++++++++ strings/common.py | 2 ++ tests/test_leaderboards.py | 28 ++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 1 deletion(-) diff --git a/commands/ops_admin_commands.py b/commands/ops_admin_commands.py index 00c1547..1099170 100644 --- a/commands/ops_admin_commands.py +++ b/commands/ops_admin_commands.py @@ -13,9 +13,9 @@ from pathlib import Path import discord from discord import app_commands +from core import economy from core.admin import bot_admin_check import strings as S -from core.admin import bot_admin_check def register_ops_admin_commands( @@ -45,6 +45,7 @@ def register_ops_admin_commands( latency_ms = round(bot.latency * 1000, 1) cache_size = get_member_cache_size() user_count = await count_economy_users() + eco_stats = await economy.get_economy_stats() embed = discord.Embed(title=S.STATUS_UI["title"], color=0x57F287) embed.add_field( @@ -82,6 +83,18 @@ def register_ops_admin_commands( value=str(cache_size), inline=True, ) + total = eco_stats["total_coins"] + house_pct = round(eco_stats["house_balance"] / total * 100) if total else 0 + embed.add_field( + name=S.STATUS_UI["supply_field"], + value=S.STATUS_UI["supply_val"].format( + total=f"{total:,}".replace(",", " "), + players=f"{eco_stats['player_coins']:,}".replace(",", " "), + house=f"{eco_stats['house_balance']:,}".replace(",", " "), + house_pct=house_pct, + ), + inline=False, + ) log_lines = [ S.STATUS_UI["log_line"].format(name=p.name, size_kb=f"{p.stat().st_size / 1024:.1f}") diff --git a/core/economy/leaderboards.py b/core/economy/leaderboards.py index 55be52f..7e31d34 100644 --- a/core/economy/leaderboards.py +++ b/core/economy/leaderboards.py @@ -3,6 +3,7 @@ from __future__ import annotations from .. import pb_client +from . import house from .levels import get_level @@ -109,3 +110,30 @@ async def get_all_leaderboards() -> dict[str, list[tuple]]: "fish": [(r["user_id"], r.get("total_fish_caught", 0)) for r in desc(lambda r: r.get("total_fish_caught", 0))], } + + +async def get_economy_stats() -> dict[str, int]: + """Money-supply snapshot from a single scan: total coins in circulation, how + much players hold vs. the house. Lets /status show whether the sinks (house, + vanity burn, bail, fines) are keeping pace with minted income.""" + records = await pb_client.list_all_records() + house_id = str(house.HOUSE_ID) if house.HOUSE_ID is not None else None + total = 0 + house_balance = 0 + player_count = 0 + for r in records: + uid = r.get("user_id") + if not uid: + continue + bal = r.get("balance", 0) or 0 + total += bal + if uid == house_id: + house_balance = bal + else: + player_count += 1 + return { + "total_coins": total, + "house_balance": house_balance, + "player_coins": total - house_balance, + "player_count": player_count, + } diff --git a/strings/common.py b/strings/common.py index 4308af8..0a70b73 100644 --- a/strings/common.py +++ b/strings/common.py @@ -243,6 +243,8 @@ STATUS_UI: dict[str, str] = { "tasks_field": "🔄 Async tasks", "eco_players_field": "đŸ‘€ Eco players", "members_cache_field": "📋 Liikmed (cache)", + "supply_field": "💰 Rahavaru", + "supply_val": "Kokku {total}\nMĂ€ngijatel {players}\nKassas {house} ({house_pct}%)", "log_files_field": "📂 Log files", "log_line": "`{name}` - {size_kb} KB", "none": "-", diff --git a/tests/test_leaderboards.py b/tests/test_leaderboards.py index f150442..c3f79bc 100644 --- a/tests/test_leaderboards.py +++ b/tests/test_leaderboards.py @@ -47,3 +47,31 @@ class TestGetAllLeaderboards: fish = run(economy.get_all_leaderboards())["fish"] counts = [c for _, c in fish] assert counts == sorted(counts, reverse=True) + + +class TestEconomyStats: + def test_totals_split_house_from_players(self, fake_pb, monkeypatch): + monkeypatch.setattr(economy.house, "HOUSE_ID", 999) + monkeypatch.setattr(economy.house, "_house_pb_id", None) + # three players + the house + for uid, bal in [(1, 100), (2, 250), (3, 0)]: + run(economy.get_user(uid)) + fake_pb.record_for(uid)["balance"] = bal + run(economy.get_user(999)) + fake_pb.record_for(999)["balance"] = 5000 + + stats = run(economy.get_economy_stats()) + assert stats["total_coins"] == 100 + 250 + 0 + 5000 + assert stats["house_balance"] == 5000 + assert stats["player_coins"] == 350 + assert stats["player_count"] == 3 # house excluded + + def test_no_house_configured(self, fake_pb, monkeypatch): + monkeypatch.setattr(economy.house, "HOUSE_ID", None) + run(economy.get_user(1)) + fake_pb.record_for(1)["balance"] = 42 + stats = run(economy.get_economy_stats()) + assert stats["total_coins"] == 42 + assert stats["house_balance"] == 0 + assert stats["player_coins"] == 42 + assert stats["player_count"] == 1 From f72d72b355cc06f6ff55d4eb05baf83ed6b1cc98 Mon Sep 17 00:00:00 2001 From: Rene Arumetsa Date: Fri, 4 Sep 2026 02:31:10 +0300 Subject: [PATCH 13/21] fix(blackjack): return db_error on bet-commit failure; document new patterns - do_blackjack_bet now wraps its _commit so a DB failure returns db_error (handled by the command layer) instead of raising through the interaction. - DEV_NOTES "Adding a New Economy Command" checklist updated: use effective_cooldown / store.ITEM_COOLDOWNS as the single source of truth for item-modified cooldowns, and handle db_error via reply_db_error. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT --- core/economy/gambling.py | 7 ++++++- docs/DEV_NOTES.md | 6 +++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/core/economy/gambling.py b/core/economy/gambling.py index c89a72d..1ebe713 100644 --- a/core/economy/gambling.py +++ b/core/economy/gambling.py @@ -248,7 +248,12 @@ async def do_blackjack_bet(user_id: int, bet: int) -> dict: if user["balance"] < bet: return {"ok": False, "reason": "insufficient", "balance": user["balance"]} user["balance"] -= bet - await _commit(user_id, user) + try: + await _commit(user_id, user) + except DatabaseError: + # Deduction never persisted, so the player was not charged - report it + # instead of raising through the interaction handler. + return {"ok": False, "reason": "db_error"} return {"ok": True, "balance": user["balance"]} diff --git a/docs/DEV_NOTES.md b/docs/DEV_NOTES.md index c00a2cb..af08b3b 100644 --- a/docs/DEV_NOTES.md +++ b/docs/DEV_NOTES.md @@ -58,7 +58,7 @@ Pick the `commands/economy_*_commands.py` file that matches the new command's ca Checklist - do all of these, in order: 1. **`core/economy/.py`** (e.g. `income.py`, `gambling.py`, `fishing.py`) - add the `do_` async function with cooldown check, logic, `_commit`, and `_txn` logging (`get_user`/`_commit`/`_txn` live in `store.py`) -2. **`core/economy/store.py`** - add the cooldown to `COOLDOWNS` dict if it has one +2. **`core/economy/store.py`** - add the cooldown to `COOLDOWNS` dict if it has one. Compute the effective cooldown in `do_` with `effective_cooldown("", user["items"])` rather than an inline `timedelta(...) if item in items else ...` 3. **`core/economy/levels.py`** - add the EXP reward to `EXP_REWARDS` dict 4. **`strings/commands.py` `CMD`** - add the slash command description 5. **`strings/commands.py` `OPT`** - add any parameter descriptions @@ -66,11 +66,11 @@ Checklist - do all of these, in order: 7. **`strings/common.py` `ERR`** - add any error messages (banned, cooldown uses `CD_MSG`, jailed uses `CD_MSG["jailed"]`) 8. **`strings/common.py` `CD_MSG`** - add cooldown message if command has a cooldown 9. **`strings/commands.py` `HELP_CATEGORIES["tipibot"]["fields"]`** - add the command to the help embed -10. **`commands/economy__commands.py`** - inside `register_*_commands`, add `@tree.command(name="", ...)` `cmd_`; handle all `res["reason"]` cases +10. **`commands/economy__commands.py`** - inside `register_*_commands`, add `@tree.command(name="", ...)` `cmd_`; handle all `res["reason"]` cases. If `do_` can return `db_error` (any function that calls `get_user`/`_commit`), handle it first in the failure block with `await reply_db_error(interaction); return` (import from `._replies`) - otherwise a DB outage hangs the deferred interaction or shows a misleading message 11. **`commands/economy__commands.py`** - call `maybe_remind(user_id, "")` if the command has a cooldown and reminders make sense (the helper is passed in via the `register_*` signature) 12. **`commands/economy__commands.py`** - call `await award_exp(interaction, economy.EXP_REWARDS[""])` on success 13. **`strings/common.py` `REMINDER_OPTS`** - add a reminder option if the command needs one -14. **`bot.py` `_maybe_remind`** - if the command has an item-modified cooldown, add an `elif` branch (this helper still lives in `bot.py` and is shared across all command modules) +14. **`core/economy/store.py` `ITEM_COOLDOWNS`** - if an item shortens the command's cooldown, add `"": ("", timedelta(...))` here. This is the single source of truth: `effective_cooldown` (used by `do_`), `_maybe_remind`, and `_restore_reminders` all read it, so you no longer edit the reminder helpers by hand --- From 8481003edf5698ed54a124f582724f7f3e85bcdb Mon Sep 17 00:00:00 2001 From: Rene Arumetsa Date: Fri, 4 Sep 2026 02:37:43 +0300 Subject: [PATCH 14/21] feat(economy): persist interactive-game stakes so a restart can't eat them Blackjack and RPS PvP deduct a stake up front and held it only in an in-memory View - a restart mid-hand lost the coins. Now the stake is escrowed on the user record (new pending_wager JSON field) in the SAME commit as the deduction, and: - do_blackjack_bet accumulates the escrow (covers double/split); do_blackjack_payout clears it on settlement (incl. the 0-payout loss/timeout paths). - do_rps_pvp_deposit records it; do_rps_pvp_payout/refund clear it, and a new do_rps_pvp_forfeit clears the loser's marker (their stake went to the winner). - reconcile_pending_wagers() runs on startup (on_ready) and refunds any stake left escrowed by an interrupted game. It's idempotent and locks per user. Schema: pending_wager auto-types as json via sync_pb_schema. Tests cover the full escrow lifecycle, loser forfeit, and idempotent reconciliation. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT --- bot.py | 10 +++ commands/economy_games_commands.py | 2 + core/economy/gambling.py | 29 +++++++- core/economy/store.py | 60 +++++++++++++++++ tests/test_pending_wager.py | 102 +++++++++++++++++++++++++++++ 5 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 tests/test_pending_wager.py diff --git a/bot.py b/bot.py index d066e92..bbaae03 100644 --- a/bot.py +++ b/bot.py @@ -441,6 +441,16 @@ async def on_ready(): # Re-schedule any reminder tasks lost on restart await _restore_reminders() + # Refund stakes escrowed by interactive games (blackjack/RPS PvP) that a + # restart interrupted, so a mid-hand crash never eats a player's coins. + try: + refunded = await economy.reconcile_pending_wagers() + if refunded: + total = sum(amt for _, amt, _ in refunded) + log.info("Reconciled %d interrupted wager(s), refunded %d coins", len(refunded), total) + except Exception: + log.exception("Pending-wager reconciliation failed") + # Notify the channel where /restart was triggered if _RESTART_FILE.exists(): try: diff --git a/commands/economy_games_commands.py b/commands/economy_games_commands.py index 7b80251..3691d61 100644 --- a/commands/economy_games_commands.py +++ b/commands/economy_games_commands.py @@ -290,10 +290,12 @@ def register_economy_games_commands( if self.bet > 0: if winner == "a": await economy.do_rps_pvp_payout(self.player_a.id, self.bet) + await economy.do_rps_pvp_forfeit(self.player_b.id) bet_line_a = f"\n+{coin(self.bet)}" bet_line_b = f"\n-{coin(self.bet)}" elif winner == "b": await economy.do_rps_pvp_payout(self.player_b.id, self.bet) + await economy.do_rps_pvp_forfeit(self.player_a.id) bet_line_a = f"\n-{coin(self.bet)}" bet_line_b = f"\n+{coin(self.bet)}" else: diff --git a/core/economy/gambling.py b/core/economy/gambling.py index 1ebe713..e803fc1 100644 --- a/core/economy/gambling.py +++ b/core/economy/gambling.py @@ -6,7 +6,10 @@ import random from ..pb_client import DatabaseError from ..emoji import EMOJI as E -from .store import _commit, _is_jailed, _locked_by, _log, _txn, get_user +from .store import ( + _commit, _is_jailed, _locked_by, _log, _txn, add_pending_wager, + clear_pending_wager, get_user, +) from .house import _credit_house @@ -105,6 +108,7 @@ async def do_rps_pvp_deposit(user_id: int, bet: int) -> dict: return {"ok": False, "reason": "insufficient"} user["balance"] -= bet user["total_wagered"] = user.get("total_wagered", 0) + bet + add_pending_wager(user, "rps", bet) # escrow survives a restart try: await _commit(user_id, user) except DatabaseError: @@ -125,6 +129,7 @@ async def do_rps_pvp_payout(winner_id: int, bet: int) -> dict: user["lifetime_earned"] = user.get("lifetime_earned", 0) + bet user["biggest_win"] = max(user.get("biggest_win", 0), bet) user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"]) + clear_pending_wager(user) # winner's escrow settled try: await _commit(winner_id, user) except DatabaseError: @@ -133,6 +138,23 @@ async def do_rps_pvp_payout(winner_id: int, bet: int) -> dict: return {"ok": True, "balance": user["balance"]} +@_locked_by(0) +async def do_rps_pvp_forfeit(loser_id: int) -> dict: + """Release the loser's escrow marker without refunding - their stake was paid + to the winner as part of the 2*bet payout. Without this the loser's + pending_wager would linger and be wrongly refunded on the next restart.""" + try: + user = await get_user(loser_id) + except DatabaseError: + return {"ok": False, "reason": "db_error"} + clear_pending_wager(user) + try: + await _commit(loser_id, user) + except DatabaseError: + return {"ok": False, "reason": "db_error"} + return {"ok": True} + + @_locked_by(0) async def do_rps_pvp_refund(user_id: int, bet: int) -> dict: """Refund a previously escrowed bet (tie / timeout / cancel).""" @@ -142,6 +164,7 @@ async def do_rps_pvp_refund(user_id: int, bet: int) -> dict: return {"ok": False, "reason": "db_error"} user["balance"] = user.get("balance", 0) + bet user["total_wagered"] = max(0, user.get("total_wagered", 0) - bet) + clear_pending_wager(user) # escrow returned try: await _commit(user_id, user) except DatabaseError: @@ -248,6 +271,9 @@ async def do_blackjack_bet(user_id: int, bet: int) -> dict: if user["balance"] < bet: return {"ok": False, "reason": "insufficient", "balance": user["balance"]} user["balance"] -= bet + # Escrow the stake in the same commit (accumulates across double/split), so a + # restart mid-hand refunds it via reconcile_pending_wagers instead of eating it. + add_pending_wager(user, "blackjack", bet) try: await _commit(user_id, user) except DatabaseError: @@ -279,6 +305,7 @@ async def do_blackjack_payout(user_id: int, payout: int, total_invested: int = 0 elif net < 0: user["lifetime_lost"] = user.get("lifetime_lost", 0) + abs(net) user["biggest_loss"] = max(user.get("biggest_loss", 0), abs(net)) + clear_pending_wager(user) # hand settled - release the escrow marker try: await _commit(user_id, user) except DatabaseError: diff --git a/core/economy/store.py b/core/economy/store.py index d1f8e5d..0cb4819 100644 --- a/core/economy/store.py +++ b/core/economy/store.py @@ -166,6 +166,11 @@ class UserData(TypedDict, total=False): # Quest system quest_daily: dict # {"date": "YYYY-MM-DD", "quests": {qid: {snap, claimed}}} quest_weekly: dict # {"week": "YYYY-Www", "quests": {qid: {snap, claimed}}} + # Coins a running interactive game (blackjack/RPS PvP) has deducted but not + # yet settled. {"kind": ..., "amount": int, "ts": iso} while escrowed, {} + # otherwise. Reconciled (refunded) on startup so a restart mid-game never + # eats the stake. See reconcile_pending_wagers. + pending_wager: dict def _default_user() -> UserData: @@ -222,6 +227,8 @@ def _default_user() -> UserData: # ── Quests ─────────────────────────────────────────────────────────── "quest_daily": {}, "quest_weekly": {}, + # ── Interactive-game escrow (blackjack / RPS PvP) ──────────────────── + "pending_wager": {}, } @@ -399,6 +406,59 @@ async def _commit(user_id: int, user: UserData) -> dict | None: raise DatabaseError(f"Failed to persist user {user_id}: {exc}") from exc +# --------------------------------------------------------------------------- +# Pending-wager escrow (interactive games survive a restart) +# --------------------------------------------------------------------------- +# Interactive games (blackjack, RPS PvP) deduct a stake up front and hold it in +# an in-memory View until the hand resolves. A restart would drop the View and +# lose the coins. To prevent that, the deduction commit also records the escrowed +# amount on the user (add_pending_wager), the settlement commit clears it +# (clear_pending_wager), and reconcile_pending_wagers refunds anything still +# outstanding at startup. All three mutate the user dict in place so the escrow +# state rides along in the SAME commit as the balance change (atomic). +def add_pending_wager(user: UserData, kind: str, amount: int) -> None: + """Record/accumulate `amount` coins as escrowed by a `kind` game.""" + pw = dict(user.get("pending_wager") or {}) + pw = { + "kind": kind, + "amount": int(pw.get("amount", 0) or 0) + amount, + "ts": _now().isoformat(), + } + user["pending_wager"] = pw + + +def clear_pending_wager(user: UserData) -> None: + """Mark the user's escrow settled (call in the settlement commit).""" + user["pending_wager"] = {} + + +async def reconcile_pending_wagers() -> list[tuple[int, int, str]]: + """Refund every stake left escrowed by a game that a restart interrupted. + + Runs once at startup (before commands are served). Returns the list of + (user_id, refunded_amount, kind) so the caller can log a summary.""" + refunded: list[tuple[int, int, str]] = [] + for uid_str, snapshot in (await get_all_users_raw()).items(): + pw = snapshot.get("pending_wager") or {} + if int(pw.get("amount", 0) or 0) <= 0: + continue + uid = int(uid_str) + async with _user_lock(uid): + user = await get_user(uid) + pw = user.get("pending_wager") or {} + amount = int(pw.get("amount", 0) or 0) + if amount <= 0: + continue + kind = str(pw.get("kind", "?")) + user["balance"] += amount + clear_pending_wager(user) + await _commit(uid, user) + _txn("WAGER_RECONCILE", user=uid, refund=f"+{amount}", kind=kind, bal=user["balance"]) + _log.info("Refunded interrupted %s wager: %s coins to user %s", kind, amount, uid) + refunded.append((uid, amount, kind)) + return refunded + + # --------------------------------------------------------------------------- # /reminders # --------------------------------------------------------------------------- diff --git a/tests/test_pending_wager.py b/tests/test_pending_wager.py new file mode 100644 index 0000000..b52e6a5 --- /dev/null +++ b/tests/test_pending_wager.py @@ -0,0 +1,102 @@ +"""Tests for pending-wager escrow persistence. + +Interactive games (blackjack, RPS PvP) deduct a stake up front and hold it in an +in-memory View. These tests verify the stake is recorded on the user record in +the same commit, cleared on settlement, and refunded by reconcile on restart. +""" + +from core import economy + +from conftest import run + +UID = 555 +OPP = 556 + + +def _fund(fake_pb, uid: int, amount: int) -> None: + run(economy.get_user(uid)) + fake_pb.record_for(uid)["balance"] = amount + + +def _pending(fake_pb, uid: int) -> dict: + return fake_pb.record_for(uid).get("pending_wager") or {} + + +class TestBlackjackEscrow: + def test_bet_records_pending_and_payout_clears(self, fake_pb): + _fund(fake_pb, UID, 1000) + run(economy.do_blackjack_bet(UID, 100)) + pw = _pending(fake_pb, UID) + assert pw["kind"] == "blackjack" and pw["amount"] == 100 + + run(economy.do_blackjack_payout(UID, payout=200, total_invested=100)) + assert _pending(fake_pb, UID) == {} # settled + assert fake_pb.record_for(UID)["balance"] == 1000 - 100 + 200 + + def test_double_split_accumulates_escrow(self, fake_pb): + _fund(fake_pb, UID, 1000) + run(economy.do_blackjack_bet(UID, 100)) # initial + run(economy.do_blackjack_bet(UID, 100)) # double / split adds a hand + assert _pending(fake_pb, UID)["amount"] == 200 + + def test_losing_hand_clears_escrow(self, fake_pb): + _fund(fake_pb, UID, 1000) + run(economy.do_blackjack_bet(UID, 100)) + run(economy.do_blackjack_payout(UID, payout=0, total_invested=100)) # bust/loss + assert _pending(fake_pb, UID) == {} + + +class TestRpsEscrow: + def test_deposit_records_and_payout_forfeit_clear(self, fake_pb): + _fund(fake_pb, UID, 500) + _fund(fake_pb, OPP, 500) + run(economy.do_rps_pvp_deposit(UID, 100)) + run(economy.do_rps_pvp_deposit(OPP, 100)) + assert _pending(fake_pb, UID)["kind"] == "rps" + assert _pending(fake_pb, OPP)["amount"] == 100 + + run(economy.do_rps_pvp_payout(UID, 100)) # UID wins + run(economy.do_rps_pvp_forfeit(OPP)) # OPP loses + assert _pending(fake_pb, UID) == {} + assert _pending(fake_pb, OPP) == {} + assert fake_pb.record_for(UID)["balance"] == 500 - 100 + 200 + assert fake_pb.record_for(OPP)["balance"] == 500 - 100 + + def test_refund_clears_escrow(self, fake_pb): + _fund(fake_pb, UID, 500) + run(economy.do_rps_pvp_deposit(UID, 100)) + run(economy.do_rps_pvp_refund(UID, 100)) + assert _pending(fake_pb, UID) == {} + assert fake_pb.record_for(UID)["balance"] == 500 + + +class TestReconcile: + def test_refunds_interrupted_stakes(self, fake_pb): + # Simulate a restart: two players left mid-game with escrowed stakes. + _fund(fake_pb, UID, 400) + run(economy.do_blackjack_bet(UID, 100)) # 300 left, 100 escrowed + _fund(fake_pb, OPP, 500) + fake_pb.record_for(OPP)["balance"] = 500 + run(economy.do_rps_pvp_deposit(OPP, 250)) # 250 left, 250 escrowed + + refunded = run(economy.reconcile_pending_wagers()) + by_uid = {uid: (amt, kind) for uid, amt, kind in refunded} + assert by_uid[UID] == (100, "blackjack") + assert by_uid[OPP] == (250, "rps") + assert fake_pb.record_for(UID)["balance"] == 400 # stake restored + assert fake_pb.record_for(OPP)["balance"] == 500 + assert _pending(fake_pb, UID) == {} + assert _pending(fake_pb, OPP) == {} + + def test_noop_when_nothing_pending(self, fake_pb): + _fund(fake_pb, UID, 100) + assert run(economy.reconcile_pending_wagers()) == [] + assert fake_pb.record_for(UID)["balance"] == 100 + + def test_reconcile_is_idempotent(self, fake_pb): + _fund(fake_pb, UID, 400) + run(economy.do_blackjack_bet(UID, 100)) + run(economy.reconcile_pending_wagers()) + # A second run (e.g. another restart) must not double-refund. + assert run(economy.reconcile_pending_wagers()) == [] + assert fake_pb.record_for(UID)["balance"] == 400 From b34c1e22dad9fefc994b7645db3fd99310ddcd50 Mon Sep 17 00:00:00 2001 From: Rene Arumetsa Date: Fri, 4 Sep 2026 02:44:46 +0300 Subject: [PATCH 15/21] feat(economy): add /lootbox mystery box (coin sink with random reward) A pay-to-open box (1000 coins) with a weighted reward: coin tiers (usually a net loss - the sink), a random 30-min earn/exp buff, or a rare jackpot. All rolling lives in do_open_lootbox for testability; the command adds a short reveal. - New core module lootbox.py; extracted consumables.grant_buff (reused by both consumables and lootbox) to avoid duplicating the buff-stacking logic. - New lootboxes_opened stat; pending schema syncs as a number automatically. - Added /consumables, /lootbox, /vanity to the help embed (the first two were previously missing from /help). Tests cover charging, insufficient/banned, the coin and buff outcomes, the net-vs-reward invariant, and that balance never goes negative. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT --- commands/economy_support_commands.py | 41 +++++++++++++ core/economy/__init__.py | 3 +- core/economy/consumables.py | 22 ++++--- core/economy/lootbox.py | 91 ++++++++++++++++++++++++++++ core/economy/store.py | 2 + strings/__init__.py | 2 + strings/commands.py | 4 ++ strings/economy.py | 18 ++++++ tests/test_lootbox.py | 78 ++++++++++++++++++++++++ 9 files changed, 253 insertions(+), 8 deletions(-) create mode 100644 core/economy/lootbox.py create mode 100644 tests/test_lootbox.py 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 From 417617175ecbd25297eb2c878c4b90dfd16b103b Mon Sep 17 00:00:00 2001 From: Rene Arumetsa Date: Fri, 4 Sep 2026 02:45:25 +0300 Subject: [PATCH 16/21] docs(readme): document /consumables, /lootbox and /vanity coin sinks Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 9c07cbe..3b88501 100644 --- a/README.md +++ b/README.md @@ -339,6 +339,9 @@ The **ECONOMY** role is granted on your first EXP award (i.e. first successful e | `/leaderboard` | Paginated leaderboard with 6 tabs: đŸȘ™ Coins, 📊 EXP, 🏆 Season EXP, đŸ”„ Prestige, đŸŽČ Wagered, 🎣 Fish caught. House pinned at #0 on coins tab. | | `/shop` | Browse all items by tier. Shows owned status, Anticheat charges remaining, and level lock for T2/T3. | | `/buy ` | Purchase an item by name (partial match accepted). | +| `/consumables` | Buy repeatable, expiring boosts (earn ×2, EXP ×2, or an instant cooldown wipe). A recurring coin sink. | +| `/lootbox` | Open a mystery box for **1 000 ⏥**. Weighted random reward: coin tiers (usually a small net loss), a 30-min earn/EXP ×2 buff, or a rare jackpot. | +| `/vanity` | Status shop — buy and equip cosmetic badges/titles (shown on `/profile`). Coins are **burned**, not recirculated. No gameplay effect. | | `/reminders` | Toggle per-command DM notifications. Bot DMs you the moment each cooldown expires. | | `/quests` | Personal daily (3) and weekly (2) quests with progress bars and a claim button. | | `/fish` | Interactive fishing minigame. Cast, wait for bite, pull, then keep or sell. 2min cooldown (90s with Ussipurk). | From 42bbdbd93d552a2d725a247d4664904feb37d889 Mon Sep 17 00:00:00 2001 From: Rene Arumetsa Date: Fri, 4 Sep 2026 02:53:14 +0300 Subject: [PATCH 17/21] feat(economy): add /bank vault (rob-proof storage) + net-worth leaderboard A strategic counterpart to /rob: coins moved to the bank are safe from /rob and /heist (which only touch liquid balance) but earn no Bot Farm interest and can't be spent, gambled or given until withdrawn - the deliberate trade-off against keeping coins liquid. - New bank.py (do_deposit/do_withdraw), bank_balance schema field. - /bank (view), /deposit, /withdraw commands ('all' supported), with db_error handling; /balance shows the vault when non-zero. - Coins leaderboard and /status money-supply now count net worth (balance + bank_balance), so banking never hides you from the board or the supply metric. - Season reset wipes bank_balance too (no cross-season wealth hiding). 10 tests: deposit/withdraw math, guards, coin conservation, rob cannot touch the vault, and net-worth accounting on the leaderboard + stats. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT --- README.md | 5 +- commands/economy_profile_commands.py | 3 + commands/economy_support_commands.py | 63 ++++++++++++++++++++ core/economy/__init__.py | 3 +- core/economy/admin.py | 1 + core/economy/bank.py | 63 ++++++++++++++++++++ core/economy/leaderboards.py | 21 ++++--- core/economy/store.py | 4 +- strings/__init__.py | 2 + strings/commands.py | 8 +++ strings/economy.py | 15 +++++ tests/test_bank.py | 86 ++++++++++++++++++++++++++++ 12 files changed, 264 insertions(+), 10 deletions(-) create mode 100644 core/economy/bank.py create mode 100644 tests/test_bank.py diff --git a/README.md b/README.md index 3b88501..2eb5769 100644 --- a/README.md +++ b/README.md @@ -336,9 +336,12 @@ The **ECONOMY** role is granted on your first EXP award (i.e. first successful e | `/rank [@user]` | EXP total, current level, progress bar to next level, leaderboard rank. | | `/stats [@user]` | Lifetime statistics: economy totals, work/beg counts, gambling records, crime/heist history, social totals, best streak. | | `/cooldowns` | All cooldowns at a glance with live Discord timestamps. Shows jail timer if jailed. | -| `/leaderboard` | Paginated leaderboard with 6 tabs: đŸȘ™ Coins, 📊 EXP, 🏆 Season EXP, đŸ”„ Prestige, đŸŽČ Wagered, 🎣 Fish caught. House pinned at #0 on coins tab. | +| `/leaderboard` | Paginated leaderboard with 6 tabs: đŸȘ™ Coins (net worth = wallet + bank), 📊 EXP, 🏆 Season EXP, đŸ”„ Prestige, đŸŽČ Wagered, 🎣 Fish caught. House pinned at #0 on coins tab. | | `/shop` | Browse all items by tier. Shows owned status, Anticheat charges remaining, and level lock for T2/T3. | | `/buy ` | Purchase an item by name (partial match accepted). | +| `/bank` | View your vault. Banked coins are **rob-proof** (`/rob` and `/heist` only touch liquid balance) but earn no Bot Farm interest and can't be spent until withdrawn. | +| `/deposit ` | Move coins from your wallet into the bank. `all` deposits everything liquid. | +| `/withdraw ` | Move coins from the bank back to your wallet. `all` withdraws everything banked. | | `/consumables` | Buy repeatable, expiring boosts (earn ×2, EXP ×2, or an instant cooldown wipe). A recurring coin sink. | | `/lootbox` | Open a mystery box for **1 000 ⏥**. Weighted random reward: coin tiers (usually a small net loss), a 30-min earn/EXP ×2 buff, or a rare jackpot. | | `/vanity` | Status shop — buy and equip cosmetic badges/titles (shown on `/profile`). Coins are **burned**, not recirculated. No gameplay effect. | diff --git a/commands/economy_profile_commands.py b/commands/economy_profile_commands.py index c47ec9d..7a1a256 100644 --- a/commands/economy_profile_commands.py +++ b/commands/economy_profile_commands.py @@ -292,6 +292,9 @@ def register_economy_profile_commands( color=0xF4C430, ) embed.add_field(name=S.BALANCE_UI["saldo"], value=coin(data["balance"]), inline=True) + bank_balance = data.get("bank_balance", 0) + if bank_balance: + embed.add_field(name=S.BANK_UI["f_bank"], value=coin(bank_balance), inline=True) streak = data.get("daily_streak", 0) if streak: embed.add_field( diff --git a/commands/economy_support_commands.py b/commands/economy_support_commands.py index 79157fc..4abfacb 100644 --- a/commands/economy_support_commands.py +++ b/commands/economy_support_commands.py @@ -363,6 +363,69 @@ def register_economy_support_commands( embed.set_footer(text=foot) await msg.edit(embed=embed) + # -- /bank, /deposit, /withdraw ----------------------------------------- + @tree.command(name="bank", description=S.CMD["bank"]) + async def cmd_bank(interaction: discord.Interaction): + data = await economy.get_user(interaction.user.id) + embed = discord.Embed( + title=S.BANK_UI["title"], description=S.BANK_UI["desc"], color=0xF4C430 + ) + embed.add_field(name=S.BANK_UI["f_liquid"], value=coin(data.get("balance", 0)), inline=True) + embed.add_field(name=S.BANK_UI["f_bank"], value=coin(data.get("bank_balance", 0)), inline=True) + await interaction.response.send_message(embed=embed, ephemeral=True) + + @tree.command(name="deposit", description=S.CMD["deposit"]) + @app_commands.describe(summa=S.OPT["deposit_summa"]) + async def cmd_deposit(interaction: discord.Interaction, summa: str): + data = await economy.get_user(interaction.user.id) + amount, err = parse_amount(summa, data.get("balance", 0)) + if err or amount is None: + await interaction.response.send_message(err or S.ERR["invalid_amount"], ephemeral=True) + return + if amount <= 0: + await interaction.response.send_message(S.ERR["positive_amount"], ephemeral=True) + return + res = await economy.do_deposit(interaction.user.id, amount) + 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.BANK_UI["nothing_liquid"], ephemeral=True) + return + await interaction.response.send_message( + S.BANK_UI["deposited"].format( + amount=coin(res["amount"]), balance=coin(res["balance"]), bank=coin(res["bank"]) + ) + ) + + @tree.command(name="withdraw", description=S.CMD["withdraw"]) + @app_commands.describe(summa=S.OPT["withdraw_summa"]) + async def cmd_withdraw(interaction: discord.Interaction, summa: str): + data = await economy.get_user(interaction.user.id) + amount, err = parse_amount(summa, data.get("bank_balance", 0)) + if err or amount is None: + await interaction.response.send_message(err or S.ERR["invalid_amount"], ephemeral=True) + return + if amount <= 0: + await interaction.response.send_message(S.ERR["positive_amount"], ephemeral=True) + return + res = await economy.do_withdraw(interaction.user.id, amount) + 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.BANK_UI["nothing_bank"], ephemeral=True) + return + await interaction.response.send_message( + S.BANK_UI["withdrawn"].format( + amount=coin(res["amount"]), balance=coin(res["balance"]), bank=coin(res["bank"]) + ) + ) + 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 4a89445..1f2a941 100644 --- a/core/economy/__init__.py +++ b/core/economy/__init__.py @@ -16,6 +16,7 @@ from .store import ( ) from .house import * from .house import _credit_house, _house_record_id +from .bank import * from .levels import * from .shop import * from .consumables import * @@ -33,6 +34,6 @@ from .heist import * from .admin import * from . import ( # noqa: E402 (submodules addressable as economy.store etc.) - admin, consumables, fishing, gambling, heist, house, income, jail, + admin, bank, consumables, fishing, gambling, heist, house, income, jail, leaderboards, levels, lootbox, prestige, quests, shop, store, vanity, ) diff --git a/core/economy/admin.py b/core/economy/admin.py index ffe3db9..41f6685 100644 --- a/core/economy/admin.py +++ b/core/economy/admin.py @@ -22,6 +22,7 @@ async def do_season_reset(top_n: int = 10) -> list[tuple[str, int, int]]: reset_fields = { "exp": 0, "balance": 0, + "bank_balance": 0, "items": [], "item_uses": {}, "last_daily": None, diff --git a/core/economy/bank.py b/core/economy/bank.py new file mode 100644 index 0000000..3e6da2c --- /dev/null +++ b/core/economy/bank.py @@ -0,0 +1,63 @@ +"""Bank vault: rob-proof coin storage. + +Coins moved to the bank are safe from /rob and /heist (those target the liquid +`balance` only), but they earn no interest and cannot be spent, gambled or given +until withdrawn. This is the deliberate trade-off against keeping coins liquid, +where Bot Farm can earn interest but a robber can take a cut. Net worth +(balance + bank_balance) is what the coins leaderboard ranks, so banking never +hides you from the leaderboard. +""" + +from __future__ import annotations + +from ..pb_client import DatabaseError +from .store import _commit, _locked_by, _txn, get_user + +__all__ = ["do_deposit", "do_withdraw"] + + +@_locked_by(0) +async def do_deposit(user_id: int, amount: int) -> dict: + """Move `amount` coins from liquid balance into the bank vault.""" + 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 amount <= 0: + return {"ok": False, "reason": "invalid"} + if user["balance"] < amount: + return {"ok": False, "reason": "insufficient", "balance": user["balance"]} + user["balance"] -= amount + user["bank_balance"] = user.get("bank_balance", 0) + amount + try: + await _commit(user_id, user) + except DatabaseError: + return {"ok": False, "reason": "db_error"} + _txn("BANK_DEPOSIT", user=user_id, amount=amount, bal=user["balance"], bank=user["bank_balance"]) + return {"ok": True, "amount": amount, "balance": user["balance"], "bank": user["bank_balance"]} + + +@_locked_by(0) +async def do_withdraw(user_id: int, amount: int) -> dict: + """Move `amount` coins from the bank vault back to liquid balance.""" + 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 amount <= 0: + return {"ok": False, "reason": "invalid"} + bank = user.get("bank_balance", 0) + if bank < amount: + return {"ok": False, "reason": "insufficient", "bank": bank} + user["bank_balance"] = bank - amount + user["balance"] += amount + try: + await _commit(user_id, user) + except DatabaseError: + return {"ok": False, "reason": "db_error"} + _txn("BANK_WITHDRAW", user=user_id, amount=amount, bal=user["balance"], bank=user["bank_balance"]) + return {"ok": True, "amount": amount, "balance": user["balance"], "bank": user["bank_balance"]} diff --git a/core/economy/leaderboards.py b/core/economy/leaderboards.py index 7e31d34..9d39e52 100644 --- a/core/economy/leaderboards.py +++ b/core/economy/leaderboards.py @@ -7,11 +7,17 @@ from . import house from .levels import get_level +def _net_worth(r: dict) -> int: + """Coins that count toward wealth: liquid balance + banked vault.""" + return (r.get("balance", 0) or 0) + (r.get("bank_balance", 0) or 0) + + async def get_leaderboard(top_n: int | None = 10) -> list[tuple[str, int]]: - """Return top_n (user_id_str, balance) pairs sorted descending.""" + """Return top_n (user_id_str, net_worth) pairs sorted descending. + Net worth = balance + bank_balance, so banking coins does not hide them.""" records = await pb_client.list_all_records() result = sorted( - ((r["user_id"], r.get("balance", 0)) for r in records if r.get("user_id")), + ((r["user_id"], _net_worth(r)) for r in records if r.get("user_id")), key=lambda x: x[1], reverse=True, ) @@ -97,8 +103,8 @@ async def get_all_leaderboards() -> dict[str, list[tuple]]: return sorted(users, key=keyfn, reverse=True) return { - "coins": [(r["user_id"], r.get("balance", 0)) - for r in desc(lambda r: r.get("balance", 0))], + "coins": [(r["user_id"], _net_worth(r)) + for r in desc(_net_worth)], "exp": [(r["user_id"], r.get("exp", 0), get_level(r.get("exp", 0))) for r in desc(lambda r: r.get("exp", 0))], "season": [(r["user_id"], r.get("season_total_exp", 0), r.get("prestige_level", 0)) @@ -125,10 +131,11 @@ async def get_economy_stats() -> dict[str, int]: uid = r.get("user_id") if not uid: continue - bal = r.get("balance", 0) or 0 - total += bal + # Banked coins are still part of the money supply. + worth = (r.get("balance", 0) or 0) + (r.get("bank_balance", 0) or 0) + total += worth if uid == house_id: - house_balance = bal + house_balance = worth else: player_count += 1 return { diff --git a/core/economy/store.py b/core/economy/store.py index efdf7ae..10a0804 100644 --- a/core/economy/store.py +++ b/core/economy/store.py @@ -114,7 +114,8 @@ HEIST_JAIL = timedelta(hours=1, minutes=30) # User schema # --------------------------------------------------------------------------- class UserData(TypedDict, total=False): - balance: int + balance: int # liquid coins - spendable, gamblable, robbable + bank_balance: int # vaulted coins - safe from /rob and /heist, not spendable until withdrawn exp: int # lifetime EXP (resets each season) last_daily: str | None last_work: str | None @@ -177,6 +178,7 @@ class UserData(TypedDict, total=False): def _default_user() -> UserData: return { "balance": 0, + "bank_balance": 0, "exp": 0, "last_daily": None, "last_work": None, diff --git a/strings/__init__.py b/strings/__init__.py index 2bb5696..0e4ce64 100644 --- a/strings/__init__.py +++ b/strings/__init__.py @@ -60,6 +60,7 @@ from .economy import ( CONSUMABLE_DESCRIPTIONS, VANITY_UI, LOOTBOX_UI, + BANK_UI, JAILED_UI, SHOP_BTN, DAILY_UI, @@ -154,6 +155,7 @@ __all__ = [ 'CONSUMABLE_DESCRIPTIONS', 'VANITY_UI', 'LOOTBOX_UI', + 'BANK_UI', 'JAILED_UI', 'SHOP_BTN', 'DAILY_UI', diff --git a/strings/commands.py b/strings/commands.py index ad82600..227dbb3 100644 --- a/strings/commands.py +++ b/strings/commands.py @@ -76,6 +76,9 @@ CMD: dict[str, str] = { "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", + "bank": "Vaata oma panka - röövikindel hoius", + "deposit": "Pane mĂŒnte panka (röövikindel, aga ei teeni intressi)", + "withdraw": "VĂ”ta mĂŒnte pangast rahakotti", } # --------------------------------------------------------------------------- @@ -93,6 +96,8 @@ OPT: dict[str, str] = { "give_kasutaja": "Kellele annad?", "give_summa": "Kui palju annad? ('all' = kogu saldo)", "buy_ese": "Eseme nimi (vaata /shop)", + "deposit_summa": "Kui palju panna panka? ('all' = kogu vaba raha)", + "withdraw_summa": "Kui palju pangast vĂ€lja vĂ”tta? ('all' = kogu pangas)", "consumable_ese": "Turgutus, mida osta (tĂŒhjaks jĂ€ttes nĂ€eb menĂŒĂŒd)", "vanity_ese": "Tiitel, mida osta vĂ”i kanda (tĂŒhjaks jĂ€ttes nĂ€eb poodi)", "rps_panus": "Valikuline TipiCOINide panus ('all' = kogu saldo)", @@ -150,6 +155,9 @@ HELP_CATEGORIES: dict[str, dict] = { ("/heist", "Alusta grupiröövi pangahoidlasse. Min 2 mĂ€ngijat, max 8. 5 min ĂŒhinemisaeg. Õnnestumisel jagatakse saak vĂ”rdselt - ebaĂ”nnestumisel 1h 30min vangis + trahv. 4h serveri ooteaeg (ei ole isiklik)."), ("/jailbreak", "Proovi vanglas olles tĂ€ringuid visata, et duublit saada (3 katset). Duubli korral saad vabaks. EbaĂ”nnestumisel saad valida: maksa kautsjon (20-30% saldost, min 350 ⏥) vĂ”i jÀÀ vanglasse kuni aja lĂ”puni."), ("/give @user ", "Anna TipiCOINe teisele mĂ€ngijale"), + ("/bank", "Vaata oma panka. Panka pandud mĂŒndid on röövikindlad (/rob ja /heist ei puuduta)."), + ("/deposit ", "Pane mĂŒnte panka (röövikindel hoius, ei teeni intressi)."), + ("/withdraw ", "VĂ”ta mĂŒnte pangast rahakotti."), ("/quests", "Vaata oma pĂ€eva- ja nĂ€dalaĂŒlesandeid ning nĂ”ua auhinnad (uueneb iga pĂ€ev/nĂ€dal)."), ("/leaderboard", "TipiBOTi edetabel - kes on kĂ”ige rikkam?"), ("/shop", "Sirvi TipiBOTi poodi"), diff --git a/strings/economy.py b/strings/economy.py index fadc95d..76be096 100644 --- a/strings/economy.py +++ b/strings/economy.py @@ -19,6 +19,7 @@ __all__ = [ 'CONSUMABLE_DESCRIPTIONS', 'VANITY_UI', 'LOOTBOX_UI', + 'BANK_UI', 'JAILED_UI', 'SHOP_BTN', 'DAILY_UI', @@ -275,6 +276,20 @@ LOOTBOX_UI: dict[str, str] = { "foot_buff": "Saldo: {balance}", } +# --------------------------------------------------------------------------- +# Bank vault (rob-proof storage; liquid vs banked coins) +# --------------------------------------------------------------------------- +BANK_UI: dict[str, str] = { + "title": "🏩 TipiPANK", + "desc": "Panka pandud mĂŒndid on **röövikindlad** (`/rob` ja `/heist` neid ei puuduta), aga neid ei saa kulutada ega panustada enne vĂ€ljavĂ”tmist ega teeni Botikoopa intressi.", + "f_liquid": "đŸ’” Rahakotis (vaba)", + "f_bank": "🏩 Pangas (kaitstud)", + "deposited": "🏩 Panid **{amount}** panka.\nđŸ’” Rahakotis: {balance} · 🏩 Pangas: {bank}", + "withdrawn": "đŸ’” VĂ”tsid **{amount}** pangast vĂ€lja.\nđŸ’” Rahakotis: {balance} · 🏩 Pangas: {bank}", + "nothing_liquid": "❌ Sul pole nii palju vaba raha rahakotis.", + "nothing_bank": "❌ Sul pole nii palju raha pangas.", +} + JAILED_UI: dict[str, str] = { "title": "🔒 Praegu vanglas", "empty": "KĂ”ik on vabad! Vanglas pole kedagi.", diff --git a/tests/test_bank.py b/tests/test_bank.py new file mode 100644 index 0000000..6ccc9f6 --- /dev/null +++ b/tests/test_bank.py @@ -0,0 +1,86 @@ +"""Tests for the bank vault: rob-proof storage and net-worth accounting.""" + +from core import economy + +from conftest import run + +UID = 6161 +ROBBER = 6162 + + +def _fund(fake_pb, uid: int, balance: int, bank: int = 0) -> None: + run(economy.get_user(uid)) + rec = fake_pb.record_for(uid) + rec["balance"] = balance + rec["bank_balance"] = bank + + +class TestDepositWithdraw: + def test_deposit_moves_liquid_to_bank(self, fake_pb): + _fund(fake_pb, UID, 1000) + res = run(economy.do_deposit(UID, 400)) + assert res["ok"] and res["balance"] == 600 and res["bank"] == 400 + rec = fake_pb.record_for(UID) + assert rec["balance"] == 600 and rec["bank_balance"] == 400 + + def test_withdraw_moves_bank_to_liquid(self, fake_pb): + _fund(fake_pb, UID, 100, bank=500) + res = run(economy.do_withdraw(UID, 300)) + assert res["ok"] and res["balance"] == 400 and res["bank"] == 200 + + def test_deposit_more_than_liquid_rejected(self, fake_pb): + _fund(fake_pb, UID, 100) + res = run(economy.do_deposit(UID, 500)) + assert not res["ok"] and res["reason"] == "insufficient" + assert fake_pb.record_for(UID)["balance"] == 100 # unchanged + + def test_withdraw_more_than_bank_rejected(self, fake_pb): + _fund(fake_pb, UID, 0, bank=100) + res = run(economy.do_withdraw(UID, 500)) + assert not res["ok"] and res["reason"] == "insufficient" + assert fake_pb.record_for(UID)["bank_balance"] == 100 + + def test_nonpositive_rejected(self, fake_pb): + _fund(fake_pb, UID, 1000, bank=1000) + assert run(economy.do_deposit(UID, 0))["reason"] == "invalid" + assert run(economy.do_withdraw(UID, -5))["reason"] == "invalid" + + def test_banned_rejected(self, fake_pb): + _fund(fake_pb, UID, 1000) + fake_pb.record_for(UID)["eco_banned"] = True + assert run(economy.do_deposit(UID, 100))["reason"] == "banned" + + def test_round_trip_conserves_coins(self, fake_pb): + _fund(fake_pb, UID, 1000) + run(economy.do_deposit(UID, 700)) + run(economy.do_withdraw(UID, 700)) + rec = fake_pb.record_for(UID) + assert rec["balance"] == 1000 and rec["bank_balance"] == 0 + + +class TestRobProof: + def test_rob_cannot_touch_banked_coins(self, fake_pb, monkeypatch): + # Target keeps everything banked, only a little liquid (< rob threshold). + _fund(fake_pb, UID, 50, bank=100_000) + _fund(fake_pb, ROBBER, 1000) + # Target has < 100 liquid, so a rob is rejected as "broke" - the vault is + # invisible to /rob (which only reads balance). + res = run(economy.do_rob(ROBBER, UID)) + assert not res["ok"] and res["reason"] == "broke" + assert fake_pb.record_for(UID)["bank_balance"] == 100_000 # untouched + + +class TestNetWorth: + def test_leaderboard_counts_bank(self, fake_pb): + _fund(fake_pb, UID, 100, bank=900) # net worth 1000 + _fund(fake_pb, ROBBER, 500, bank=0) # net worth 500 + board = dict((uid, worth) for uid, worth in run(economy.get_leaderboard(top_n=None))) + assert board[str(UID)] == 1000 + assert board[str(ROBBER)] == 500 + + def test_economy_stats_counts_bank(self, fake_pb, monkeypatch): + monkeypatch.setattr(economy.house, "HOUSE_ID", None) + _fund(fake_pb, UID, 100, bank=900) + stats = run(economy.get_economy_stats()) + assert stats["total_coins"] == 1000 + assert stats["player_coins"] == 1000 From 570d8b7cbdd5679831f015352c18eb58eba07a40 Mon Sep 17 00:00:00 2001 From: Rene Arumetsa Date: Fri, 4 Sep 2026 02:57:34 +0300 Subject: [PATCH 18/21] feat(economy): add /achievements milestone badges 15 one-time achievements over the lifetime stat counters (work/wealth/gambling/ crime/heists/fishing/streaks/prestige), each paying a modest one-time coin reward when unlocked. Detection is lazy - opening /achievements claims any newly earned (like quests roll on view) - so no per-command hook is needed, and rewards are a bounded, one-time coin source. - New achievements.py: ACHIEVEMENTS table, pure newly_earned/achievements_view, and locked do_check_achievements. New achievements_earned schema field. - /achievements command (own view claims; viewing others is read-only) with progress bars, db_error handling, and an "unlocked" banner. 8 tests: threshold detection, one-time claim (no double pay), multi-unlock, view progress capping. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT --- README.md | 1 + commands/economy_profile_commands.py | 49 ++++++++++++ core/economy/__init__.py | 6 +- core/economy/achievements.py | 111 +++++++++++++++++++++++++++ core/economy/store.py | 2 + strings/__init__.py | 2 + strings/commands.py | 2 + strings/economy.py | 12 +++ tests/test_achievements.py | 78 +++++++++++++++++++ 9 files changed, 261 insertions(+), 2 deletions(-) create mode 100644 core/economy/achievements.py create mode 100644 tests/test_achievements.py diff --git a/README.md b/README.md index 2eb5769..2ecae2b 100644 --- a/README.md +++ b/README.md @@ -347,6 +347,7 @@ The **ECONOMY** role is granted on your first EXP award (i.e. first successful e | `/vanity` | Status shop — buy and equip cosmetic badges/titles (shown on `/profile`). Coins are **burned**, not recirculated. No gameplay effect. | | `/reminders` | Toggle per-command DM notifications. Bot DMs you the moment each cooldown expires. | | `/quests` | Personal daily (3) and weekly (2) quests with progress bars and a claim button. | +| `/achievements` | Milestone badges over your lifetime stats (work/wealth/gambling/crime/fishing/streaks/prestige). Each unlocks once and pays a one-time coin reward; opening the command claims any newly earned. | | `/fish` | Interactive fishing minigame. Cast, wait for bite, pull, then keep or sell. 2min cooldown (90s with Ussipurk). | | `/fishbook` | View your fish collection - all caught species, rarity, count, and current inventory amounts. | | `/fishsell` | Sell all fish currently in your inventory at once. | diff --git a/commands/economy_profile_commands.py b/commands/economy_profile_commands.py index 7a1a256..9fbd4d2 100644 --- a/commands/economy_profile_commands.py +++ b/commands/economy_profile_commands.py @@ -10,6 +10,8 @@ from discord import app_commands from core import economy import strings as S +from ._replies import reply_db_error + def register_economy_profile_commands( tree: app_commands.CommandTree, @@ -342,6 +344,53 @@ def register_economy_profile_commands( data = await economy.get_user(target.id) await interaction.response.send_message(embed=_balance_embed(target, data)) + @tree.command(name="achievements", description=S.CMD["achievements"]) + async def cmd_achievements( + interaction: discord.Interaction, kasutaja: discord.Member | None = None + ): + target = kasutaja or interaction.user + note = "" + # Only the invoker's own view claims newly-earned achievements. + if target.id == interaction.user.id: + res = await economy.do_check_achievements(interaction.user.id) + if not res["ok"]: + await reply_db_error(interaction) + return + if res["new"]: + names = ", ".join( + f"{economy.ACHIEVEMENTS[a]['emoji']} {economy.ACHIEVEMENTS[a]['name']}" + for a in res["new"] + ) + note = S.ACHIEVEMENTS_UI["unlocked_note"].format( + names=names, reward=coin(res["reward"]) + ) + "\n\n" + + data = await economy.get_user(target.id) + rows = economy.achievements_view(data) + earned_count = sum(1 for r in rows if r["earned"]) + lines = [ + S.ACHIEVEMENTS_UI["row_earned"].format( + emoji=r["emoji"], name=r["name"], reward=coin(r["reward"]) + ) + if r["earned"] + else S.ACHIEVEMENTS_UI["row_locked"].format( + emoji=r["emoji"], name=r["name"], progress=r["progress"], + goal=r["goal"], reward=coin(r["reward"]), + ) + for r in rows + ] + title = S.ACHIEVEMENTS_UI["title"] + if target.id != interaction.user.id: + title += f" · {target.display_name}" + embed = discord.Embed( + title=title, + description=note + + S.ACHIEVEMENTS_UI["desc"].format(earned=earned_count, total=len(rows)) + + "\n\n" + "\n".join(lines), + color=0xF4C430, + ) + await interaction.response.send_message(embed=embed) + @tree.command(name="cooldowns", description=S.CMD["cooldowns"]) async def cmd_cooldowns(interaction: discord.Interaction): data = await economy.get_user(interaction.user.id) diff --git a/core/economy/__init__.py b/core/economy/__init__.py index 1f2a941..73f5b71 100644 --- a/core/economy/__init__.py +++ b/core/economy/__init__.py @@ -31,9 +31,11 @@ from .gambling import * from .prestige import * from .leaderboards import * from .heist import * +from .achievements import * from .admin import * from . import ( # noqa: E402 (submodules addressable as economy.store etc.) - admin, bank, consumables, fishing, gambling, heist, house, income, jail, - leaderboards, levels, lootbox, prestige, quests, shop, store, vanity, + achievements, admin, bank, consumables, fishing, gambling, heist, house, + income, jail, leaderboards, levels, lootbox, prestige, quests, shop, store, + vanity, ) diff --git a/core/economy/achievements.py b/core/economy/achievements.py new file mode 100644 index 0000000..194ad71 --- /dev/null +++ b/core/economy/achievements.py @@ -0,0 +1,111 @@ +"""Achievements: one-time milestone badges over the lifetime stat counters. + +Each achievement watches a monotonic stat the bot already tracks and unlocks once +that stat crosses a threshold, paying a modest one-time coin reward. Detection is +lazy: do_check_achievements is called when the player opens /achievements (like +quests roll on view), so no hook is needed on every command. Rewards are bounded +(each pays once) so this is a small, capped coin source. +""" + +from __future__ import annotations + +from typing import TypedDict + +from ..pb_client import DatabaseError +from .store import _commit, _locked_by, _txn, get_user + +__all__ = [ + "Achievement", + "ACHIEVEMENTS", + "newly_earned", + "achievements_view", + "do_check_achievements", +] + + +class Achievement(TypedDict): + stat: str # UserData counter this milestone watches + goal: int + name: str + emoji: str + reward: int # one-time coin payout + + +# Ordered roughly easy -> hard within each theme. Rewards scale with difficulty. +ACHIEVEMENTS: dict[str, Achievement] = { + # Grind + "work_10": {"stat": "work_count", "goal": 10, "name": "Töömesilane", "emoji": "🐝", "reward": 250}, + "work_100": {"stat": "work_count", "goal": 100, "name": "Töönarkomaan", "emoji": "đŸ› ïž", "reward": 1_500}, + "beg_50": {"stat": "beg_count", "goal": 50, "name": "Elukutseline kerjus", "emoji": "đŸ„ș", "reward": 500}, + # Wealth + "earned_50k": {"stat": "lifetime_earned", "goal": 50_000, "name": "JĂ”ukas", "emoji": "💰", "reward": 1_000}, + "earned_500k": {"stat": "lifetime_earned", "goal": 500_000, "name": "TipiRIKAS", "emoji": "đŸ€‘", "reward": 5_000}, + # Gambling + "wager_10k": {"stat": "total_wagered", "goal": 10_000, "name": "HasartmĂ€ngur", "emoji": "đŸŽČ", "reward": 750}, + "wager_100k": {"stat": "total_wagered", "goal": 100_000, "name": "KĂ”rgete panuste mĂ€ngija", "emoji": "🃏", "reward": 3_000}, + "jackpot_1": {"stat": "slots_jackpots", "goal": 1, "name": "Jackpot!", "emoji": "🎰", "reward": 1_000}, + # Crime & heists + "crime_25": {"stat": "crimes_succeeded", "goal": 25, "name": "Kurjategija", "emoji": "đŸŠč", "reward": 1_000}, + "heist_5": {"stat": "heists_won", "goal": 5, "name": "Pangaröövel", "emoji": "💣", "reward": 1_500}, + # Fishing + "fish_25": {"stat": "total_fish_caught", "goal": 25, "name": "Kalur", "emoji": "🎣", "reward": 500}, + "fish_250": {"stat": "total_fish_caught", "goal": 250, "name": "KalapĂŒĂŒgimeister", "emoji": "🐟", "reward": 3_000}, + # Dedication + "streak_7": {"stat": "best_daily_streak", "goal": 7, "name": "PĂŒsiv", "emoji": "đŸ”„", "reward": 500}, + "streak_30": {"stat": "best_daily_streak", "goal": 30, "name": "PĂŒhendunud", "emoji": "đŸ—“ïž", "reward": 2_500}, + "prestige_1": {"stat": "prestige_level", "goal": 1, "name": "TaassĂŒnd", "emoji": "♻", "reward": 2_000}, +} + + +def _earned_ids(user) -> set[str]: + return set(user.get("achievements_earned") or []) + + +def newly_earned(user) -> list[str]: + """Achievement ids whose threshold is met but which are not yet claimed.""" + earned = _earned_ids(user) + return [ + aid for aid, a in ACHIEVEMENTS.items() + if aid not in earned and int(user.get(a["stat"], 0) or 0) >= a["goal"] + ] + + +def achievements_view(user) -> list[dict]: + """Display rows for every achievement: progress + earned flag (ordered as + defined, earned last so unfinished goals surface first).""" + earned = _earned_ids(user) + rows = [] + for aid, a in ACHIEVEMENTS.items(): + prog = int(user.get(a["stat"], 0) or 0) + rows.append({ + "id": aid, "name": a["name"], "emoji": a["emoji"], + "goal": a["goal"], "reward": a["reward"], + "progress": min(prog, a["goal"]), "earned": aid in earned, + }) + rows.sort(key=lambda r: r["earned"]) # unearned first + return rows + + +@_locked_by(0) +async def do_check_achievements(user_id: int) -> dict: + """Claim any newly-earned achievements and pay their one-time rewards.""" + try: + user = await get_user(user_id) + except DatabaseError: + return {"ok": False, "reason": "db_error"} + new = newly_earned(user) + if not new: + return {"ok": True, "new": [], "reward": 0, "balance": user["balance"]} + + earned = list(user.get("achievements_earned") or []) + total = 0 + for aid in new: + earned.append(aid) + total += ACHIEVEMENTS[aid]["reward"] + user["achievements_earned"] = earned + user["balance"] += total + user["lifetime_earned"] = user.get("lifetime_earned", 0) + total + user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"]) + await _commit(user_id, user) + _txn("ACHIEVEMENTS", user=user_id, unlocked=",".join(new), reward=f"+{total}", bal=user["balance"]) + return {"ok": True, "new": new, "reward": total, "balance": user["balance"]} diff --git a/core/economy/store.py b/core/economy/store.py index 10a0804..be07226 100644 --- a/core/economy/store.py +++ b/core/economy/store.py @@ -154,6 +154,7 @@ class UserData(TypedDict, total=False): total_received: int best_daily_streak: int lootboxes_opened: int + achievements_earned: list # ids of achievements already claimed heist_global_cd_until: float # Prestige system prestige_level: int @@ -217,6 +218,7 @@ def _default_user() -> UserData: "total_received": 0, "best_daily_streak": 0, "lootboxes_opened": 0, + "achievements_earned": [], "heist_global_cd_until": 0.0, # ── Prestige ───────────────────────────────────────────────────────── "prestige_level": 0, diff --git a/strings/__init__.py b/strings/__init__.py index 0e4ce64..1498f26 100644 --- a/strings/__init__.py +++ b/strings/__init__.py @@ -61,6 +61,7 @@ from .economy import ( VANITY_UI, LOOTBOX_UI, BANK_UI, + ACHIEVEMENTS_UI, JAILED_UI, SHOP_BTN, DAILY_UI, @@ -156,6 +157,7 @@ __all__ = [ 'VANITY_UI', 'LOOTBOX_UI', 'BANK_UI', + 'ACHIEVEMENTS_UI', 'JAILED_UI', 'SHOP_BTN', 'DAILY_UI', diff --git a/strings/commands.py b/strings/commands.py index 227dbb3..ea4a04e 100644 --- a/strings/commands.py +++ b/strings/commands.py @@ -76,6 +76,7 @@ CMD: dict[str, str] = { "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", + "achievements": "Vaata oma saavutusi ja teeni mĂ€rkide eest ĂŒhekordseid preemiaid", "bank": "Vaata oma panka - röövikindel hoius", "deposit": "Pane mĂŒnte panka (röövikindel, aga ei teeni intressi)", "withdraw": "VĂ”ta mĂŒnte pangast rahakotti", @@ -159,6 +160,7 @@ HELP_CATEGORIES: dict[str, dict] = { ("/deposit ", "Pane mĂŒnte panka (röövikindel hoius, ei teeni intressi)."), ("/withdraw ", "VĂ”ta mĂŒnte pangast rahakotti."), ("/quests", "Vaata oma pĂ€eva- ja nĂ€dalaĂŒlesandeid ning nĂ”ua auhinnad (uueneb iga pĂ€ev/nĂ€dal)."), + ("/achievements", "Vaata oma saavutusi. Iga lukust lahti saanud mĂ€rk annab ĂŒhekordse mĂŒntipreemia."), ("/leaderboard", "TipiBOTi edetabel - kes on kĂ”ige rikkam?"), ("/shop", "Sirvi TipiBOTi poodi"), ("/buy ", "Osta ese TipiBOTi poodist"), diff --git a/strings/economy.py b/strings/economy.py index 76be096..e4c448c 100644 --- a/strings/economy.py +++ b/strings/economy.py @@ -20,6 +20,7 @@ __all__ = [ 'VANITY_UI', 'LOOTBOX_UI', 'BANK_UI', + 'ACHIEVEMENTS_UI', 'JAILED_UI', 'SHOP_BTN', 'DAILY_UI', @@ -290,6 +291,17 @@ BANK_UI: dict[str, str] = { "nothing_bank": "❌ Sul pole nii palju raha pangas.", } +# --------------------------------------------------------------------------- +# Achievements (one-time milestone badges over lifetime stats) +# --------------------------------------------------------------------------- +ACHIEVEMENTS_UI: dict[str, str] = { + "title": "🏅 Saavutused", + "desc": "Iga lukust lahti saanud mĂ€rk annab ĂŒhekordse mĂŒntipreemia.\nAvatud: **{earned}/{total}**", + "row_earned": "✅ {emoji} **{name}** · +{reward}", + "row_locked": "🔒 {emoji} {name} · {progress}/{goal} · +{reward}", + "unlocked_note": "🎉 **Uued saavutused avatud:** {names}\n💰 Preemia: +{reward}", +} + JAILED_UI: dict[str, str] = { "title": "🔒 Praegu vanglas", "empty": "KĂ”ik on vabad! Vanglas pole kedagi.", diff --git a/tests/test_achievements.py b/tests/test_achievements.py new file mode 100644 index 0000000..c60d85e --- /dev/null +++ b/tests/test_achievements.py @@ -0,0 +1,78 @@ +"""Tests for the achievements system (one-time milestone rewards).""" + +from core import economy + +from conftest import run + +UID = 4747 + + +def _fund(fake_pb, **stats) -> None: + run(economy.get_user(UID)) + rec = fake_pb.record_for(UID) + rec.update(stats) + + +class TestNewlyEarned: + def test_threshold_met_is_newly_earned(self, fake_pb): + _fund(fake_pb, work_count=10) + user = run(economy.get_user(UID)) + assert "work_10" in economy.newly_earned(user) + + def test_below_threshold_not_earned(self, fake_pb): + _fund(fake_pb, work_count=9) + user = run(economy.get_user(UID)) + assert "work_10" not in economy.newly_earned(user) + + def test_already_claimed_not_repeated(self, fake_pb): + _fund(fake_pb, work_count=10, achievements_earned=["work_10"]) + user = run(economy.get_user(UID)) + assert "work_10" not in economy.newly_earned(user) + + +class TestClaim: + def test_claims_and_pays_reward_once(self, fake_pb): + _fund(fake_pb, work_count=10, balance=0) + reward = economy.ACHIEVEMENTS["work_10"]["reward"] + + res = run(economy.do_check_achievements(UID)) + assert res["ok"] and res["new"] == ["work_10"] + assert res["reward"] == reward + assert fake_pb.record_for(UID)["balance"] == reward + assert "work_10" in fake_pb.record_for(UID)["achievements_earned"] + + # Second call: nothing new, no double pay. + res2 = run(economy.do_check_achievements(UID)) + assert res2["new"] == [] and res2["reward"] == 0 + assert fake_pb.record_for(UID)["balance"] == reward + + def test_multiple_unlocked_at_once(self, fake_pb): + _fund(fake_pb, work_count=100, total_fish_caught=25, balance=0) + res = run(economy.do_check_achievements(UID)) + # work_10, work_100 and fish_25 all cross at once + assert set(res["new"]) == {"work_10", "work_100", "fish_25"} + expected = sum(economy.ACHIEVEMENTS[a]["reward"] for a in res["new"]) + assert res["reward"] == expected + assert fake_pb.record_for(UID)["balance"] == expected + + def test_nothing_to_claim(self, fake_pb): + _fund(fake_pb, balance=500) + res = run(economy.do_check_achievements(UID)) + assert res["ok"] and res["new"] == [] and res["reward"] == 0 + assert fake_pb.record_for(UID)["balance"] == 500 + + +class TestView: + def test_view_marks_earned_and_progress(self, fake_pb): + _fund(fake_pb, work_count=5, achievements_earned=["beg_50"]) + rows = economy.achievements_view(run(economy.get_user(UID))) + by_id = {r["id"]: r for r in rows} + assert by_id["beg_50"]["earned"] is True + assert by_id["work_10"]["earned"] is False + assert by_id["work_10"]["progress"] == 5 # capped at goal + assert len(rows) == len(economy.ACHIEVEMENTS) + + def test_progress_capped_at_goal(self, fake_pb): + _fund(fake_pb, work_count=9999) + rows = {r["id"]: r for r in economy.achievements_view(run(economy.get_user(UID)))} + assert rows["work_10"]["progress"] == rows["work_10"]["goal"] From ba044bf16f661fb7fb13e4470e0742eaefcddda5 Mon Sep 17 00:00:00 2001 From: Rene Arumetsa Date: Fri, 4 Sep 2026 03:02:27 +0300 Subject: [PATCH 19/21] polish(economy): surface bank/lootbox/achievement stats in /stats and /adminview - /stats and /profile stats now show lootboxes opened and achievements unlocked alongside the best streak. - /adminview shows bank balance, achievements count, lootboxes opened, and any pending (escrowed) wager. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT --- commands/economy_admin_commands.py | 12 ++++++++++++ commands/economy_profile_commands.py | 14 ++++++++++++-- strings/admin.py | 3 +++ strings/economy.py | 2 +- 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/commands/economy_admin_commands.py b/commands/economy_admin_commands.py index ad5f1de..61d26ea 100644 --- a/commands/economy_admin_commands.py +++ b/commands/economy_admin_commands.py @@ -211,17 +211,29 @@ def register_economy_admin_commands( prestige_pp = data.get("prestige_points", 0) total_fish = data.get("total_fish_caught", 0) inv_fish = len(data.get("fish_inventory") or []) + pw = data.get("pending_wager") or {} + wager_str = f"{pw.get('amount', 0):,} ({pw.get('kind')})" if pw.get("amount") else "-" embed = discord.Embed( title=S.ADMINVIEW_UI["title"].format(name=kasutaja.display_name), color=0x5865F2, ) embed.add_field(name=S.ADMINVIEW_UI["f_balance"], value=f"{data.get('balance', 0):,} {economy.COIN}", inline=True) + embed.add_field(name=S.ADMINVIEW_UI["f_bank"], value=f"{data.get('bank_balance', 0):,} {economy.COIN}", inline=True) embed.add_field(name=S.ADMINVIEW_UI["f_exp"], value=S.ADMINVIEW_UI["exp_val"].format(exp=f"{exp:,}", level=level), inline=True) embed.add_field(name=S.ADMINVIEW_UI["f_streak"], value=str(data.get("daily_streak", 0)), inline=True) embed.add_field(name=S.ADMINVIEW_UI["f_banned"], value=banned, inline=True) embed.add_field(name=S.ADMINVIEW_UI["f_jailed"], value=jailed, inline=True) embed.add_field(name=S.ADMINVIEW_UI["f_prestige"], value=S.ADMINVIEW_UI["prestige_val"].format(level=prestige_lvl, pp=prestige_pp), inline=True) embed.add_field(name=S.ADMINVIEW_UI["f_fish"], value=S.ADMINVIEW_UI["fish_val"].format(caught=total_fish, inv=inv_fish), inline=True) + embed.add_field( + name=S.ADMINVIEW_UI["f_extras"], + value=S.ADMINVIEW_UI["extras_val"].format( + ach=len(data.get("achievements_earned") or []), + lootboxes=data.get("lootboxes_opened", 0), + wager=wager_str, + ), + inline=True, + ) embed.add_field(name=S.ADMINVIEW_UI["f_items"], value=items_str, inline=False) embed.add_field(name=S.ADMINVIEW_UI["f_uses"], value=uses_str, inline=False) embed.add_field(name=S.ADMINVIEW_UI["f_last_daily"], value=data.get("last_daily") or "-", inline=True) diff --git a/commands/economy_profile_commands.py b/commands/economy_profile_commands.py index 9fbd4d2..34a6e76 100644 --- a/commands/economy_profile_commands.py +++ b/commands/economy_profile_commands.py @@ -155,7 +155,12 @@ def register_economy_profile_commands( ) embed.add_field( name=S.STATS_UI["records_field"], - value=S.STATS_UI["records_val"].format(streak=_s("best_daily_streak")), + value=S.STATS_UI["records_val"].format( + streak=_s("best_daily_streak"), + lootboxes=_s("lootboxes_opened"), + achievements=len(data.get("achievements_earned") or []), + ach_total=len(economy.ACHIEVEMENTS), + ), inline=True, ) return embed @@ -596,7 +601,12 @@ def register_economy_profile_commands( ) embed.add_field( name=S.STATS_UI["records_field"], - value=S.STATS_UI["records_val"].format(streak=_s("best_daily_streak")), + value=S.STATS_UI["records_val"].format( + streak=_s("best_daily_streak"), + lootboxes=_s("lootboxes_opened"), + achievements=len(data.get("achievements_earned") or []), + ach_total=len(economy.ACHIEVEMENTS), + ), inline=True, ) await interaction.response.send_message(embed=embed, ephemeral=True) diff --git a/strings/admin.py b/strings/admin.py index 7dd90f5..a4f1aaf 100644 --- a/strings/admin.py +++ b/strings/admin.py @@ -19,6 +19,9 @@ ADMINVIEW_UI: dict[str, str] = { "banned_yes": "đŸš« JAH", "banned_no": "✅ Ei", "f_balance": "💰 Saldo", + "f_bank": "🏩 Pangas", + "f_extras": "🏅 Muu", + "extras_val": "Saavutusi: {ach}\nÕnnekaste: {lootboxes}\nOotel panus: {wager}", "f_exp": "📊 EXP / Tase", "f_streak": "đŸ”„ Streak", "f_banned": "đŸš« Keelatud", diff --git a/strings/economy.py b/strings/economy.py index e4c448c..2f21233 100644 --- a/strings/economy.py +++ b/strings/economy.py @@ -341,7 +341,7 @@ STATS_UI: dict[str, str] = { "social_field": "đŸ€ Sotsiaalne", "social_val": "Kingitud: {given}\nSaadud: {received}", "records_field": "đŸ”„ Rekordid", - "records_val": "Pikim pĂ€evane streak: **{streak}** pĂ€eva", + "records_val": "Pikim pĂ€evane streak: **{streak}** pĂ€eva\n🎁 Õnnekaste avatud: **{lootboxes}**\n🏅 Saavutusi: **{achievements}/{ach_total}**", } # --------------------------------------------------------------------------- From d1cf6cdbd1f86a8909c5eab111e957a52ab95bfc Mon Sep 17 00:00:00 2001 From: Rene Arumetsa Date: Fri, 4 Sep 2026 03:10:42 +0300 Subject: [PATCH 20/21] feat(economy): add /lottery - daily draw, weighted winner takes the pot Buy tickets (200 coins each, max 100/draw); one winner is drawn daily at 21:00 Tallinn time weighted by ticket count and credited the whole pot. Coin-conserving by design: each ticket's cost is deducted at purchase and the winner is minted exactly the sum of all ticket spend - no shared pot record, so no cross-period race. Ticket state lives per-user keyed by draw period (full scan only at draw time and for the pot view). - New lottery.py: TICKET_COST/MAX_TICKETS/DRAW_HOUR, pure period_for, and do_buy_ticket / get_lottery_state / do_lottery_draw. New lottery_tickets + lottery_period schema fields (period added to _TEXT_FIELDS). - /lottery [kogus] command (view or buy) with full failure handling. - Scheduled lottery_draw_daily loop in bot.py (both profiles; each draws its own collection), announcing to the optional LOTTERY_CHANNEL_ID (config + .env). 14 tests: period boundary, buy/accumulate/reset/caps/guards, pot state, draw payout with coin conservation, and the more-tickets-wins-more weighting. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT --- .env.example | 6 ++ README.md | 1 + bot.py | 71 ++++++++++++++ commands/economy_extra_commands.py | 61 ++++++++++++ config.py | 9 ++ core/economy/__init__.py | 5 +- core/economy/lottery.py | 149 +++++++++++++++++++++++++++++ core/economy/store.py | 4 + scripts/sync_pb_schema.py | 2 +- strings/__init__.py | 2 + strings/commands.py | 3 + strings/economy.py | 21 ++++ tests/test_lottery.py | 131 +++++++++++++++++++++++++ 13 files changed, 462 insertions(+), 3 deletions(-) create mode 100644 core/economy/lottery.py create mode 100644 tests/test_lottery.py diff --git a/.env.example b/.env.example index ca588c6..485bd09 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,12 @@ BIRTHDAY_CHANNEL_ID= # How many days before a birthday the on-join check counts as "coming up" BIRTHDAY_WINDOW_DAYS=7 +# Channel ID where the daily lottery draw result is announced (optional - the +# draw still runs and pays the winner if unset; per-profile like BIRTHDAY_CHANNEL) +LOTTERY_CHANNEL_ID_DEV= +LOTTERY_CHANNEL_ID_ECONOMY= +LOTTERY_CHANNEL_ID= + # PocketBase backend (https://pocketbase.io) PB_URL=http://127.0.0.1:8090 PB_ADMIN_EMAIL=admin@example.com diff --git a/README.md b/README.md index 2ecae2b..c82c85c 100644 --- a/README.md +++ b/README.md @@ -348,6 +348,7 @@ The **ECONOMY** role is granted on your first EXP award (i.e. first successful e | `/reminders` | Toggle per-command DM notifications. Bot DMs you the moment each cooldown expires. | | `/quests` | Personal daily (3) and weekly (2) quests with progress bars and a claim button. | | `/achievements` | Milestone badges over your lifetime stats (work/wealth/gambling/crime/fishing/streaks/prestige). Each unlocks once and pays a one-time coin reward; opening the command claims any newly earned. | +| `/lottery [amount]` | View the pot or buy tickets (200 ⏥ each, max 100/draw). One winner is drawn daily at 21:00 Tallinn time and takes the whole pot — more tickets = higher weighted chance. Coins are conserved (the pot equals total ticket spend). Announced in `LOTTERY_CHANNEL_ID` if set. | | `/fish` | Interactive fishing minigame. Cast, wait for bite, pull, then keep or sell. 2min cooldown (90s with Ussipurk). | | `/fishbook` | View your fish collection - all caught species, rarity, count, and current inventory amounts. | | `/fishsell` | Sell all fish currently in your inventory at once. | diff --git a/bot.py b/bot.py index bbaae03..98f0174 100644 --- a/bot.py +++ b/bot.py @@ -336,6 +336,72 @@ async def before_birthday_daily(): await bot.wait_until_ready() +# --------------------------------------------------------------------------- +# Daily lottery draw (Tallinn-time DRAW_HOUR:00) +# --------------------------------------------------------------------------- +async def _resolve_channel(channel_id: int): + """Best-effort fetch of a text channel by id (cache, then API).""" + if not channel_id: + return None + channel = bot.get_channel(channel_id) + if channel is None: + try: + channel = await bot.fetch_channel(channel_id) + except (discord.NotFound, discord.Forbidden, discord.HTTPException): + return None + return channel + + +@tasks.loop(time=datetime.time(hour=economy.lottery.DRAW_HOUR, minute=0, tzinfo=TALLINN_TZ)) +async def lottery_draw_daily(): + """Draw the day's lottery winner and announce it (if a channel is set).""" + period = datetime.datetime.now(TALLINN_TZ).date().isoformat() + try: + result = await economy.do_lottery_draw(period) + except Exception: + log.exception("Lottery draw failed for %s", period) + return + channel = await _resolve_channel(config.LOTTERY_CHANNEL_ID) + if result is None: + log.info("Lottery draw %s: no participants", period) + if channel: + try: + await channel.send(S.LOTTERY_UI["draw_none"]) + except discord.HTTPException: + pass + return + if not result.get("ok"): + log.error("Lottery draw %s could not pay winner %s (pot %s)", + period, result.get("winner_id"), result.get("pot")) + return + log.info("Lottery draw %s: winner %s won %s (%s/%s tickets)", + period, result["winner_id"], result["pot"], + result["winner_tickets"], result["total_tickets"]) + if channel: + mention = f"<@{result['winner_id']}>" + embed = discord.Embed( + title=S.LOTTERY_UI["draw_title"], + description=S.LOTTERY_UI["draw_win"].format( + winner=mention, + pot=_coin(result["pot"]), + tickets=result["winner_tickets"], + total=result["total_tickets"], + chance=round(result["win_chance"] * 100, 1), + players=result["participants"], + ), + color=0xF4C430, + ) + try: + await channel.send(content=mention, embed=embed) + except discord.HTTPException: + pass + + +@lottery_draw_daily.before_loop +async def before_lottery_draw_daily(): + await bot.wait_until_ready() + + # --------------------------------------------------------------------------- # Rotating rich presence # --------------------------------------------------------------------------- @@ -433,6 +499,11 @@ async def on_ready(): birthday_daily.start() log.info("Birthday daily task started (fires 09:00 Tallinn time)") + # Start daily lottery draw (runs in every profile; each has its own collection) + if not lottery_draw_daily.is_running(): + lottery_draw_daily.start() + log.info("Lottery draw task started (fires %02d:00 Tallinn time)", economy.lottery.DRAW_HOUR) + # Start rotating rich presence if not _rotate_presence.is_running(): _rotate_presence.start() diff --git a/commands/economy_extra_commands.py b/commands/economy_extra_commands.py index c846ef4..1101339 100644 --- a/commands/economy_extra_commands.py +++ b/commands/economy_extra_commands.py @@ -5,6 +5,7 @@ import datetime import random import time from collections.abc import Awaitable, Callable, MutableSet +from zoneinfo import ZoneInfo import discord from discord import app_commands @@ -15,6 +16,8 @@ import strings as S from ._replies import reply_db_error +_TALLINN = ZoneInfo("Europe/Tallinn") + def register_economy_extra_commands( tree: app_commands.CommandTree, @@ -554,6 +557,64 @@ def register_economy_extra_commands( ) await interaction.response.send_message(embed=embed) + # ----------------------------------------------------------------------- + # /lottery - daily draw, one weighted winner takes the pot + # ----------------------------------------------------------------------- + @tree.command(name="lottery", description=S.CMD["lottery"]) + @app_commands.describe(kogus=S.OPT["lottery_kogus"]) + async def cmd_lottery(interaction: discord.Interaction, kogus: int | None = None): + period = economy.period_for(datetime.datetime.now(_TALLINN)) + + if kogus is None: + state = await economy.get_lottery_state(period, interaction.user.id) + embed = discord.Embed( + title=S.LOTTERY_UI["title"], + description=S.LOTTERY_UI["desc"].format( + draw_time=f"{economy.DRAW_HOUR}:00", cost=coin(state["ticket_cost"]) + ), + color=0xF4C430, + ) + embed.add_field(name=S.LOTTERY_UI["f_pot"], value=coin(state["pot"]), inline=True) + embed.add_field(name=S.LOTTERY_UI["f_players"], value=str(state["participants"]), inline=True) + if state["your_tickets"]: + chance = round(state["your_tickets"] / state["total_tickets"] * 100, 1) + embed.add_field( + name=S.LOTTERY_UI["f_your"], + value=S.LOTTERY_UI["your_val"].format(tickets=state["your_tickets"], chance=chance), + inline=False, + ) + else: + embed.add_field(name=S.LOTTERY_UI["f_your"], value=S.LOTTERY_UI["your_none"], inline=False) + await interaction.response.send_message(embed=embed) + return + + if kogus <= 0: + await interaction.response.send_message(S.ERR["positive_amount"], ephemeral=True) + return + res = await economy.do_buy_ticket(interaction.user.id, kogus, period) + 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) + elif res["reason"] == "max_tickets": + await interaction.response.send_message( + S.LOTTERY_UI["max_tickets"].format(cap=res["cap"], held=res["held"]), ephemeral=True + ) + elif res["reason"] == "insufficient": + await interaction.response.send_message( + S.ERR["broke_need"].format(need=coin(res["need"])), ephemeral=True + ) + else: + await interaction.response.send_message(S.ERR["invalid_amount"], ephemeral=True) + return + await interaction.response.send_message( + S.LOTTERY_UI["bought"].format( + count=res["bought"], cost=coin(res["cost"]), + tickets=res["tickets"], balance=coin(res["balance"]), + ) + ) + class LeaderboardView(discord.ui.View): PER_PAGE = 10 diff --git a/config.py b/config.py index be14bd8..89a2cb2 100644 --- a/config.py +++ b/config.py @@ -42,6 +42,15 @@ BIRTHDAY_CHANNEL_ID = ( BIRTHDAY_WINDOW_DAYS = int(os.getenv("BIRTHDAY_WINDOW_DAYS", "7")) BASE_ROLE_IDS: list[int] = [1478304631930228779, 1478302278862766190] +# Channel where the daily lottery draw result is announced. Optional - if unset, +# the draw still runs and pays the winner, it just isn't announced. +_LEGACY_LOTTERY_CHANNEL_ID = _env_int("LOTTERY_CHANNEL_ID", 0) +LOTTERY_CHANNEL_ID_DEV = _env_int("LOTTERY_CHANNEL_ID_DEV", _LEGACY_LOTTERY_CHANNEL_ID) +LOTTERY_CHANNEL_ID_ECONOMY = _env_int("LOTTERY_CHANNEL_ID_ECONOMY", 0) +LOTTERY_CHANNEL_ID = ( + LOTTERY_CHANNEL_ID_ECONOMY if BOT_PROFILE == "economy" else LOTTERY_CHANNEL_ID_DEV +) + def _parse_admin_roles(raw: str) -> dict[int, set[int]]: """Parse DISCORD_ADMIN_ROLES env var as "guild_id:role_id[:role_id...],guild_id:role_id...". diff --git a/core/economy/__init__.py b/core/economy/__init__.py index 73f5b71..05275ec 100644 --- a/core/economy/__init__.py +++ b/core/economy/__init__.py @@ -32,10 +32,11 @@ from .prestige import * from .leaderboards import * from .heist import * from .achievements import * +from .lottery import * from .admin import * from . import ( # noqa: E402 (submodules addressable as economy.store etc.) achievements, admin, bank, consumables, fishing, gambling, heist, house, - income, jail, leaderboards, levels, lootbox, prestige, quests, shop, store, - vanity, + income, jail, leaderboards, levels, lootbox, lottery, prestige, quests, + shop, store, vanity, ) diff --git a/core/economy/lottery.py b/core/economy/lottery.py new file mode 100644 index 0000000..84f9213 --- /dev/null +++ b/core/economy/lottery.py @@ -0,0 +1,149 @@ +"""Daily lottery: buy tickets, one weighted winner takes the whole pot. + +Coin flow is conserved without any shared pot record: each ticket's cost is +deducted from the buyer at purchase, and at draw time the winner is credited +exactly the sum of every ticket's cost (tickets * TICKET_COST). More tickets = +higher win chance (weighted draw). Ticket state lives on each user's own record +keyed by the draw period, so a full scan is only needed at draw time and for the +/lottery pot view - never on the hot path. + +The period is a draw-date ISO string computed by the caller (Tallinn-time aware); +core functions take it explicitly so they stay timezone-agnostic and testable. +""" + +from __future__ import annotations + +import random +from datetime import timedelta + +from .. import pb_client +from ..pb_client import DatabaseError +from .store import _commit, _txn, _user_lock, get_user + +__all__ = [ + "TICKET_COST", + "MAX_TICKETS_PER_DRAW", + "DRAW_HOUR", + "period_for", + "do_buy_ticket", + "do_lottery_draw", + "get_lottery_state", +] + +TICKET_COST = 200 +MAX_TICKETS_PER_DRAW = 100 # per-user cap so one whale can't guarantee a win +DRAW_HOUR = 21 # Tallinn-time hour the daily draw fires + + +def period_for(now_local) -> str: + """Draw-date (ISO) that tickets bought at `now_local` (a tz-aware local + datetime) count toward: today before DRAW_HOUR, else tomorrow (today's draw + has already fired). The draw loop itself draws for `now_local.date()`.""" + d = now_local.date() + if now_local.hour >= DRAW_HOUR: + d = d + timedelta(days=1) + return d.isoformat() + + +async def do_buy_ticket(user_id: int, count: int, period: str) -> dict: + """Buy `count` tickets for the draw on `period`. Deducts count*TICKET_COST.""" + if count <= 0: + return {"ok": False, "reason": "invalid"} + async with _user_lock(user_id): + 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"} + # A new period resets any tickets left over from a previous draw. + held = user.get("lottery_tickets", 0) if user.get("lottery_period") == period else 0 + if held + count > MAX_TICKETS_PER_DRAW: + return {"ok": False, "reason": "max_tickets", "held": held, "cap": MAX_TICKETS_PER_DRAW} + cost = count * TICKET_COST + if user["balance"] < cost: + return {"ok": False, "reason": "insufficient", "need": cost - user["balance"]} + user["balance"] -= cost + user["lottery_tickets"] = held + count + user["lottery_period"] = period + try: + await _commit(user_id, user) + except DatabaseError: + return {"ok": False, "reason": "db_error"} + _txn("LOTTERY_BUY", user=user_id, tickets=count, period=period, cost=f"-{cost}", bal=user["balance"]) + return { + "ok": True, + "bought": count, + "tickets": user["lottery_tickets"], + "cost": cost, + "balance": user["balance"], + } + + +def _participants(records: list[dict], period: str) -> list[tuple[str, int]]: + """(user_id, tickets) for everyone holding tickets for `period`.""" + out = [] + for r in records: + uid = r.get("user_id") + if uid and r.get("lottery_period") == period and (r.get("lottery_tickets", 0) or 0) > 0: + out.append((uid, int(r["lottery_tickets"]))) + return out + + +async def get_lottery_state(period: str, user_id: int | None = None) -> dict: + """Pot / participant snapshot for the /lottery view.""" + records = await pb_client.list_all_records() + parts = _participants(records, period) + total_tickets = sum(t for _, t in parts) + your_tickets = 0 + if user_id is not None: + your_tickets = next((t for uid, t in parts if uid == str(user_id)), 0) + return { + "pot": total_tickets * TICKET_COST, + "total_tickets": total_tickets, + "participants": len(parts), + "your_tickets": your_tickets, + "ticket_cost": TICKET_COST, + } + + +async def do_lottery_draw(period: str) -> dict | None: + """Draw the winner for `period` and credit them the whole pot (minted, since + ticket costs were burned at purchase - net conserved). Returns the result, or + None if nobody entered.""" + records = await pb_client.list_all_records() + parts = _participants(records, period) + if not parts: + return None + total_tickets = sum(t for _, t in parts) + pot = total_tickets * TICKET_COST + winner_id = int(random.choices( + [uid for uid, _ in parts], weights=[t for _, t in parts], k=1 + )[0]) + winner_tickets = next(t for uid, t in parts if uid == str(winner_id)) + + async with _user_lock(winner_id): + try: + winner = await get_user(winner_id) + except DatabaseError: + return {"ok": False, "reason": "db_error", "winner_id": winner_id, "pot": pot} + winner["balance"] += pot + winner["lifetime_earned"] = winner.get("lifetime_earned", 0) + pot + winner["biggest_win"] = max(winner.get("biggest_win", 0), pot) + winner["peak_balance"] = max(winner.get("peak_balance", 0), winner["balance"]) + winner["lottery_tickets"] = 0 # consumed + try: + await _commit(winner_id, winner) + except DatabaseError: + return {"ok": False, "reason": "db_error", "winner_id": winner_id, "pot": pot} + _txn("LOTTERY_DRAW", winner=winner_id, period=period, pot=f"+{pot}", + tickets=winner_tickets, total_tickets=total_tickets, players=len(parts)) + return { + "ok": True, + "winner_id": winner_id, + "pot": pot, + "winner_tickets": winner_tickets, + "total_tickets": total_tickets, + "participants": len(parts), + "win_chance": winner_tickets / total_tickets, + } diff --git a/core/economy/store.py b/core/economy/store.py index be07226..689f561 100644 --- a/core/economy/store.py +++ b/core/economy/store.py @@ -155,6 +155,8 @@ class UserData(TypedDict, total=False): best_daily_streak: int lootboxes_opened: int achievements_earned: list # ids of achievements already claimed + lottery_tickets: int # tickets held for the current lottery period + lottery_period: str | None # draw-date the held tickets count for (ISO date) heist_global_cd_until: float # Prestige system prestige_level: int @@ -219,6 +221,8 @@ def _default_user() -> UserData: "best_daily_streak": 0, "lootboxes_opened": 0, "achievements_earned": [], + "lottery_tickets": 0, + "lottery_period": None, "heist_global_cd_until": 0.0, # ── Prestige ───────────────────────────────────────────────────────── "prestige_level": 0, diff --git a/scripts/sync_pb_schema.py b/scripts/sync_pb_schema.py index 6e31cdf..e4c2c14 100644 --- a/scripts/sync_pb_schema.py +++ b/scripts/sync_pb_schema.py @@ -39,7 +39,7 @@ COLLECTIONS = sorted({config.PB_ECONOMY_COLLECTION_DEV, config.PB_ECONOMY_COLLEC _TEXT_FIELDS = { "last_daily", "last_work", "last_beg", "last_crime", "last_rob", "last_heist", "last_fish", "last_streak_date", "jailed_until", - "vanity_active", + "vanity_active", "lottery_period", } diff --git a/strings/__init__.py b/strings/__init__.py index 1498f26..0315bb4 100644 --- a/strings/__init__.py +++ b/strings/__init__.py @@ -62,6 +62,7 @@ from .economy import ( LOOTBOX_UI, BANK_UI, ACHIEVEMENTS_UI, + LOTTERY_UI, JAILED_UI, SHOP_BTN, DAILY_UI, @@ -158,6 +159,7 @@ __all__ = [ 'LOOTBOX_UI', 'BANK_UI', 'ACHIEVEMENTS_UI', + 'LOTTERY_UI', 'JAILED_UI', 'SHOP_BTN', 'DAILY_UI', diff --git a/strings/commands.py b/strings/commands.py index ea4a04e..83b68c5 100644 --- a/strings/commands.py +++ b/strings/commands.py @@ -80,6 +80,7 @@ CMD: dict[str, str] = { "bank": "Vaata oma panka - röövikindel hoius", "deposit": "Pane mĂŒnte panka (röövikindel, aga ei teeni intressi)", "withdraw": "VĂ”ta mĂŒnte pangast rahakotti", + "lottery": "Vaata TipiLOTO potti vĂ”i osta pileteid (tĂŒhjaks jĂ€ttes nĂ€eb infot)", } # --------------------------------------------------------------------------- @@ -99,6 +100,7 @@ OPT: dict[str, str] = { "buy_ese": "Eseme nimi (vaata /shop)", "deposit_summa": "Kui palju panna panka? ('all' = kogu vaba raha)", "withdraw_summa": "Kui palju pangast vĂ€lja vĂ”tta? ('all' = kogu pangas)", + "lottery_kogus": "Mitu piletit osta (tĂŒhjaks jĂ€ttes nĂ€eb potti)", "consumable_ese": "Turgutus, mida osta (tĂŒhjaks jĂ€ttes nĂ€eb menĂŒĂŒd)", "vanity_ese": "Tiitel, mida osta vĂ”i kanda (tĂŒhjaks jĂ€ttes nĂ€eb poodi)", "rps_panus": "Valikuline TipiCOINide panus ('all' = kogu saldo)", @@ -161,6 +163,7 @@ HELP_CATEGORIES: dict[str, dict] = { ("/withdraw ", "VĂ”ta mĂŒnte pangast rahakotti."), ("/quests", "Vaata oma pĂ€eva- ja nĂ€dalaĂŒlesandeid ning nĂ”ua auhinnad (uueneb iga pĂ€ev/nĂ€dal)."), ("/achievements", "Vaata oma saavutusi. Iga lukust lahti saanud mĂ€rk annab ĂŒhekordse mĂŒntipreemia."), + ("/lottery [kogus]", "Vaata TipiLOTO potti vĂ”i osta pileteid. Loosimine iga pĂ€ev - ĂŒks vĂ”itja saab kogu poti (rohkem pileteid = suurem vĂ”iduvĂ”imalus)."), ("/leaderboard", "TipiBOTi edetabel - kes on kĂ”ige rikkam?"), ("/shop", "Sirvi TipiBOTi poodi"), ("/buy ", "Osta ese TipiBOTi poodist"), diff --git a/strings/economy.py b/strings/economy.py index 2f21233..14f5e7a 100644 --- a/strings/economy.py +++ b/strings/economy.py @@ -21,6 +21,7 @@ __all__ = [ 'LOOTBOX_UI', 'BANK_UI', 'ACHIEVEMENTS_UI', + 'LOTTERY_UI', 'JAILED_UI', 'SHOP_BTN', 'DAILY_UI', @@ -302,6 +303,26 @@ ACHIEVEMENTS_UI: dict[str, str] = { "unlocked_note": "🎉 **Uued saavutused avatud:** {names}\n💰 Preemia: +{reward}", } +# --------------------------------------------------------------------------- +# Lottery (daily draw; one weighted winner takes the pot) +# --------------------------------------------------------------------------- +LOTTERY_UI: dict[str, str] = { + "title": "đŸŽŸïž TipiLOTO", + "desc": "Osta pileteid ja vĂ”ida kogu pott! Loosimine iga pĂ€ev **{draw_time}**. Iga pilet = **{cost}**, rohkem pileteid = suurem vĂ”iduvĂ”imalus.", + "f_pot": "💰 Praegune pott", + "f_players": "đŸ‘„ Osalejaid", + "f_your": "đŸŽŸïž Sinu piletid", + "your_val": "{tickets} tk · vĂ”iduvĂ”imalus **{chance}%**", + "your_none": "Sul pole veel pileteid. Osta kĂ€suga `/lottery `.", + "bought": "đŸŽŸïž Ostsid **{count}** piletit ({cost}).\nSul on nĂŒĂŒd **{tickets}** piletit selle pĂ€eva loosimises.\nSaldo: {balance}", + "max_tickets": "❌ Maksimaalne piletite arv ĂŒhes loosimises on {cap} (sul on {held}).", + "empty_pot": "đŸŽŸïž Pott on tĂŒhi - ole esimene, kes piletit ostab!", + # Draw announcement + "draw_title": "đŸŽŸïž TipiLOTO loosimine!", + "draw_win": "🎉 VĂ”itja: {winner}\n💰 VĂ”it: **{pot}**\nđŸŽŸïž {tickets}/{total} piletit ({chance}%)\nđŸ‘„ {players} osalejat", + "draw_none": "đŸŽŸïž TĂ€na keegi pileteid ei ostnud - loosimist ei toimunud.", +} + JAILED_UI: dict[str, str] = { "title": "🔒 Praegu vanglas", "empty": "KĂ”ik on vabad! Vanglas pole kedagi.", diff --git a/tests/test_lottery.py b/tests/test_lottery.py new file mode 100644 index 0000000..535e8a6 --- /dev/null +++ b/tests/test_lottery.py @@ -0,0 +1,131 @@ +"""Tests for the daily lottery (buy tickets, weighted winner takes the pot).""" + +import datetime +import random +from zoneinfo import ZoneInfo + +from core import economy + +from conftest import run + +TZ = ZoneInfo("Europe/Tallinn") +P = "2026-09-04" # a fixed draw period +UID = 3131 +UID2 = 3132 +UID3 = 3133 + + +def _fund(fake_pb, uid: int, balance: int) -> None: + run(economy.get_user(uid)) + fake_pb.record_for(uid)["balance"] = balance + + +class TestPeriod: + def test_before_draw_hour_is_today(self): + dt = datetime.datetime(2026, 9, 4, 20, 0, tzinfo=TZ) + assert economy.period_for(dt) == "2026-09-04" + + def test_at_or_after_draw_hour_is_tomorrow(self): + dt = datetime.datetime(2026, 9, 4, economy.DRAW_HOUR, 0, tzinfo=TZ) + assert economy.period_for(dt) == "2026-09-05" + + +class TestBuy: + def test_buy_deducts_and_records_tickets(self, fake_pb): + _fund(fake_pb, UID, 10_000) + res = run(economy.do_buy_ticket(UID, 3, P)) + assert res["ok"] and res["tickets"] == 3 + assert res["cost"] == 3 * economy.TICKET_COST + assert res["balance"] == 10_000 - 3 * economy.TICKET_COST + rec = fake_pb.record_for(UID) + assert rec["lottery_tickets"] == 3 and rec["lottery_period"] == P + + def test_buy_accumulates_same_period(self, fake_pb): + _fund(fake_pb, UID, 10_000) + run(economy.do_buy_ticket(UID, 2, P)) + res = run(economy.do_buy_ticket(UID, 3, P)) + assert res["tickets"] == 5 + + def test_new_period_resets_tickets(self, fake_pb): + _fund(fake_pb, UID, 10_000) + run(economy.do_buy_ticket(UID, 5, P)) + res = run(economy.do_buy_ticket(UID, 1, "2026-09-05")) + assert res["tickets"] == 1 # old period's tickets dropped + + def test_insufficient_rejected(self, fake_pb): + _fund(fake_pb, UID, 100) + res = run(economy.do_buy_ticket(UID, 1, P)) + assert not res["ok"] and res["reason"] == "insufficient" + assert fake_pb.record_for(UID)["balance"] == 100 + + def test_max_tickets_enforced(self, fake_pb): + _fund(fake_pb, UID, 10_000_000) + res = run(economy.do_buy_ticket(UID, economy.MAX_TICKETS_PER_DRAW + 1, P)) + assert not res["ok"] and res["reason"] == "max_tickets" + + def test_nonpositive_rejected(self, fake_pb): + _fund(fake_pb, UID, 10_000) + assert run(economy.do_buy_ticket(UID, 0, P))["reason"] == "invalid" + + def test_banned_rejected(self, fake_pb): + _fund(fake_pb, UID, 10_000) + fake_pb.record_for(UID)["eco_banned"] = True + assert run(economy.do_buy_ticket(UID, 1, P))["reason"] == "banned" + + +class TestState: + def test_pot_and_your_tickets(self, fake_pb): + _fund(fake_pb, UID, 10_000) + _fund(fake_pb, UID2, 10_000) + run(economy.do_buy_ticket(UID, 3, P)) + run(economy.do_buy_ticket(UID2, 2, P)) + state = run(economy.get_lottery_state(P, UID)) + assert state["total_tickets"] == 5 + assert state["pot"] == 5 * economy.TICKET_COST + assert state["participants"] == 2 + assert state["your_tickets"] == 3 + + def test_other_period_not_counted(self, fake_pb): + _fund(fake_pb, UID, 10_000) + run(economy.do_buy_ticket(UID, 3, "2026-01-01")) + state = run(economy.get_lottery_state(P)) + assert state["total_tickets"] == 0 and state["pot"] == 0 + + +class TestDraw: + def test_no_participants_returns_none(self, fake_pb): + assert run(economy.do_lottery_draw(P)) is None + + def test_winner_gets_whole_pot_and_coins_conserved(self, fake_pb): + _fund(fake_pb, UID, 10_000) + _fund(fake_pb, UID2, 10_000) + run(economy.do_buy_ticket(UID, 3, P)) # -600 + run(economy.do_buy_ticket(UID2, 2, P)) # -400 + pot = 5 * economy.TICKET_COST + total_before = sum(fake_pb.record_for(u)["balance"] for u in (UID, UID2)) + + random.seed(1) + res = run(economy.do_lottery_draw(P)) + assert res["ok"] and res["pot"] == pot + winner, loser = (UID, UID2) if res["winner_id"] == UID else (UID2, UID) + assert fake_pb.record_for(winner)["balance"] == ( + (10_000 - (3 if winner == UID else 2) * economy.TICKET_COST) + pot + ) + # Coins conserved: the pot minted to the winner equals total ticket spend. + total_after = sum(fake_pb.record_for(u)["balance"] for u in (UID, UID2)) + assert total_after == total_before + pot + assert fake_pb.record_for(res["winner_id"])["lottery_tickets"] == 0 # consumed + + def test_more_tickets_wins_more_often(self, fake_pb): + _fund(fake_pb, UID, 10_000_000) + _fund(fake_pb, UID2, 10_000_000) + wins = {UID: 0, UID2: 0} + for seed in range(200): + # reset tickets each round to the same split + fake_pb.record_for(UID).update(lottery_tickets=9, lottery_period=P) + fake_pb.record_for(UID2).update(lottery_tickets=1, lottery_period=P) + random.seed(seed) + res = run(economy.do_lottery_draw(P)) + wins[res["winner_id"]] += 1 + # UID holds 90% of tickets -> should win far more often. + assert wins[UID] > wins[UID2] * 3 From 837ad32757101622e7ceeca89815ccdda6ca019e Mon Sep 17 00:00:00 2001 From: Rene Arumetsa Date: Fri, 4 Sep 2026 03:11:55 +0300 Subject: [PATCH 21/21] docs(dev-notes): list new economy submodules and commands Reflects bank/lootbox/achievements/lottery/consumables/vanity modules, the pending-wager escrow + reconcile, effective_cooldown/ITEM_COOLDOWNS, and the net-worth leaderboard/economy-stats helpers. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT --- docs/DEV_NOTES.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/DEV_NOTES.md b/docs/DEV_NOTES.md index af08b3b..7f3eb39 100644 --- a/docs/DEV_NOTES.md +++ b/docs/DEV_NOTES.md @@ -16,7 +16,7 @@ The codebase is split into **`core/`** (domain logic), **`commands/`** (Discord | File | Purpose | |---|---| -| `core/economy/` | Economy business logic **package**, re-exported via `core/economy/__init__.py` so callers use `from core import economy` + attribute access (`economy.do_daily`, `economy.SHOP`, ...). Submodules: `store.py` (user records, per-user locks, `COOLDOWNS`, `JAIL_DURATION`, `COIN`, `get_user`/`_commit`/`_txn`), `income.py`, `gambling.py`, `fishing.py`, `jail.py`, `heist.py`, `prestige.py`, `shop.py`, `levels.py`, `quests.py`, `leaderboards.py`, `house.py`, `admin.py` | +| `core/economy/` | Economy business logic **package**, re-exported via `core/economy/__init__.py` so callers use `from core import economy` + attribute access (`economy.do_daily`, `economy.SHOP`, ...). Submodules: `store.py` (user records, per-user locks, `COOLDOWNS`, `ITEM_COOLDOWNS`/`effective_cooldown`, `JAIL_DURATION`, `COIN`, `get_user`/`_commit`/`_txn`, `pending_wager` escrow + `reconcile_pending_wagers`), `income.py`, `gambling.py`, `fishing.py`, `jail.py`, `heist.py`, `prestige.py`, `shop.py`, `consumables.py` (timed buffs + `grant_buff`), `vanity.py` (cosmetic badges), `lootbox.py` (mystery box), `bank.py` (rob-proof vault), `achievements.py` (milestone badges), `lottery.py` (daily draw), `levels.py`, `quests.py`, `leaderboards.py` (incl. `get_all_leaderboards`/`get_economy_stats`, net-worth = balance+bank), `house.py`, `admin.py` | | `core/pb_client.py` | Async PocketBase REST client - auth token cache, CRUD on `economy_users` collection | | `core/sheets.py` | Google Sheets integration (member sync) | | `core/member_sync.py` | Birthday/member sync helpers | @@ -31,10 +31,10 @@ Each file exposes a `register__commands(tree, bot, ...)` function. `bot.p | `commands/dev_member_runtime.py` | `on_member_join` flow + `birthday_daily` task body | | `commands/economy_income_commands.py` | `/daily`, `/work`, `/beg`, `/crime`, `/rob` | | `commands/economy_games_commands.py` | `/roulette`, `/slots`, `/blackjack`, `/rps` | -| `commands/economy_extra_commands.py` | `/heist`, `/jailbreak`, `/reminders`, `/request`, ... | +| `commands/economy_extra_commands.py` | `/heist`, `/jailbreak`, `/reminders`, `/request`, `/give`, `/lottery`, `/leaderboard`, ... | | `commands/economy_fish_commands.py` | `/fish`, `/fishbook`, `/fishsell` | -| `commands/economy_profile_commands.py` | `/balance`, `/rank`, `/stats`, `/cooldowns`, `/leaderboard` | -| `commands/economy_support_commands.py` | `/shop`, `/buy`, `/give`, `/economysetup` | +| `commands/economy_profile_commands.py` | `/balance`, `/rank`, `/stats`, `/cooldowns`, `/achievements` | +| `commands/economy_support_commands.py` | `/shop`, `/buy`, `/economysetup`, `/consumables`, `/vanity`, `/lootbox`, `/bank`, `/deposit`, `/withdraw` | | `commands/economy_prestige_commands.py` | `/prestige`, `/prestigeshop`, `/prestigebuy` | | `commands/economy_admin_commands.py` | `/admincoins`, `/adminexp`, `/adminitem`, `/adminjail`, `/adminban`, `/adminreset`, `/adminview` | | `commands/ops_admin_commands.py` | `/sync`, `/restart`, `/shutdown`, `/pause`, `/send`, `/status` |