diff --git a/README.md b/README.md index 3451168..fa10ef3 100644 --- a/README.md +++ b/README.md @@ -245,15 +245,15 @@ 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. Botikoobas 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. 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 ⬡ | XL hiirematt reduces cooldown to 3min. Mehhaaniline klaviatuur multiplies earnings ×2. | -| `/crime` | 2h | 200–500 ⬡ | 60% success rate (75% with CAT6). +30% earnings with Mikrofon on win. Fail = fine + 30min jail. Mänguritool skips jail on fail. | +| `/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. | | `/fish` | 2min | varies | Interactive minigame. Cast → wait for bite → press button within 2s → keep in inventory or sell immediately. Ussipurk reduces cooldown to 90s. | ### Daily streak -The streak increments each time you claim `/daily` within the cooldown window. Missing a day resets it to 1 **unless** you own the TipiLAN trofee item. +The streak increments each time you claim `/daily` within the cooldown window. Missing a day resets it to 1 **unless** you own the TipiLAN karikas item. | Streak | Multiplier | Payout (base) | |---|---|---| @@ -262,7 +262,7 @@ The streak increments each time you claim `/daily` within the cooldown window. M | 7–13 days | ×2.0 | 300 ⬡ | | 14+ days | ×3.0 | 450 ⬡ | -> With LAN Pilet (×2 daily) and a 14-day streak (×3.0) the base payout reaches **900 ⬡**. Add Botikoobas 5% interest on top. +> With LAN pilet (×2 daily) and a 14-day streak (×3.0) the base payout reaches **900 ⬡**. Add Bot Farm 5% interest on top. --- @@ -281,15 +281,15 @@ Every successful economy action awards EXP: | `/beg` completed | +5 | | `/fish` catch | +2 to +25 (varies by rarity: common 2–3, uncommon 6–7, rare 10, epic 14–15, legendary 25) | -**Level formula:** `level = max(1, floor(√(total_exp ÷ 6)))` +**Level formula:** `level = max(1, floor(√(total_exp ÷ 10)))` | Level | EXP required | Milestone | |---|---|---| | 1 | 0 | TipiNOOB role | -| 5 | 150 | TipiGRINDER role | -| 10 | 600 | TipiHUSTLER role · **T2 shop unlocks** | -| 20 | 2 400 | TipiCHAD role · **T3 shop unlocks** | -| 30 | 5 400 | TipiLEGEND role | +| 5 | 250 | TipiGRINDER role | +| 10 | 1 000 | TipiHUSTLER role · **T2 shop unlocks** | +| 20 | 4 000 | TipiCHAD role · **T3 shop unlocks** | +| 30 | 9 000 | TipiLEGEND role | Use `/rank` to see your current EXP, level, progress bar to the next level, and leaderboard position. @@ -340,6 +340,7 @@ The **ECONOMY** role is granted on your first EXP award (i.e. first successful e | `/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). | | `/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). | | `/fishbook` | View your fish collection - all caught species, rarity, count, and current inventory amounts. | | `/fishsell` | Sell all fish currently in your inventory at once. | @@ -351,7 +352,7 @@ The **ECONOMY** role is granted on your first EXP award (i.e. first successful e ### Jail system -`/crime` fail (without Mänguritool) jails you for **30 minutes**. While jailed, `/work`, `/beg`, `/crime`, `/rob`, and `/give` are blocked. +`/crime` fail (without Gaming tool) jails you for **30 minutes**. While jailed, `/work`, `/beg`, `/crime`, `/rob`, and `/give` are blocked. #### `/jailbreak` Press the roll button - both dice are rolled simultaneously with an animated reveal. **3 attempts** per sentence. Matching values (doubles) = free instantly. If all 3 fail you pay bail: @@ -380,6 +381,19 @@ Spend PP in `/prestigeshop`: --- +### Quests + +`/quests` shows your personal quest board: **3 daily** and **2 weekly** quests with progress bars and a claim button. + +- Quests track counters you're already grinding: work/beg counts, coins earned, amount wagered, fish caught, crimes succeeded, coins given, heists joined. +- Progress starts counting from the moment the quest set rolls - stats earned before that don't count. +- The daily set resets at **UTC midnight**, the weekly set on the **ISO week** rollover. Each player gets their own rotation. +- Rewards are TipiCOINs (prestige coin multiplier applies) plus EXP through the normal level-up path. + +> Schema note: the quest fields live in PocketBase - after pulling this feature run `python scripts/add_quest_fields.py` once (it patches both the dev and prod collections). + +--- + ### Fishing `/fish` is an interactive minigame with a **2-minute cooldown** (90s with Ussipurk): @@ -407,39 +421,34 @@ All items are **permanent** once purchased **except Anticheat**, which expires a | Item | Cost | Effect | |---|---|---| | Mängurihiir | 500 ⬡ | `/work` earns +50% | -| XL hiirematt | 600 ⬡ | `/beg` cooldown 5min → 3min | -| Anticheat | 750 ⬡ | Rob attempts against you fail and fine the robber. **2 uses**, then repurchase. | +| 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 | -| LAN Pilet | 1 200 ⬡ | `/daily` reward ×2 | -| Botikoobas | 1 500 ⬡ | `/daily` adds 5% interest on balance (capped at 500 ⬡/day) | +| LAN pilet | 1 200 ⬡ | `/daily` reward ×2 | +| Bot Farm | 1 500 ⬡ | `/daily` adds 5% interest on balance (capped at 500 ⬡/day) | #### Tier 2 - level 10 required (TipiHUSTLER+) | Item | Cost | Effect | |---|---|---| -| Mehhaaniline klaviatuur | 1 800 ⬡ | `/beg` earns ×2 | +| Mehaaniline klaviatuur | 1 800 ⬡ | `/beg` earns ×2 | | Ultralai monitor | 2 500 ⬡ | `/work` cooldown 1h → 40min | -| Mikrofon | 2 800 ⬡ | `/crime` win earns +30% | +| Eraldiseisev mikrofon | 2 800 ⬡ | `/crime` win earns +30% | | Reguleeritav laud | 3 500 ⬡ | `/work` earns +25% (stacks with Mängurihiir → ×1.875 combined) | -| CAT6 netikaabel | 3 500 ⬡ | `/crime` success rate 60% → 75% | +| Cat6 kaabel | 3 500 ⬡ | `/crime` success rate 60% → 75% | | Jellyfin server | 4 000 ⬡ | `/rob` success rate 45% → 60% | - -#### Tier 2 - level 10 required (TipiHUSTLER+) - continued - -| Item | Cost | Effect | -|---|---|---| | Ussipurk | 3 500 ⬡ | `/fish` cooldown 2min → 90s | #### Tier 3 - level 20 required (TipiCHAD+) | Item | Cost | Effect | |---|---|---| -| TipiLAN trofee | 6 000 ⬡ | Daily streak survives missed days | -| 360hz monitor | 7 500 ⬡ | Slots jackpot 10× → 15×, triple 4× → 6× | -| Mänguritool | 9 000 ⬡ | `/crime` fail never sends you to jail | | Kalavõrk | 5 000 ⬡ | All fish caught are bumped up one rarity tier | +| TipiLAN karikas | 6 000 ⬡ | Daily streak survives missed days | +| 360Hz monitor | 7 500 ⬡ | Slots triple multipliers ×1.5 (jackpot ×25 → ×37) | | Echolood | 8 000 ⬡ | Fishing bite window 2s → 3s | +| Gaming tool | 9 000 ⬡ | `/crime` fail never sends you to jail | --- @@ -491,6 +500,7 @@ Every slash command invocation is logged with the user ID, display name, and all │ ├── economy_income_commands.py # /daily, /work, /beg, /crime, /rob │ ├── economy_prestige_commands.py# /prestige, /prestigeshop, /prestigebuy │ ├── economy_profile_commands.py # /balance, /rank, /stats, /cooldowns, /leaderboard +│ ├── economy_quests_commands.py # /quests (daily/weekly quest board + claim) │ ├── economy_support_commands.py # /shop, /buy, /give, /economysetup │ ├── info_commands.py # /patchnotes, /help auxiliaries │ ├── ops_admin_commands.py # /sync, /restart, /shutdown, /pause, /send, /status @@ -501,7 +511,8 @@ Every slash command invocation is logged with the user ID, display name, and all │ └── POCKETBASE_SETUP.md # PocketBase collection schema + setup instructions ├── scripts/ │ ├── migrate_to_pb.py # One-time legacy migration: economy.json → PocketBase -│ ├── add_stats_fields.py # Schema migration: add new fields to economy_users collection +│ ├── add_stats_fields.py # Schema migration: add stats fields to economy_users collection +│ ├── add_quest_fields.py # Schema migration: add quest fields (patches dev + prod) │ └── reset_pb_collections.py # Destructive: deletes & recreates economy collections (--confirm required) ├── requirements.txt # Python dependencies ├── .env.example # Template for secrets diff --git a/core/economy.py b/core/economy.py index 6d36fb6..6ec6619 100644 --- a/core/economy.py +++ b/core/economy.py @@ -7,6 +7,7 @@ All public async functions are the single source of truth for mutations. from __future__ import annotations import asyncio +import functools import logging import math import random @@ -465,25 +466,81 @@ def _default_user() -> UserData: # --------------------------------------------------------------------------- _log = logging.getLogger("tipiCOIN.economy") +# --------------------------------------------------------------------------- +# Per-user write locks +# --------------------------------------------------------------------------- +# Every mutation is a read-modify-write cycle (get_user → mutate → _commit); +# without serialization, two concurrent commands for the same user overwrite +# each other's commit. Locking rules that keep this deadlock-free: +# - a decorated function must never call another decorated function +# - house balance changes go through _credit_house (an atomic PocketBase +# increment, no lock), so they are safe while holding user locks +_user_locks: dict[int, asyncio.Lock] = {} + + +def _user_lock(user_id: int) -> asyncio.Lock: + lock = _user_locks.get(user_id) + if lock is None: + lock = _user_locks[user_id] = asyncio.Lock() + return lock + + +def _locked_by(*arg_positions: int): + """Serialize the decorated function per user id found at the given + positional-argument indices. Multiple ids are acquired in sorted order so + two-user functions (do_give, do_rob) cannot deadlock each other.""" + def decorator(fn): + @functools.wraps(fn) + async def wrapper(*args, **kwargs): + locks = [_user_lock(uid) for uid in sorted({args[pos] for pos in arg_positions})] + for lock in locks: + await lock.acquire() + try: + return await fn(*args, **kwargs) + finally: + for lock in reversed(locks): + lock.release() + return wrapper + return decorator + + # --------------------------------------------------------------------------- # House account (bot user) # --------------------------------------------------------------------------- HOUSE_ID: int | None = None +_house_pb_id: str | None = None def set_house(user_id: int) -> None: """Register the bot's Discord user ID as the house account.""" - global HOUSE_ID + global HOUSE_ID, _house_pb_id + if HOUSE_ID != user_id: + _house_pb_id = None HOUSE_ID = user_id +async def _house_record_id() -> str | None: + """PocketBase record id of the house account (cached; creates the record on first use).""" + global _house_pb_id + if HOUSE_ID is None: + return None + if _house_pb_id is None: + house = await get_user(HOUSE_ID) + _house_pb_id = house.get("_pb_id") # type: ignore[typeddict-item] + return _house_pb_id + + async def _credit_house(amount: int) -> None: - """Add `amount` coins to the house account. No-op if house not set.""" - if HOUSE_ID is None or amount <= 0: + """Add `amount` coins to the house via an atomic PocketBase increment. + + Deliberately lock-free: callers hold per-user locks, so this must never + acquire one itself (see the locking rules above).""" + if amount <= 0: return - user = await get_user(HOUSE_ID) - user["balance"] = user.get("balance", 0) + amount - await _commit(HOUSE_ID, user) + record_id = await _house_record_id() + if record_id is None: + return + await pb_client.update_record(record_id, {"balance+": amount}) async def get_heist_global_cd() -> float: @@ -496,13 +553,13 @@ async def get_heist_global_cd() -> float: async def set_heist_global_cd(until: float) -> None: """Persist heist global cooldown expiry to the house account in PocketBase.""" - if HOUSE_ID is None: + record_id = await _house_record_id() + if record_id is None: return - house = await get_user(HOUSE_ID) - house["heist_global_cd_until"] = until - await _commit(HOUSE_ID, house) + await pb_client.update_record(record_id, {"heist_global_cd_until": until}) +@_locked_by(0) async def do_spam_jail(user_id: int) -> None: """Jail a user for 30 minutes due to suspected automated command spam.""" user = await get_user(user_id) @@ -643,6 +700,7 @@ async def get_leaderboard_exp(top_n: int | None = 10) -> list[tuple[str, int, in return entries if top_n is None else entries[:top_n] +@_locked_by(0) 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) @@ -690,17 +748,20 @@ async def do_season_reset(top_n: int = 10) -> list[tuple[str, int, int]]: # --------------------------------------------------------------------------- # Internal write helper # --------------------------------------------------------------------------- -async def _commit(user_id: int, user: UserData) -> None: +async def _commit(user_id: int, user: UserData) -> dict | None: + """Persist the full user record. Returns the record as PocketBase stored it + (fields absent from the collection schema are silently dropped by PB).""" record_id = user.get("_pb_id") # type: ignore[typeddict-item] clean = {k: v for k, v in user.items() if k != "_pb_id"} clean["user_id"] = str(user_id) try: if record_id: - await pb_client.update_record(record_id, clean) + return await pb_client.update_record(record_id, clean) else: _log.warning("_commit for user %s had no _pb_id; creating new record", user_id) created = await pb_client.create_record(clean) user["_pb_id"] = created["id"] # type: ignore[typeddict-unknown-key] + return created except (aiohttp.ClientError, asyncio.TimeoutError, RuntimeError) as exc: _log.error("_commit failed for user %s: %s", user_id, exc) raise DatabaseError(f"Failed to persist user {user_id}: {exc}") from exc @@ -713,7 +774,7 @@ async def _commit(user_id: int, user: UserData) -> None: # 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. +# Rotation is seeded by user id + period key, so each player gets their own set. class QuestDef(TypedDict): stat: str # UserData counter field the quest tracks goal: int @@ -758,9 +819,10 @@ def _pick_quests(pool: dict[str, QuestDef], count: int, seed: str) -> list[str]: def _new_quest_block( - user: UserData, pool: dict[str, QuestDef], count: int, period_val: str, period_field: str + user: UserData, user_id: int, pool: dict[str, QuestDef], count: int, + period_val: str, period_field: str ) -> dict: - chosen = _pick_quests(pool, count, f"{period_field}:{period_val}") + chosen = _pick_quests(pool, count, f"{user_id}:{period_field}:{period_val}") return { period_field: period_val, "quests": { @@ -770,16 +832,16 @@ def _new_quest_block( } -def _ensure_quests(user: UserData) -> bool: +def _ensure_quests(user: UserData, user_id: int) -> 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") + user["quest_daily"] = _new_quest_block(user, user_id, 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") + user["quest_weekly"] = _new_quest_block(user, user_id, QUESTS_WEEKLY, WEEKLY_QUEST_COUNT, week_key, "week") changed = True return changed @@ -808,20 +870,27 @@ def _quest_view(user: UserData) -> dict: } +@_locked_by(0) 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) + if _ensure_quests(user, user_id): + saved = await _commit(user_id, user) + if saved is not None and "quest_daily" not in saved: + _log.warning( + "PocketBase collection has no quest fields - quest state is not " + "persisted and progress will stay at 0. Run scripts/add_quest_fields.py." + ) return _quest_view(user) +@_locked_by(0) 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) + _ensure_quests(user, user_id) coin_mult, _ = _prestige_mult(user) total_coins = total_exp = claimed = 0 for pool, block in ( @@ -850,6 +919,7 @@ async def claim_quests(user_id: int) -> dict: # --------------------------------------------------------------------------- # /daily # --------------------------------------------------------------------------- +@_locked_by(0) async def do_daily(user_id: int) -> dict: try: user = await get_user(user_id) @@ -929,6 +999,7 @@ async def do_daily(user_id: int) -> dict: _WORK_JOBS = strings.WORK_JOBS +@_locked_by(0) async def do_work(user_id: int) -> dict: try: user = await get_user(user_id) @@ -982,6 +1053,7 @@ _BEG_LINES = strings.BEG_LINES _BEG_JAIL_LINES = strings.BEG_JAIL_LINES +@_locked_by(0) async def do_beg(user_id: int) -> dict: try: user = await get_user(user_id) @@ -1019,6 +1091,7 @@ async def do_beg(user_id: int) -> dict: # --------------------------------------------------------------------------- # /fish # --------------------------------------------------------------------------- +@_locked_by(0) async def do_fish_start(user_id: int) -> dict: """Check cooldown + jail, set cooldown. Call before starting the fishing minigame.""" try: @@ -1039,6 +1112,7 @@ async def do_fish_start(user_id: int) -> dict: return {"ok": True} +@_locked_by(0) async def do_fish_resolve(user_id: int, fish_id: str, weight: int) -> dict: """Add catch to inventory + update fish_book. Returns catch info incl. pre-calculated value.""" user = await get_user(user_id) @@ -1084,6 +1158,7 @@ async def do_fish_resolve(user_id: int, fish_id: str, weight: int) -> dict: } +@_locked_by(0) async def do_fish_sell(user_id: int, indices: list[int] | None = None) -> dict: """Sell fish from inventory. indices=None sells all. Returns coins earned.""" user = await get_user(user_id) @@ -1144,6 +1219,7 @@ async def do_fishbook(user_id: int) -> dict: # --------------------------------------------------------------------------- # /prestige # --------------------------------------------------------------------------- +@_locked_by(0) async def do_prestige(user_id: int) -> dict: """Prestige: requires level 30, earns PP, resets balance/exp/items/cooldowns.""" try: @@ -1192,6 +1268,7 @@ async def do_prestige(user_id: int) -> dict: } +@_locked_by(0) async def do_prestige_buy(user_id: int, upgrade_id: str) -> dict: """Spend PP to buy a prestige upgrade level.""" if upgrade_id not in PRESTIGE_SHOP: @@ -1293,6 +1370,7 @@ _CRIME_WIN = strings.CRIME_WIN _CRIME_LOSE = strings.CRIME_LOSE +@_locked_by(0) async def do_crime(user_id: int) -> dict: try: user = await get_user(user_id) @@ -1349,6 +1427,7 @@ async def do_crime(user_id: int) -> dict: # --------------------------------------------------------------------------- # /jailbreak (Monopoly-style dice rolls) # --------------------------------------------------------------------------- +@_locked_by(0) async def set_jailbreak_used(user_id: int) -> None: """Mark that the user has consumed their dice attempt for this jail sentence.""" user = await get_user(user_id) @@ -1356,6 +1435,7 @@ async def set_jailbreak_used(user_id: int) -> None: await _commit(user_id, user) +@_locked_by(0) async def do_jail_free(user_id: int) -> dict: """Remove jail status after rolling doubles.""" user = await get_user(user_id) @@ -1368,6 +1448,7 @@ async def do_jail_free(user_id: int) -> dict: MIN_BAIL = 350 +@_locked_by(0) 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.""" @@ -1389,6 +1470,7 @@ async def do_bail(user_id: int) -> dict: # --------------------------------------------------------------------------- # /rob # --------------------------------------------------------------------------- +@_locked_by(0, 1) async def do_rob(robber_id: int, target_id: int) -> dict: try: robber = await get_user(robber_id) @@ -1490,6 +1572,7 @@ async def do_rob(robber_id: int, target_id: int) -> dict: # --------------------------------------------------------------------------- # /roulette # --------------------------------------------------------------------------- +@_locked_by(0) async def do_roulette(user_id: int, bet: int, colour: str) -> dict: try: user = await get_user(user_id) @@ -1532,6 +1615,7 @@ async def do_roulette(user_id: int, bet: int, colour: str) -> dict: # --------------------------------------------------------------------------- # /rps (bet resolution) # --------------------------------------------------------------------------- +@_locked_by(0) async def do_game_bet(user_id: int, bet: int, outcome: str) -> dict: """Settle a simple win/tie/lose bet. outcome: 'win' | 'tie' | 'lose'.""" try: @@ -1565,6 +1649,7 @@ async def do_game_bet(user_id: int, bet: int, outcome: str) -> dict: # --------------------------------------------------------------------------- # /rps PvP escrow (deposit/payout/refund) # --------------------------------------------------------------------------- +@_locked_by(0) async def do_rps_pvp_deposit(user_id: int, bet: int) -> dict: """Hold `bet` coins from a player as escrow for a PvP RPS duel.""" try: @@ -1587,6 +1672,7 @@ async def do_rps_pvp_deposit(user_id: int, bet: int) -> dict: return {"ok": True, "balance": user["balance"]} +@_locked_by(0) async def do_rps_pvp_payout(winner_id: int, bet: int) -> dict: """Credit the duel winner with 2*bet (their stake back + opponent's).""" try: @@ -1606,6 +1692,7 @@ 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_refund(user_id: int, bet: int) -> dict: """Refund a previously escrowed bet (tie / timeout / cancel).""" try: @@ -1649,6 +1736,7 @@ def _spin() -> str: return random.choices(list(symbols), weights=list(weights), k=1)[0] +@_locked_by(0) async def do_slots(user_id: int, bet: int) -> dict: try: user = await get_user(user_id) @@ -1705,6 +1793,7 @@ async def do_slots(user_id: int, bet: int) -> dict: # --------------------------------------------------------------------------- # /give # --------------------------------------------------------------------------- +@_locked_by(0, 1) async def do_give(giver_id: int, receiver_id: int, amount: int) -> dict: try: giver = await get_user(giver_id) @@ -1756,6 +1845,7 @@ async def do_give(giver_id: int, receiver_id: int, amount: int) -> dict: # --------------------------------------------------------------------------- # /buy # --------------------------------------------------------------------------- +@_locked_by(0) async def do_buy(user_id: int, item_id: str) -> dict: if item_id not in SHOP: return {"ok": False, "reason": "not_found"} @@ -1797,6 +1887,7 @@ async def do_buy(user_id: int, item_id: str) -> dict: # --------------------------------------------------------------------------- # Admin actions # --------------------------------------------------------------------------- +@_locked_by(0) async def do_admin_coins(target_id: int, amount: int, admin_id: int, reason: str) -> dict: """Give (positive) or take (negative) coins from a user. Balance is floored at 0.""" user = await get_user(target_id) @@ -1807,6 +1898,7 @@ async def do_admin_coins(target_id: int, amount: int, admin_id: int, reason: str return {"ok": True, "balance": user["balance"], "change": amount} +@_locked_by(0) async def do_admin_jail(target_id: int, minutes: int, admin_id: int, reason: str) -> dict: """Manually jail a user for `minutes` minutes.""" user = await get_user(target_id) @@ -1817,6 +1909,7 @@ async def do_admin_jail(target_id: int, minutes: int, admin_id: int, reason: str return {"ok": True, "jailed_until": user["jailed_until"]} +@_locked_by(0) async def do_admin_unjail(target_id: int, admin_id: int) -> dict: """Remove jail from a user.""" user = await get_user(target_id) @@ -1827,6 +1920,7 @@ async def do_admin_unjail(target_id: int, admin_id: int) -> dict: return {"ok": True} +@_locked_by(0) async def do_admin_ban(target_id: int, admin_id: int, reason: str) -> dict: """Ban a user from all economy commands.""" user = await get_user(target_id) @@ -1836,6 +1930,7 @@ async def do_admin_ban(target_id: int, admin_id: int, reason: str) -> dict: return {"ok": True} +@_locked_by(0) async def do_admin_unban(target_id: int, admin_id: int) -> dict: """Lift an economy ban.""" user = await get_user(target_id) @@ -1845,6 +1940,7 @@ async def do_admin_unban(target_id: int, admin_id: int) -> dict: return {"ok": True} +@_locked_by(0) async def do_admin_reset(target_id: int, admin_id: int) -> dict: """Wipe a user's economy data back to defaults.""" user = await get_user(target_id) @@ -1861,6 +1957,7 @@ async def do_admin_inspect(target_id: int) -> dict: return {"ok": True, "data": dict(user)} +@_locked_by(0) async def do_admin_exp(target_id: int, amount: int, admin_id: int, reason: str) -> dict: """Give (positive) or take (negative) EXP from a user. EXP is floored at 0.""" user = await get_user(target_id) @@ -1882,6 +1979,7 @@ async def do_admin_exp(target_id: int, amount: int, admin_id: int, reason: str) } +@_locked_by(0) async def do_admin_item(target_id: int, item_id: str, action: str, admin_id: int) -> dict: """Give or remove an item. action='give'|'remove'. Returns ok/reason.""" if item_id not in SHOP: @@ -1915,6 +2013,7 @@ async def do_admin_item(target_id: int, item_id: str, action: str, admin_id: int # --------------------------------------------------------------------------- # /reminders # --------------------------------------------------------------------------- +@_locked_by(0) async def do_set_reminders(user_id: int, commands: list[str]) -> None: """Overwrite the user's reminder list with the given command names.""" user = await get_user(user_id) @@ -1925,6 +2024,7 @@ async def do_set_reminders(user_id: int, commands: list[str]) -> None: # --------------------------------------------------------------------------- # /blackjack # --------------------------------------------------------------------------- +@_locked_by(0) async def do_blackjack_bet(user_id: int, bet: int) -> dict: """Deduct the initial blackjack bet. Returns ok/fail.""" try: @@ -1942,6 +2042,7 @@ async def do_blackjack_bet(user_id: int, bet: int) -> dict: return {"ok": True, "balance": user["balance"]} +@_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) @@ -2009,57 +2110,58 @@ async def do_heist_resolve(user_ids: list[int], success: bool) -> dict: if success and HOUSE_ID is not None: try: house = await get_user(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"]) + if debit > 0: + await pb_client.update_record(house["_pb_id"], {"balance-": debit}) # type: ignore[typeddict-item] except DatabaseError: return {"ok": False, "reason": "db_error"} - pct = random.uniform(0.20, 0.55) - total = max(300, int(house["balance"] * pct)) - payout_each = total // len(user_ids) - house["balance"] = max(0, house["balance"] - total) - try: - await _commit(HOUSE_ID, house) - except DatabaseError: - return {"ok": False, "reason": "db_error"} - _txn("HEIST_HOUSE", change=f"-{total}", house_bal=house["balance"]) + _txn("HEIST_HOUSE", change=f"-{debit}", house_bal=house["balance"] - debit) for uid in user_ids: - try: - user = await get_user(uid) - except DatabaseError: - failed_users.append(uid) - if success and payout_each > 0: - await _refund_house_safe(payout_each, "heist_win_compensate", uid) - continue - user["last_heist"] = now.isoformat() - user["heists_joined"] = user.get("heists_joined", 0) + 1 - fine_credited = False - if success: - user["balance"] += payout_each - user["heists_won"] = user.get("heists_won", 0) + 1 - user["lifetime_earned"] = user.get("lifetime_earned", 0) + payout_each - user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"]) - _txn("HEIST_WIN", user=uid, change=f"+{payout_each}", bal=user["balance"]) - else: - fine = max(150, min(1000, int(user["balance"] * 0.15))) - user["balance"] = max(0, user["balance"] - fine) - user["jailed_until"] = (now + HEIST_JAIL).isoformat() - user["jailbreak_used"] = False - user["times_jailed"] = user.get("times_jailed", 0) + 1 - user["lifetime_lost"] = user.get("lifetime_lost", 0) + fine - _txn("HEIST_FAIL", user=uid, fine=f"-{fine}", jailed_until=user["jailed_until"], bal=user["balance"]) - if fine > 0: - try: - await _credit_house(fine) - fine_credited = True - except DatabaseError: - pass # user commit will still be attempted; if both fail, no economy effect - try: - await _commit(uid, user) - except DatabaseError: - failed_users.append(uid) - 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_ID, fine if 'fine' in locals() else 0, "heist_fail_compensate", uid) + async with _user_lock(uid): + try: + user = await get_user(uid) + except DatabaseError: + failed_users.append(uid) + if success and payout_each > 0: + await _refund_house_safe(payout_each, "heist_win_compensate", uid) + continue + user["last_heist"] = now.isoformat() + user["heists_joined"] = user.get("heists_joined", 0) + 1 + fine_credited = False + if success: + user["balance"] += payout_each + user["heists_won"] = user.get("heists_won", 0) + 1 + user["lifetime_earned"] = user.get("lifetime_earned", 0) + payout_each + user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"]) + _txn("HEIST_WIN", user=uid, change=f"+{payout_each}", bal=user["balance"]) + else: + fine = max(150, min(1000, int(user["balance"] * 0.15))) + user["balance"] = max(0, user["balance"] - fine) + user["jailed_until"] = (now + HEIST_JAIL).isoformat() + user["jailbreak_used"] = False + user["times_jailed"] = user.get("times_jailed", 0) + 1 + user["lifetime_lost"] = user.get("lifetime_lost", 0) + fine + _txn("HEIST_FAIL", user=uid, fine=f"-{fine}", jailed_until=user["jailed_until"], bal=user["balance"]) + if fine > 0: + try: + await _credit_house(fine) + fine_credited = True + except DatabaseError: + pass # user commit will still be attempted; if both fail, no economy effect + try: + await _commit(uid, user) + except DatabaseError: + failed_users.append(uid) + 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_ID, fine if 'fine' in locals() else 0, "heist_fail_compensate", uid) return {"ok": True, "payout_each": payout_each, "success": success, "failed_users": failed_users} @@ -2069,9 +2171,7 @@ async def _refund_house_safe(amount: int, context: str, related_uid: int) -> Non if HOUSE_ID is None or amount <= 0: return try: - house = await get_user(HOUSE_ID) - house["balance"] = house.get("balance", 0) + amount - await _commit(HOUSE_ID, house) + await _credit_house(amount) except DatabaseError as exc: _log.critical( "House compensation failed (%s, related uid %s, amount %s): %s", @@ -2080,16 +2180,14 @@ 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 debit of `amount` from the house (compensates a failed user fine). - - Reads house, subtracts amount, commits. Logs critical if it fails. - """ + """Best-effort atomic debit of `amount` from the house (compensates a failed + user fine). Logs critical if it fails.""" if HOUSE_ID is None or amount <= 0: return try: - house = await get_user(HOUSE_ID) - house["balance"] = max(0, house.get("balance", 0) - amount) - await _commit(HOUSE_ID, house) + record_id = await _house_record_id() + if record_id: + await pb_client.update_record(record_id, {"balance-": amount}) except DatabaseError as exc: _log.critical( "House debit compensation failed (%s, related uid %s, amount %s): %s", diff --git a/docs/PATCHNOTES.md b/docs/PATCHNOTES.md index 7cd8c9c..cc2e400 100644 --- a/docs/PATCHNOTES.md +++ b/docs/PATCHNOTES.md @@ -5,7 +5,8 @@ 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. +- 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. +- Fixed concurrent commands being able to overwrite each other's balance changes — every economy action now runs under a per-user lock, and house payouts use atomic database increments ## v0.1.0 — 2026-05-03 diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..a266747 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest>=8.0 diff --git a/scripts/add_quest_fields.py b/scripts/add_quest_fields.py index 20189e5..d31e87e 100644 --- a/scripts/add_quest_fields.py +++ b/scripts/add_quest_fields.py @@ -25,7 +25,9 @@ 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 +# Patch both profiles' collections so the result doesn't depend on which +# BOT_PROFILE happened to be set when the script was run. +COLLECTIONS = sorted({config.PB_ECONOMY_COLLECTION_DEV, config.PB_ECONOMY_COLLECTION_ECONOMY}) # --------------------------------------------------------------------------- # New fields to add @@ -55,41 +57,44 @@ async def main() -> None: 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() + for collection in COLLECTIONS: + print(f"── {collection} ──") - existing = {f["name"] for f in col.get("fields", [])} - print(f"Existing fields ({len(existing)}): {sorted(existing)}\n") + # ── 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()}\n") + continue + col = await resp.json() - 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)") + existing = {f["name"] for f in col.get("fields", [])} + print(f"Existing fields ({len(existing)}): {sorted(existing)}\n") - if not new_fields: - print("\nNothing to add - schema already up to date.") - return + 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)") - # ── 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 not new_fields: + print("Nothing to add - schema already up to date.\n") + continue + + # ── 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"Schema update failed ({resp.status}): {await resp.text()}\n") + continue + print(f"✅ Added {len(new_fields)} field(s) successfully.\n") if __name__ == "__main__": diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..350e825 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,108 @@ +"""Shared fixtures: an in-memory PocketBase stand-in wired into core.economy.""" + +from __future__ import annotations + +import asyncio +import copy +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from core import economy, pb_client # noqa: E402 + + +class FakePocketBase: + """In-memory stand-in for core.pb_client. + + Mimics the behaviours the economy layer depends on: + - records are returned as deep copies (like JSON over REST) + - "field+" / "field-" body keys are atomic number modifiers + - fields not in `schema_fields` are silently dropped, like PocketBase + does for fields missing from the collection schema (schema_fields=None + keeps everything) + """ + + def __init__(self, schema_fields: set[str] | None = None): + self.records: dict[str, dict] = {} + self.schema_fields = schema_fields + self._next_id = 0 + + def _filter(self, data: dict) -> dict: + if self.schema_fields is None: + return dict(data) + return {k: v for k, v in data.items() if k.rstrip("+-") in self.schema_fields} + + def _apply(self, record: dict, data: dict) -> None: + for key, value in self._filter(data).items(): + if key.endswith("+"): + record[key[:-1]] = record.get(key[:-1], 0) + value + elif key.endswith("-"): + record[key[:-1]] = record.get(key[:-1], 0) - value + else: + record[key] = value + + async def get_record(self, user_id: str) -> dict | None: + await asyncio.sleep(0) # yield, so unserialized tasks would interleave + for record in self.records.values(): + if record.get("user_id") == user_id: + return copy.deepcopy(record) + return None + + async def create_record(self, record: dict) -> dict: + await asyncio.sleep(0) + self._next_id += 1 + stored = self._filter(record) + stored["id"] = f"rec{self._next_id}" + stored["user_id"] = record.get("user_id", "") + self.records[stored["id"]] = stored + return copy.deepcopy(stored) + + async def update_record(self, record_id: str, data: dict) -> dict: + await asyncio.sleep(0) + record = self.records[record_id] + self._apply(record, data) + return copy.deepcopy(record) + + async def list_all_records(self, page_size: int = 500) -> list[dict]: + await asyncio.sleep(0) + return copy.deepcopy(list(self.records.values())) + + async def count_records(self) -> int: + return len(self.records) + + # -- test helpers ------------------------------------------------------- + def record_for(self, user_id: int) -> dict: + for record in self.records.values(): + if record.get("user_id") == str(user_id): + return record + raise KeyError(user_id) + + +def _install(monkeypatch, fake: FakePocketBase) -> FakePocketBase: + for name in ("get_record", "create_record", "update_record", + "list_all_records", "count_records"): + monkeypatch.setattr(pb_client, name, getattr(fake, name)) + monkeypatch.setattr(economy, "HOUSE_ID", None) + monkeypatch.setattr(economy, "_house_pb_id", None) + economy._user_locks.clear() + return fake + + +@pytest.fixture +def fake_pb(monkeypatch) -> FakePocketBase: + return _install(monkeypatch, FakePocketBase()) + + +@pytest.fixture +def fake_pb_without_quest_fields(monkeypatch) -> FakePocketBase: + """A fake whose collection schema predates the quest migration.""" + schema = set(economy._default_user().keys()) | {"user_id"} + schema -= {"quest_daily", "quest_weekly"} + return _install(monkeypatch, FakePocketBase(schema_fields=schema)) + + +def run(coro): + return asyncio.run(coro) diff --git a/tests/test_economy_flows.py b/tests/test_economy_flows.py new file mode 100644 index 0000000..44684e5 --- /dev/null +++ b/tests/test_economy_flows.py @@ -0,0 +1,204 @@ +"""Tests for the async economy flows against the in-memory PocketBase fake.""" + +import asyncio +import random +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, "_now", lambda: dt) + return dt + + +class TestGetUser: + def test_creates_default_record(self, fake_pb): + user = run(economy.get_user(UID)) + assert user["balance"] == 0 + assert fake_pb.record_for(UID)["user_id"] == str(UID) + + def test_roundtrips_existing_data(self, fake_pb): + run(economy.get_user(UID)) + fake_pb.record_for(UID)["balance"] = 1234 + assert run(economy.get_user(UID))["balance"] == 1234 + + +class TestDaily: + def test_first_claim(self, fake_pb): + res = run(economy.do_daily(UID)) + assert res["ok"] and res["streak"] == 1 and res["earned"] == 150 + + def test_cooldown_blocks_second_claim(self, fake_pb): + run(economy.do_daily(UID)) + res = run(economy.do_daily(UID)) + assert not res["ok"] and res["reason"] == "cooldown" + + def test_streak_increments_next_day(self, fake_pb, monkeypatch): + t0 = _fixed_now(monkeypatch, datetime(2026, 7, 25, 12, tzinfo=timezone.utc)) + run(economy.do_daily(UID)) + _fixed_now(monkeypatch, t0 + timedelta(days=1)) + res = run(economy.do_daily(UID)) + assert res["ok"] and res["streak"] == 2 + + def test_streak_resets_after_missed_day(self, fake_pb, monkeypatch): + t0 = _fixed_now(monkeypatch, datetime(2026, 7, 25, 12, tzinfo=timezone.utc)) + run(economy.do_daily(UID)) + _fixed_now(monkeypatch, t0 + timedelta(days=3)) + res = run(economy.do_daily(UID)) + assert res["ok"] and res["streak"] == 1 + + def test_karikas_preserves_streak(self, fake_pb, monkeypatch): + t0 = _fixed_now(monkeypatch, datetime(2026, 7, 25, 12, tzinfo=timezone.utc)) + run(economy.do_daily(UID)) + fake_pb.record_for(UID)["items"] = ["karikas"] + fake_pb.record_for(UID)["daily_streak"] = 5 + _fixed_now(monkeypatch, t0 + timedelta(days=3)) + res = run(economy.do_daily(UID)) + assert res["ok"] and res["streak"] == 5 + + def test_streak_multiplier_tiers(self, fake_pb, monkeypatch): + t0 = _fixed_now(monkeypatch, datetime(2026, 7, 25, 12, tzinfo=timezone.utc)) + run(economy.get_user(UID)) + rec = fake_pb.record_for(UID) + rec["daily_streak"] = 13 + rec["last_streak_date"] = (t0.date() - timedelta(days=1)).isoformat() + res = run(economy.do_daily(UID)) + assert res["streak"] == 14 and res["streak_mult"] == 3.0 and res["earned"] == 450 + + +class TestBuy: + def test_insufficient_funds(self, fake_pb): + res = run(economy.do_buy(UID, "gaming_hiir")) + assert not res["ok"] and res["reason"] == "insufficient" + + def test_purchase_and_rebuy_blocked(self, fake_pb): + run(economy.get_user(UID)) + fake_pb.record_for(UID)["balance"] = 1000 + res = run(economy.do_buy(UID, "gaming_hiir")) + assert res["ok"] and res["balance"] == 500 + assert "gaming_hiir" in fake_pb.record_for(UID)["items"] + res = run(economy.do_buy(UID, "gaming_hiir")) + assert not res["ok"] and res["reason"] == "owned" + + def test_tier2_requires_level(self, fake_pb): + run(economy.get_user(UID)) + fake_pb.record_for(UID)["balance"] = 100_000 + res = run(economy.do_buy(UID, "jellyfin")) + assert not res["ok"] and res["reason"] == "level_required" + fake_pb.record_for(UID)["exp"] = economy.exp_for_level(10) + assert run(economy.do_buy(UID, "jellyfin"))["ok"] + + def test_anticheat_repurchase_after_depletion(self, fake_pb): + run(economy.get_user(UID)) + rec = fake_pb.record_for(UID) + rec["balance"] = 10_000 + assert run(economy.do_buy(UID, "anticheat"))["ok"] + assert fake_pb.record_for(UID)["item_uses"]["anticheat"] == 2 + fake_pb.record_for(UID)["item_uses"]["anticheat"] = 0 + assert run(economy.do_buy(UID, "anticheat"))["ok"] + assert fake_pb.record_for(UID)["item_uses"]["anticheat"] == 2 + + +class TestGive: + def test_transfer(self, fake_pb): + run(economy.get_user(UID)) + fake_pb.record_for(UID)["balance"] = 500 + res = run(economy.do_give(UID, OTHER, 200)) + assert res["ok"] + assert fake_pb.record_for(UID)["balance"] == 300 + assert fake_pb.record_for(OTHER)["balance"] == 200 + + def test_insufficient(self, fake_pb): + res = run(economy.do_give(UID, OTHER, 50)) + assert not res["ok"] and res["reason"] == "insufficient" + + def test_opposite_transfers_do_not_deadlock(self, fake_pb): + async def both(): + for uid in (UID, OTHER): + await economy.get_user(uid) + fake_pb.record_for(uid)["balance"] = 100 + await asyncio.wait_for( + asyncio.gather( + economy.do_give(UID, OTHER, 10), + economy.do_give(OTHER, UID, 25), + ), + timeout=5, + ) + run(both()) + total = fake_pb.record_for(UID)["balance"] + fake_pb.record_for(OTHER)["balance"] + assert total == 200 + + +class TestRob: + def test_anticheat_blocks_and_depletes(self, fake_pb): + run(economy.get_user(UID)) + run(economy.get_user(OTHER)) + economy.set_house(HOUSE) + fake_pb.record_for(UID)["balance"] = 1000 + target = fake_pb.record_for(OTHER) + target["balance"] = 1000 + target["items"] = ["anticheat"] + target["item_uses"] = {"anticheat": 1} + res = run(economy.do_rob(UID, OTHER)) + assert res["ok"] and not res["success"] and res["reason"] == "valvur" + assert fake_pb.record_for(UID)["balance"] == 1000 - res["fine"] + assert "anticheat" not in fake_pb.record_for(OTHER)["items"] + # the fine flows to the house + assert fake_pb.record_for(HOUSE)["balance"] == res["fine"] + + +class TestGambling: + def test_roulette_conserves_money_with_house(self, fake_pb): + economy.set_house(HOUSE) + run(economy.get_user(UID)) + fake_pb.record_for(UID)["balance"] = 1000 + random.seed(3) + res = run(economy.do_roulette(UID, 100, "punane")) + assert res["ok"] + user_bal = fake_pb.record_for(UID)["balance"] + if res["won"]: + assert user_bal == 1000 + res["change"] + else: + assert user_bal == 900 + assert fake_pb.record_for(HOUSE)["balance"] == 100 + + def test_bet_larger_than_balance_rejected(self, fake_pb): + res = run(economy.do_slots(UID, 50)) + assert not res["ok"] and res["reason"] == "insufficient" + + def test_blackjack_bet_and_payout(self, fake_pb): + economy.set_house(HOUSE) + run(economy.get_user(UID)) + fake_pb.record_for(UID)["balance"] = 500 + assert run(economy.do_blackjack_bet(UID, 100))["ok"] + assert fake_pb.record_for(UID)["balance"] == 400 + # player loses: payout 0 of 100 invested -> house gains the bet + run(economy.do_blackjack_payout(UID, 0, total_invested=100)) + assert fake_pb.record_for(UID)["balance"] == 400 + assert fake_pb.record_for(HOUSE)["balance"] == 100 + + +class TestConcurrency: + """The per-user locks must serialize read-modify-write cycles.""" + + def test_concurrent_exp_awards_are_not_lost(self, fake_pb): + async def hammer(): + await asyncio.gather(*(economy.award_exp(UID, 10) for _ in range(25))) + run(hammer()) + assert fake_pb.record_for(UID)["exp"] == 250 + + def test_concurrent_credit_house_is_atomic(self, fake_pb): + economy.set_house(HOUSE) + + async def hammer(): + await economy.get_user(HOUSE) + await asyncio.gather(*(economy._credit_house(7) for _ in range(30))) + run(hammer()) + assert fake_pb.record_for(HOUSE)["balance"] == 210 diff --git a/tests/test_economy_pure.py b/tests/test_economy_pure.py new file mode 100644 index 0000000..5d72d30 --- /dev/null +++ b/tests/test_economy_pure.py @@ -0,0 +1,86 @@ +"""Tests for the pure (no-database) economy math.""" + +import random +from datetime import timedelta + +from core import economy + + +class TestLevels: + def test_milestones(self): + assert economy.get_level(0) == 1 + assert economy.get_level(249) == 4 + assert economy.get_level(250) == 5 + assert economy.get_level(1000) == 10 + assert economy.get_level(4000) == 20 + assert economy.get_level(9000) == 30 + + def test_exp_for_level_is_inverse(self): + for level in range(1, 41): + exp = economy.exp_for_level(level) + assert economy.get_level(exp) == level + if level > 1: + assert economy.get_level(exp - 1) == level - 1 + + def test_negative_exp_clamps_to_level_1(self): + assert economy.get_level(-500) == 1 + + def test_role_names(self): + assert economy.level_role_name(1) == "TipiNOOB" + assert economy.level_role_name(4) == "TipiNOOB" + assert economy.level_role_name(5) == "TipiGRINDER" + assert economy.level_role_name(10) == "TipiHUSTLER" + assert economy.level_role_name(20) == "TipiCHAD" + assert economy.level_role_name(30) == "TipiLEGEND" + assert economy.level_role_name(99) == "TipiLEGEND" + + +class TestGambleExp: + def test_tiers(self): + assert economy.gamble_exp(0) == 0 + assert economy.gamble_exp(9) == 0 + assert economy.gamble_exp(10) == 5 + assert economy.gamble_exp(99) == 5 + assert economy.gamble_exp(100) == 10 + assert economy.gamble_exp(999) == 10 + assert economy.gamble_exp(1_000) == 15 + assert economy.gamble_exp(9_999) == 15 + assert economy.gamble_exp(10_000) == 20 + assert economy.gamble_exp(99_999) == 20 + assert economy.gamble_exp(100_000) == 25 + + def test_cap(self): + assert economy.gamble_exp(10_000_000) == 25 + + +class TestFormatTd: + def test_hours(self): + assert economy.format_td(timedelta(hours=1, minutes=23, seconds=45)) == "1t 23m" + + def test_minutes(self): + assert economy.format_td(timedelta(minutes=45, seconds=12)) == "45m 12s" + + def test_seconds(self): + assert economy.format_td(timedelta(seconds=8)) == "8s" + + +class TestRollFish: + def test_rolls_are_valid(self): + random.seed(42) + for _ in range(500): + fish_id, weight = economy.roll_fish() + if fish_id == "junk": + assert weight == 0 + else: + fish = economy.FISH_CATALOGUE[fish_id] + assert fish["weight"][0] <= weight <= fish["weight"][1] + + def test_rarity_bump_shifts_every_catch_up_a_tier(self): + random.seed(7) + rarities = { + economy.FISH_CATALOGUE[fid]["rarity"] + for fid, _ in (economy.roll_fish(rarity_bump=True) for _ in range(1000)) + if fid != "junk" + } + assert "common" not in rarities + assert "legendary" in rarities diff --git a/tests/test_quests.py b/tests/test_quests.py new file mode 100644 index 0000000..d702c20 --- /dev/null +++ b/tests/test_quests.py @@ -0,0 +1,116 @@ +"""Tests for the quest system: rotation, progress, claiming, schema detection.""" + +import logging +from datetime import datetime, timedelta, timezone + +from core import economy + +from conftest import run + +UID = 111 + + +def _fixed_now(monkeypatch, dt: datetime): + monkeypatch.setattr(economy, "_now", lambda: dt) + return dt + + +def _complete_all_active_quests(fake_pb, user_id: int) -> tuple[int, int]: + """Push every active quest's tracked counter past its goal directly in the + store. Returns (expected_coins, expected_exp).""" + rec = fake_pb.record_for(user_id) + coins = exp = 0 + for pool, block in ( + (economy.QUESTS_DAILY, rec["quest_daily"]), + (economy.QUESTS_WEEKLY, rec["quest_weekly"]), + ): + for qid, state in block["quests"].items(): + stat = pool[qid]["stat"] + rec[stat] = state["snap"] + pool[qid]["goal"] + coins += pool[qid]["coins"] + exp += pool[qid]["exp"] + return coins, exp + + +class TestRotation: + def test_deterministic_per_seed(self): + a = economy._pick_quests(economy.QUESTS_DAILY, 3, "1:date:2026-07-26") + b = economy._pick_quests(economy.QUESTS_DAILY, 3, "1:date:2026-07-26") + assert a == b + + def test_users_get_different_sets(self): + sets = { + tuple(economy._pick_quests(economy.QUESTS_DAILY, 3, f"{uid}:date:2026-07-26")) + for uid in range(50) + } + assert len(sets) > 1 + + def test_counts(self, fake_pb): + data = run(economy.get_quests(UID)) + assert len(data["daily"]) == economy.DAILY_QUEST_COUNT + assert len(data["weekly"]) == economy.WEEKLY_QUEST_COUNT + + def test_daily_rolls_over_weekly_stays(self, fake_pb, monkeypatch): + # a Tuesday, so day+1 stays inside the same ISO week + t0 = _fixed_now(monkeypatch, datetime(2026, 7, 21, 12, tzinfo=timezone.utc)) + run(economy.get_quests(UID)) + rec = fake_pb.record_for(UID) + daily_before, weekly_before = dict(rec["quest_daily"]), dict(rec["quest_weekly"]) + _fixed_now(monkeypatch, t0 + timedelta(days=1)) + run(economy.get_quests(UID)) + rec = fake_pb.record_for(UID) + assert rec["quest_daily"]["date"] != daily_before["date"] + assert rec["quest_weekly"] == weekly_before + + +class TestProgress: + def test_progress_tracks_counter_delta(self, fake_pb): + run(economy.get_quests(UID)) + rec = fake_pb.record_for(UID) + qid, state = next(iter(rec["quest_daily"]["quests"].items())) + stat = economy.QUESTS_DAILY[qid]["stat"] + rec[stat] = state["snap"] + 1 + data = run(economy.get_quests(UID)) + quest = next(q for q in data["daily"] if q["id"] == qid) + assert quest["progress"] == 1 + + def test_pre_roll_stats_do_not_count(self, fake_pb): + run(economy.get_user(UID)) + fake_pb.record_for(UID)["work_count"] = 500 + data = run(economy.get_quests(UID)) + assert all(q["progress"] == 0 for q in data["daily"] + data["weekly"]) + + +class TestClaim: + def test_claim_pays_and_is_idempotent(self, fake_pb): + run(economy.get_quests(UID)) + coins, exp = _complete_all_active_quests(fake_pb, UID) + res = run(economy.claim_quests(UID)) + assert res["ok"] + assert res["claimed"] == economy.DAILY_QUEST_COUNT + economy.WEEKLY_QUEST_COUNT + assert res["coins"] == coins and res["exp"] == exp + assert fake_pb.record_for(UID)["balance"] == coins + res = run(economy.claim_quests(UID)) + assert not res["ok"] and res["reason"] == "nothing" + + def test_claim_with_nothing_done(self, fake_pb): + run(economy.get_quests(UID)) + res = run(economy.claim_quests(UID)) + assert not res["ok"] + + +class TestSchemaDetection: + def test_missing_quest_fields_warn_and_zero_progress( + self, fake_pb_without_quest_fields, caplog + ): + """Reproduces the live 'quests never progress' symptom: when the + collection schema lacks quest_daily/quest_weekly, PocketBase drops the + rolled quest block, so every call re-rolls with a fresh snapshot.""" + fake = fake_pb_without_quest_fields + with caplog.at_level(logging.WARNING): + run(economy.get_quests(UID)) + assert any("quest fields" in r.message for r in caplog.records) + # counters advance, but progress stays 0 because the snapshot re-rolls + fake.record_for(UID)["work_count"] = 500 + data = run(economy.get_quests(UID)) + assert all(q["progress"] == 0 for q in data["daily"] + data["weekly"])