Added quests

This commit is contained in:
Rene Arumetsa
2026-07-26 20:11:14 +03:00
parent 0cdd8dac63
commit cb18d9b882
6 changed files with 403 additions and 0 deletions

8
bot.py
View File

@@ -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_games_commands import register_economy_games_commands
from commands.economy_income_commands import register_economy_income_commands from commands.economy_income_commands import register_economy_income_commands
from commands.economy_prestige_commands import register_prestige_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_profile_commands import register_economy_profile_commands
from commands.economy_support_commands import register_economy_support_commands from commands.economy_support_commands import register_economy_support_commands
from commands.ops_channel_commands import register_ops_channel_commands from commands.ops_channel_commands import register_ops_channel_commands
@@ -815,6 +816,13 @@ register_economy_fish_commands(
active_games=_active_games, active_games=_active_games,
) )
register_economy_quests_commands(
tree,
bot,
coin=_coin,
award_exp=_award_exp,
)
register_economy_games_commands( register_economy_games_commands(
tree, tree,
coin=_coin, coin=_coin,

View File

@@ -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))

View File

@@ -401,6 +401,9 @@ class UserData(TypedDict, total=False):
fish_book: dict # {fish_id: times_caught} fish_book: dict # {fish_id: times_caught}
total_fish_caught: int total_fish_caught: int
fish_inventory: list # [{fish_id, weight, value}] - survives prestige 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: def _default_user() -> UserData:
@@ -451,6 +454,9 @@ def _default_user() -> UserData:
"fish_book": {}, "fish_book": {},
"total_fish_caught": 0, "total_fish_caught": 0,
"fish_inventory": [], "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 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 # /daily
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -3,6 +3,10 @@
Here you'll find an overview of TipiBOT updates. Latest changes are at the top. 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`). 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 ## v0.1.0 — 2026-05-03
- Added `/patchnotes` - Added `/patchnotes`

View File

@@ -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())

View File

@@ -174,6 +174,7 @@ CMD: dict[str, str] = {
"fishbook": "Vaata oma kalakogu ja kogutud kalaliike", "fishbook": "Vaata oma kalakogu ja kogutud kalaliike",
"fishsell": "Müü kalu oma inventarist", "fishsell": "Müü kalu oma inventarist",
"patchnotes": "Vaata TipiBOTi viimaseid muudatusi ja uuendusi", "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)."), ("/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."), ("/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 <amount>", "Anna TipiCOINe teisele mängijale"), ("/give @user <amount>", "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?"), ("/leaderboard", "TipiBOTi edetabel - kes on kõige rikkam?"),
("/shop", "Sirvi TipiBOTi poodi"), ("/shop", "Sirvi TipiBOTi poodi"),
("/buy <item>", "Osta ese TipiBOTi poodist"), ("/buy <item>", "Osta ese TipiBOTi poodist"),
@@ -588,6 +590,44 @@ TITLE: dict[str, str] = {
"fish_escape": "🎣 Kala pääses!", "fish_escape": "🎣 Kala pääses!",
"fish_junk": "🗑️ Ai ai ai...", "fish_junk": "🗑️ Ai ai ai...",
"fishbook": "📖 Kalakogu", "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 ⬡",
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------