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_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,

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}
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
# ---------------------------------------------------------------------------

View File

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

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",
"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 <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?"),
("/shop", "Sirvi TipiBOTi poodi"),
("/buy <item>", "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 ⬡",
}
# ---------------------------------------------------------------------------