From cb18d9b8829007e9a61c1b94ec8b37edeb993d45 Mon Sep 17 00:00:00 2001 From: Rene Arumetsa Date: Sun, 26 Jul 2026 20:11:14 +0300 Subject: [PATCH] Added quests --- bot.py | 8 ++ commands/economy_quests_commands.py | 108 ++++++++++++++++++++ core/economy.py | 147 ++++++++++++++++++++++++++++ docs/PATCHNOTES.md | 4 + scripts/add_quest_fields.py | 96 ++++++++++++++++++ strings.py | 40 ++++++++ 6 files changed, 403 insertions(+) create mode 100644 commands/economy_quests_commands.py create mode 100644 scripts/add_quest_fields.py diff --git a/bot.py b/bot.py index a699cc2..29158d3 100644 --- a/bot.py +++ b/bot.py @@ -32,6 +32,7 @@ from commands.economy_fish_commands import register_economy_fish_commands from commands.economy_games_commands import register_economy_games_commands from commands.economy_income_commands import register_economy_income_commands from commands.economy_prestige_commands import register_prestige_commands +from commands.economy_quests_commands import register_economy_quests_commands from commands.economy_profile_commands import register_economy_profile_commands from commands.economy_support_commands import register_economy_support_commands from commands.ops_channel_commands import register_ops_channel_commands @@ -815,6 +816,13 @@ register_economy_fish_commands( active_games=_active_games, ) +register_economy_quests_commands( + tree, + bot, + coin=_coin, + award_exp=_award_exp, +) + register_economy_games_commands( tree, coin=_coin, diff --git a/commands/economy_quests_commands.py b/commands/economy_quests_commands.py new file mode 100644 index 0000000..af1ed1c --- /dev/null +++ b/commands/economy_quests_commands.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import asyncio +import datetime +from collections.abc import Awaitable, Callable + +import discord +from discord import app_commands + +from core import economy +import strings as S + + +_BAR_SLOTS = 8 + + +def _bar(progress: int, goal: int) -> str: + """Return an 8-slot ▰/▱ progress bar for a quest.""" + filled = 0 if goal <= 0 else min(_BAR_SLOTS, round(_BAR_SLOTS * progress / goal)) + return "▰" * filled + "▱" * (_BAR_SLOTS - filled) + + +def _has_claimable(view_data: dict) -> bool: + return any( + q["done"] and not q["claimed"] + for q in view_data["daily"] + view_data["weekly"] + ) + + +def register_economy_quests_commands( + tree: app_commands.CommandTree, + bot: discord.Client, + coin: Callable[[int], str], + award_exp: Callable[[discord.Interaction, int], Awaitable[None]], +) -> None: + def _render(view_data: dict) -> discord.Embed: + embed = discord.Embed(title=S.TITLE["quests"], color=0xF4C430) + sections = ( + (S.QUEST_UI["daily_header"], view_data["daily"]), + (S.QUEST_UI["weekly_header"], view_data["weekly"]), + ) + for header, quests in sections: + if quests: + blocks = [] + for q in quests: + desc = S.QUEST_DESCRIPTIONS.get(q["id"], q["id"]) + if q["claimed"]: + status = S.QUEST_UI["completed"] + elif q["done"]: + status = S.QUEST_UI["ready"] + else: + status = S.QUEST_UI["progress"].format(progress=q["progress"], max=q["goal"]) + reward = S.QUEST_UI["reward"].format(coins=f"{q['coins']:,}", exp=q["exp"]) + blocks.append( + f"{_bar(q['progress'], q['goal'])} **{desc}**\n{status} · {reward}" + ) + value = "\n\n".join(blocks) + else: + value = S.QUEST_UI["empty"] + embed.add_field(name=header, value=value, inline=False) + return embed + + class QuestView(discord.ui.View): + def __init__(self, invoker_id: int, view_data: dict): + super().__init__(timeout=180) + self.invoker_id = invoker_id + btn = discord.ui.Button( + label=S.QUEST_UI["claim_btn"], + style=discord.ButtonStyle.success, + disabled=not _has_claimable(view_data), + ) + btn.callback = self._claim + self.add_item(btn) + + async def _claim(self, interaction: discord.Interaction): + if interaction.user.id != self.invoker_id: + await interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True) + return + try: + res = await economy.claim_quests(self.invoker_id) + except economy.DatabaseError: + await interaction.response.send_message(S.QUEST_UI["error"], ephemeral=True) + return + if not res["ok"]: + await interaction.response.send_message(S.QUEST_UI["nothing"], ephemeral=True) + return + new_data = await economy.get_quests(self.invoker_id) + await interaction.response.edit_message( + embed=_render(new_data), view=QuestView(self.invoker_id, new_data) + ) + await interaction.followup.send( + S.QUEST_UI["claimed_msg"].format( + count=res["claimed"], coins=coin(res["coins"]), exp=res["exp"] + ), + ephemeral=True, + ) + if res["exp"]: + asyncio.create_task(award_exp(interaction, res["exp"])) + + @tree.command(name="quests", description=S.CMD["quests"]) + async def cmd_quests(interaction: discord.Interaction): + await interaction.response.defer() + try: + data = await economy.get_quests(interaction.user.id) + except economy.DatabaseError: + await interaction.followup.send(S.QUEST_UI["error"], ephemeral=True) + return + await interaction.followup.send(embed=_render(data), view=QuestView(interaction.user.id, data)) diff --git a/core/economy.py b/core/economy.py index d85fa6a..6d36fb6 100644 --- a/core/economy.py +++ b/core/economy.py @@ -401,6 +401,9 @@ class UserData(TypedDict, total=False): fish_book: dict # {fish_id: times_caught} total_fish_caught: int fish_inventory: list # [{fish_id, weight, value}] - survives prestige + # Quest system + quest_daily: dict # {"date": "YYYY-MM-DD", "quests": {qid: {snap, claimed}}} + quest_weekly: dict # {"week": "YYYY-Www", "quests": {qid: {snap, claimed}}} def _default_user() -> UserData: @@ -451,6 +454,9 @@ def _default_user() -> UserData: "fish_book": {}, "total_fish_caught": 0, "fish_inventory": [], + # ── Quests ─────────────────────────────────────────────────────────── + "quest_daily": {}, + "quest_weekly": {}, } @@ -700,6 +706,147 @@ async def _commit(user_id: int, user: UserData) -> None: raise DatabaseError(f"Failed to persist user {user_id}: {exc}") from exc +# --------------------------------------------------------------------------- +# Quest system +# --------------------------------------------------------------------------- +# Quests reuse the monotonic lifetime counters already tracked on each user. +# Progress = (current counter value) - (snapshot taken when the quest was rolled). +# Reset is lazy & per-user: the active set is regenerated the first time a user +# interacts after the day / ISO-week rolls over (mirrors the streak-date logic). +# Rotation is seeded by the period key, so every player gets the same set. +class QuestDef(TypedDict): + stat: str # UserData counter field the quest tracks + goal: int + coins: int + exp: int + + +QUESTS_DAILY: dict[str, QuestDef] = { + "work3": {"stat": "work_count", "goal": 3, "coins": 150, "exp": 20}, + "beg5": {"stat": "beg_count", "goal": 5, "coins": 100, "exp": 15}, + "wager500": {"stat": "total_wagered", "goal": 500, "coins": 150, "exp": 20}, + "fish2": {"stat": "total_fish_caught", "goal": 2, "coins": 150, "exp": 20}, + "crime1": {"stat": "crimes_succeeded", "goal": 1, "coins": 200, "exp": 25}, + "earn1000": {"stat": "lifetime_earned", "goal": 1000, "coins": 150, "exp": 20}, + "give200": {"stat": "total_given", "goal": 200, "coins": 100, "exp": 15}, +} + +QUESTS_WEEKLY: dict[str, QuestDef] = { + "work20": {"stat": "work_count", "goal": 20, "coins": 1000, "exp": 100}, + "fish15": {"stat": "total_fish_caught", "goal": 15, "coins": 1200, "exp": 100}, + "wager5000": {"stat": "total_wagered", "goal": 5000, "coins": 1000, "exp": 100}, + "crime5": {"stat": "crimes_succeeded", "goal": 5, "coins": 1200, "exp": 120}, + "heist1": {"stat": "heists_joined", "goal": 1, "coins": 800, "exp": 80}, + "earn10000": {"stat": "lifetime_earned", "goal": 10000, "coins": 1500, "exp": 150}, +} + +DAILY_QUEST_COUNT = 3 +WEEKLY_QUEST_COUNT = 2 + + +def _period_keys() -> tuple[str, str]: + """Return (day_key, week_key) for the current UTC time.""" + today = _now().date() + iso = today.isocalendar() + return today.isoformat(), f"{iso[0]}-W{iso[1]:02d}" + + +def _pick_quests(pool: dict[str, QuestDef], count: int, seed: str) -> list[str]: + """Deterministically choose `count` quest ids from `pool` for a period.""" + rng = random.Random(seed) + return rng.sample(sorted(pool.keys()), min(count, len(pool))) + + +def _new_quest_block( + user: UserData, pool: dict[str, QuestDef], count: int, period_val: str, period_field: str +) -> dict: + chosen = _pick_quests(pool, count, f"{period_field}:{period_val}") + return { + period_field: period_val, + "quests": { + qid: {"snap": int(user.get(pool[qid]["stat"], 0) or 0), "claimed": False} + for qid in chosen + }, + } + + +def _ensure_quests(user: UserData) -> bool: + """Roll fresh daily/weekly quest sets if their period elapsed. + Mutates `user` in place; returns True if anything changed (caller commits).""" + changed = False + day_key, week_key = _period_keys() + if (user.get("quest_daily") or {}).get("date") != day_key: + user["quest_daily"] = _new_quest_block(user, QUESTS_DAILY, DAILY_QUEST_COUNT, day_key, "date") + changed = True + if (user.get("quest_weekly") or {}).get("week") != week_key: + user["quest_weekly"] = _new_quest_block(user, QUESTS_WEEKLY, WEEKLY_QUEST_COUNT, week_key, "week") + changed = True + return changed + + +def _quest_progress(user: UserData, pool: dict[str, QuestDef], qid: str, state: dict) -> int: + cur = int(user.get(pool[qid]["stat"], 0) or 0) + return max(0, cur - int(state.get("snap", 0))) + + +def _quest_view(user: UserData) -> dict: + def build(pool: dict[str, QuestDef], block: dict) -> list[dict]: + out: list[dict] = [] + for qid, state in (block.get("quests") or {}).items(): + if qid not in pool: + continue + d = pool[qid] + prog = min(d["goal"], _quest_progress(user, pool, qid, state)) + out.append({ + "id": qid, "goal": d["goal"], "coins": d["coins"], "exp": d["exp"], + "progress": prog, "done": prog >= d["goal"], "claimed": bool(state.get("claimed")), + }) + return out + return { + "daily": build(QUESTS_DAILY, user.get("quest_daily") or {}), + "weekly": build(QUESTS_WEEKLY, user.get("quest_weekly") or {}), + } + + +async def get_quests(user_id: int) -> dict: + """Return the user's active quests, rolling new sets if the period elapsed.""" + user = await get_user(user_id) + if _ensure_quests(user): + await _commit(user_id, user) + return _quest_view(user) + + +async def claim_quests(user_id: int) -> dict: + """Grant coins for every completed-but-unclaimed quest and mark them claimed. + Coins (with prestige coin_mult) are paid here; EXP is returned raw for the + caller to award via the shared award_exp path (keeps level-up notices).""" + user = await get_user(user_id) + _ensure_quests(user) + coin_mult, _ = _prestige_mult(user) + total_coins = total_exp = claimed = 0 + for pool, block in ( + (QUESTS_DAILY, user.get("quest_daily") or {}), + (QUESTS_WEEKLY, user.get("quest_weekly") or {}), + ): + for qid, state in (block.get("quests") or {}).items(): + if qid not in pool or state.get("claimed"): + continue + if _quest_progress(user, pool, qid, state) < pool[qid]["goal"]: + continue + total_coins += pool[qid]["coins"] + total_exp += pool[qid]["exp"] + state["claimed"] = True + claimed += 1 + if not claimed: + return {"ok": False, "reason": "nothing"} + coins_awarded = int(total_coins * coin_mult) + user["balance"] += coins_awarded + user["lifetime_earned"] = user.get("lifetime_earned", 0) + coins_awarded + user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"]) + await _commit(user_id, user) + return {"ok": True, "claimed": claimed, "coins": coins_awarded, "exp": total_exp, "balance": user["balance"]} + + # --------------------------------------------------------------------------- # /daily # --------------------------------------------------------------------------- diff --git a/docs/PATCHNOTES.md b/docs/PATCHNOTES.md index 7f85ae0..7cd8c9c 100644 --- a/docs/PATCHNOTES.md +++ b/docs/PATCHNOTES.md @@ -3,6 +3,10 @@ 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.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; the active set is the same for everyone and rotates over time. Complete objectives like working, fishing, wagering, or pulling off crimes to earn TipiCOIN and EXP. + ## v0.1.0 — 2026-05-03 - Added `/patchnotes` diff --git a/scripts/add_quest_fields.py b/scripts/add_quest_fields.py new file mode 100644 index 0000000..20189e5 --- /dev/null +++ b/scripts/add_quest_fields.py @@ -0,0 +1,96 @@ +"""Add the quest-system JSON fields to the economy_users PocketBase collection. + +Run once after pulling the quest changes: + python scripts/add_quest_fields.py + +Requirements: + - PocketBase running and reachable at PB_URL + - PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD set in .env +""" + +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path + +import aiohttp +from dotenv import load_dotenv + +sys.path.insert(0, str(Path(__file__).parent.parent)) +load_dotenv() + +import config # noqa: E402 + +PB_URL = config.PB_URL +PB_ADMIN_EMAIL = config.PB_ADMIN_EMAIL +PB_ADMIN_PASSWORD = config.PB_ADMIN_PASSWORD +COLLECTION = config.PB_ECONOMY_COLLECTION + +# --------------------------------------------------------------------------- +# New fields to add +# --------------------------------------------------------------------------- +_NEW_JSON_FIELDS = [ + "quest_daily", + "quest_weekly", +] + + +def _json_field(name: str) -> dict: + return {"name": name, "type": "json", "required": False} + + +async def main() -> None: + timeout = aiohttp.ClientTimeout(total=15) + async with aiohttp.ClientSession(timeout=timeout) as session: + # ── Authenticate ──────────────────────────────────────────────────── + async with session.post( + f"{PB_URL}/api/collections/_superusers/auth-with-password", + json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD}, + ) as resp: + if resp.status != 200: + print(f"Auth failed ({resp.status}): {await resp.text()}") + return + token = (await resp.json())["token"] + + hdrs = {"Authorization": token} + + # ── Fetch current collection ───────────────────────────────────────── + async with session.get( + f"{PB_URL}/api/collections/{COLLECTION}", headers=hdrs + ) as resp: + if resp.status != 200: + print(f"Could not fetch collection ({resp.status}): {await resp.text()}") + return + col = await resp.json() + + existing = {f["name"] for f in col.get("fields", [])} + print(f"Existing fields ({len(existing)}): {sorted(existing)}\n") + + new_fields = [] + for name in _NEW_JSON_FIELDS: + if name not in existing: + new_fields.append(_json_field(name)) + print(f" + {name} (json)") + else: + print(f" = {name} (already exists)") + + if not new_fields: + print("\nNothing to add - schema already up to date.") + return + + # ── Patch collection schema ────────────────────────────────────────── + updated_fields = col.get("fields", []) + new_fields + async with session.patch( + f"{PB_URL}/api/collections/{COLLECTION}", + json={"fields": updated_fields}, + headers=hdrs, + ) as resp: + if resp.status != 200: + print(f"\nSchema update failed ({resp.status}): {await resp.text()}") + return + print(f"\n✅ Added {len(new_fields)} field(s) successfully.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/strings.py b/strings.py index 7de447b..9033543 100644 --- a/strings.py +++ b/strings.py @@ -174,6 +174,7 @@ CMD: dict[str, str] = { "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", } # --------------------------------------------------------------------------- @@ -246,6 +247,7 @@ 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"), + ("/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"), @@ -588,6 +590,44 @@ TITLE: dict[str, str] = { "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 ⬡", } # ---------------------------------------------------------------------------