Added quests
This commit is contained in:
8
bot.py
8
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,
|
||||
|
||||
108
commands/economy_quests_commands.py
Normal file
108
commands/economy_quests_commands.py
Normal 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))
|
||||
147
core/economy.py
147
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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`
|
||||
|
||||
96
scripts/add_quest_fields.py
Normal file
96
scripts/add_quest_fields.py
Normal 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())
|
||||
40
strings.py
40
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 <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 ⬡",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user