forked from sass/tipibot
Added tests, fix README.
This commit is contained in:
65
README.md
65
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 |
|
| 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. |
|
| `/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. |
|
| `/beg` | 5min | 10–40 ⬡ | Hiirematt reduces cooldown to 3min. Mehaaniline 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. |
|
| `/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. |
|
| `/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
|
### 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) |
|
| 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 ⬡ |
|
| 7–13 days | ×2.0 | 300 ⬡ |
|
||||||
| 14+ days | ×3.0 | 450 ⬡ |
|
| 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 |
|
| `/beg` completed | +5 |
|
||||||
| `/fish` catch | +2 to +25 (varies by rarity: common 2–3, uncommon 6–7, rare 10, epic 14–15, legendary 25) |
|
| `/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 |
|
| Level | EXP required | Milestone |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 | 0 | TipiNOOB role |
|
| 1 | 0 | TipiNOOB role |
|
||||||
| 5 | 150 | TipiGRINDER role |
|
| 5 | 250 | TipiGRINDER role |
|
||||||
| 10 | 600 | TipiHUSTLER role · **T2 shop unlocks** |
|
| 10 | 1 000 | TipiHUSTLER role · **T2 shop unlocks** |
|
||||||
| 20 | 2 400 | TipiCHAD role · **T3 shop unlocks** |
|
| 20 | 4 000 | TipiCHAD role · **T3 shop unlocks** |
|
||||||
| 30 | 5 400 | TipiLEGEND role |
|
| 30 | 9 000 | TipiLEGEND role |
|
||||||
|
|
||||||
Use `/rank` to see your current EXP, level, progress bar to the next level, and leaderboard position.
|
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. |
|
| `/shop` | Browse all items by tier. Shows owned status, Anticheat charges remaining, and level lock for T2/T3. |
|
||||||
| `/buy <item>` | Purchase an item by name (partial match accepted). |
|
| `/buy <item>` | Purchase an item by name (partial match accepted). |
|
||||||
| `/reminders` | Toggle per-command DM notifications. Bot DMs you the moment each cooldown expires. |
|
| `/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). |
|
| `/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. |
|
| `/fishbook` | View your fish collection - all caught species, rarity, count, and current inventory amounts. |
|
||||||
| `/fishsell` | Sell all fish currently in your inventory at once. |
|
| `/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
|
### 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`
|
#### `/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:
|
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
|
### Fishing
|
||||||
|
|
||||||
`/fish` is an interactive minigame with a **2-minute cooldown** (90s with Ussipurk):
|
`/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 |
|
| Item | Cost | Effect |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Mängurihiir | 500 ⬡ | `/work` earns +50% |
|
| Mängurihiir | 500 ⬡ | `/work` earns +50% |
|
||||||
| XL hiirematt | 600 ⬡ | `/beg` cooldown 5min → 3min |
|
| Hiirematt | 600 ⬡ | `/beg` cooldown 5min → 3min |
|
||||||
| Anticheat | 750 ⬡ | Rob attempts against you fail and fine the robber. **2 uses**, then repurchase. |
|
|
||||||
| Red Bull | 800 ⬡ | `/work` has 30% chance to earn ×3 |
|
| 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 |
|
| Kõrvaklapid | 1 200 ⬡ | `/daily` cooldown 20h → 18h |
|
||||||
| LAN Pilet | 1 200 ⬡ | `/daily` reward ×2 |
|
| LAN pilet | 1 200 ⬡ | `/daily` reward ×2 |
|
||||||
| Botikoobas | 1 500 ⬡ | `/daily` adds 5% interest on balance (capped at 500 ⬡/day) |
|
| Bot Farm | 1 500 ⬡ | `/daily` adds 5% interest on balance (capped at 500 ⬡/day) |
|
||||||
|
|
||||||
#### Tier 2 - level 10 required (TipiHUSTLER+)
|
#### Tier 2 - level 10 required (TipiHUSTLER+)
|
||||||
|
|
||||||
| Item | Cost | Effect |
|
| 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 |
|
| 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) |
|
| 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% |
|
| 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 |
|
| Ussipurk | 3 500 ⬡ | `/fish` cooldown 2min → 90s |
|
||||||
|
|
||||||
#### Tier 3 - level 20 required (TipiCHAD+)
|
#### Tier 3 - level 20 required (TipiCHAD+)
|
||||||
|
|
||||||
| Item | Cost | Effect |
|
| 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 |
|
| 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 |
|
| 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_income_commands.py # /daily, /work, /beg, /crime, /rob
|
||||||
│ ├── economy_prestige_commands.py# /prestige, /prestigeshop, /prestigebuy
|
│ ├── economy_prestige_commands.py# /prestige, /prestigeshop, /prestigebuy
|
||||||
│ ├── economy_profile_commands.py # /balance, /rank, /stats, /cooldowns, /leaderboard
|
│ ├── 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
|
│ ├── economy_support_commands.py # /shop, /buy, /give, /economysetup
|
||||||
│ ├── info_commands.py # /patchnotes, /help auxiliaries
|
│ ├── info_commands.py # /patchnotes, /help auxiliaries
|
||||||
│ ├── ops_admin_commands.py # /sync, /restart, /shutdown, /pause, /send, /status
|
│ ├── 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
|
│ └── POCKETBASE_SETUP.md # PocketBase collection schema + setup instructions
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ ├── migrate_to_pb.py # One-time legacy migration: economy.json → PocketBase
|
│ ├── 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)
|
│ └── reset_pb_collections.py # Destructive: deletes & recreates economy collections (--confirm required)
|
||||||
├── requirements.txt # Python dependencies
|
├── requirements.txt # Python dependencies
|
||||||
├── .env.example # Template for secrets
|
├── .env.example # Template for secrets
|
||||||
|
|||||||
172
core/economy.py
172
core/economy.py
@@ -7,6 +7,7 @@ All public async functions are the single source of truth for mutations.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import functools
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import random
|
import random
|
||||||
@@ -465,25 +466,81 @@ def _default_user() -> UserData:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
_log = logging.getLogger("tipiCOIN.economy")
|
_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 account (bot user)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
HOUSE_ID: int | None = None
|
HOUSE_ID: int | None = None
|
||||||
|
_house_pb_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def set_house(user_id: int) -> None:
|
def set_house(user_id: int) -> None:
|
||||||
"""Register the bot's Discord user ID as the house account."""
|
"""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
|
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:
|
async def _credit_house(amount: int) -> None:
|
||||||
"""Add `amount` coins to the house account. No-op if house not set."""
|
"""Add `amount` coins to the house via an atomic PocketBase increment.
|
||||||
if HOUSE_ID is None or amount <= 0:
|
|
||||||
|
Deliberately lock-free: callers hold per-user locks, so this must never
|
||||||
|
acquire one itself (see the locking rules above)."""
|
||||||
|
if amount <= 0:
|
||||||
return
|
return
|
||||||
user = await get_user(HOUSE_ID)
|
record_id = await _house_record_id()
|
||||||
user["balance"] = user.get("balance", 0) + amount
|
if record_id is None:
|
||||||
await _commit(HOUSE_ID, user)
|
return
|
||||||
|
await pb_client.update_record(record_id, {"balance+": amount})
|
||||||
|
|
||||||
|
|
||||||
async def get_heist_global_cd() -> float:
|
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:
|
async def set_heist_global_cd(until: float) -> None:
|
||||||
"""Persist heist global cooldown expiry to the house account in PocketBase."""
|
"""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
|
return
|
||||||
house = await get_user(HOUSE_ID)
|
await pb_client.update_record(record_id, {"heist_global_cd_until": until})
|
||||||
house["heist_global_cd_until"] = until
|
|
||||||
await _commit(HOUSE_ID, house)
|
|
||||||
|
|
||||||
|
|
||||||
|
@_locked_by(0)
|
||||||
async def do_spam_jail(user_id: int) -> None:
|
async def do_spam_jail(user_id: int) -> None:
|
||||||
"""Jail a user for 30 minutes due to suspected automated command spam."""
|
"""Jail a user for 30 minutes due to suspected automated command spam."""
|
||||||
user = await get_user(user_id)
|
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]
|
return entries if top_n is None else entries[:top_n]
|
||||||
|
|
||||||
|
|
||||||
|
@_locked_by(0)
|
||||||
async def award_exp(user_id: int, amount: int) -> dict:
|
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."""
|
"""Add EXP to a user. Applies prestige exp_mult. Returns old_level, new_level, total exp."""
|
||||||
user = await get_user(user_id)
|
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
|
# 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]
|
record_id = user.get("_pb_id") # type: ignore[typeddict-item]
|
||||||
clean = {k: v for k, v in user.items() if k != "_pb_id"}
|
clean = {k: v for k, v in user.items() if k != "_pb_id"}
|
||||||
clean["user_id"] = str(user_id)
|
clean["user_id"] = str(user_id)
|
||||||
try:
|
try:
|
||||||
if record_id:
|
if record_id:
|
||||||
await pb_client.update_record(record_id, clean)
|
return await pb_client.update_record(record_id, clean)
|
||||||
else:
|
else:
|
||||||
_log.warning("_commit for user %s had no _pb_id; creating new record", user_id)
|
_log.warning("_commit for user %s had no _pb_id; creating new record", user_id)
|
||||||
created = await pb_client.create_record(clean)
|
created = await pb_client.create_record(clean)
|
||||||
user["_pb_id"] = created["id"] # type: ignore[typeddict-unknown-key]
|
user["_pb_id"] = created["id"] # type: ignore[typeddict-unknown-key]
|
||||||
|
return created
|
||||||
except (aiohttp.ClientError, asyncio.TimeoutError, RuntimeError) as exc:
|
except (aiohttp.ClientError, asyncio.TimeoutError, RuntimeError) as exc:
|
||||||
_log.error("_commit failed for user %s: %s", user_id, exc)
|
_log.error("_commit failed for user %s: %s", user_id, exc)
|
||||||
raise DatabaseError(f"Failed to persist user {user_id}: {exc}") from 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).
|
# 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
|
# 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).
|
# 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):
|
class QuestDef(TypedDict):
|
||||||
stat: str # UserData counter field the quest tracks
|
stat: str # UserData counter field the quest tracks
|
||||||
goal: int
|
goal: int
|
||||||
@@ -758,9 +819,10 @@ def _pick_quests(pool: dict[str, QuestDef], count: int, seed: str) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def _new_quest_block(
|
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:
|
) -> dict:
|
||||||
chosen = _pick_quests(pool, count, f"{period_field}:{period_val}")
|
chosen = _pick_quests(pool, count, f"{user_id}:{period_field}:{period_val}")
|
||||||
return {
|
return {
|
||||||
period_field: period_val,
|
period_field: period_val,
|
||||||
"quests": {
|
"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.
|
"""Roll fresh daily/weekly quest sets if their period elapsed.
|
||||||
Mutates `user` in place; returns True if anything changed (caller commits)."""
|
Mutates `user` in place; returns True if anything changed (caller commits)."""
|
||||||
changed = False
|
changed = False
|
||||||
day_key, week_key = _period_keys()
|
day_key, week_key = _period_keys()
|
||||||
if (user.get("quest_daily") or {}).get("date") != day_key:
|
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
|
changed = True
|
||||||
if (user.get("quest_weekly") or {}).get("week") != week_key:
|
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
|
changed = True
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
@@ -808,20 +870,27 @@ def _quest_view(user: UserData) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@_locked_by(0)
|
||||||
async def get_quests(user_id: int) -> dict:
|
async def get_quests(user_id: int) -> dict:
|
||||||
"""Return the user's active quests, rolling new sets if the period elapsed."""
|
"""Return the user's active quests, rolling new sets if the period elapsed."""
|
||||||
user = await get_user(user_id)
|
user = await get_user(user_id)
|
||||||
if _ensure_quests(user):
|
if _ensure_quests(user, user_id):
|
||||||
await _commit(user_id, user)
|
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)
|
return _quest_view(user)
|
||||||
|
|
||||||
|
|
||||||
|
@_locked_by(0)
|
||||||
async def claim_quests(user_id: int) -> dict:
|
async def claim_quests(user_id: int) -> dict:
|
||||||
"""Grant coins for every completed-but-unclaimed quest and mark them claimed.
|
"""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
|
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)."""
|
caller to award via the shared award_exp path (keeps level-up notices)."""
|
||||||
user = await get_user(user_id)
|
user = await get_user(user_id)
|
||||||
_ensure_quests(user)
|
_ensure_quests(user, user_id)
|
||||||
coin_mult, _ = _prestige_mult(user)
|
coin_mult, _ = _prestige_mult(user)
|
||||||
total_coins = total_exp = claimed = 0
|
total_coins = total_exp = claimed = 0
|
||||||
for pool, block in (
|
for pool, block in (
|
||||||
@@ -850,6 +919,7 @@ async def claim_quests(user_id: int) -> dict:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# /daily
|
# /daily
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
@_locked_by(0)
|
||||||
async def do_daily(user_id: int) -> dict:
|
async def do_daily(user_id: int) -> dict:
|
||||||
try:
|
try:
|
||||||
user = await get_user(user_id)
|
user = await get_user(user_id)
|
||||||
@@ -929,6 +999,7 @@ async def do_daily(user_id: int) -> dict:
|
|||||||
_WORK_JOBS = strings.WORK_JOBS
|
_WORK_JOBS = strings.WORK_JOBS
|
||||||
|
|
||||||
|
|
||||||
|
@_locked_by(0)
|
||||||
async def do_work(user_id: int) -> dict:
|
async def do_work(user_id: int) -> dict:
|
||||||
try:
|
try:
|
||||||
user = await get_user(user_id)
|
user = await get_user(user_id)
|
||||||
@@ -982,6 +1053,7 @@ _BEG_LINES = strings.BEG_LINES
|
|||||||
_BEG_JAIL_LINES = strings.BEG_JAIL_LINES
|
_BEG_JAIL_LINES = strings.BEG_JAIL_LINES
|
||||||
|
|
||||||
|
|
||||||
|
@_locked_by(0)
|
||||||
async def do_beg(user_id: int) -> dict:
|
async def do_beg(user_id: int) -> dict:
|
||||||
try:
|
try:
|
||||||
user = await get_user(user_id)
|
user = await get_user(user_id)
|
||||||
@@ -1019,6 +1091,7 @@ async def do_beg(user_id: int) -> dict:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# /fish
|
# /fish
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
@_locked_by(0)
|
||||||
async def do_fish_start(user_id: int) -> dict:
|
async def do_fish_start(user_id: int) -> dict:
|
||||||
"""Check cooldown + jail, set cooldown. Call before starting the fishing minigame."""
|
"""Check cooldown + jail, set cooldown. Call before starting the fishing minigame."""
|
||||||
try:
|
try:
|
||||||
@@ -1039,6 +1112,7 @@ async def do_fish_start(user_id: int) -> dict:
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@_locked_by(0)
|
||||||
async def do_fish_resolve(user_id: int, fish_id: str, weight: int) -> dict:
|
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."""
|
"""Add catch to inventory + update fish_book. Returns catch info incl. pre-calculated value."""
|
||||||
user = await get_user(user_id)
|
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:
|
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."""
|
"""Sell fish from inventory. indices=None sells all. Returns coins earned."""
|
||||||
user = await get_user(user_id)
|
user = await get_user(user_id)
|
||||||
@@ -1144,6 +1219,7 @@ async def do_fishbook(user_id: int) -> dict:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# /prestige
|
# /prestige
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
@_locked_by(0)
|
||||||
async def do_prestige(user_id: int) -> dict:
|
async def do_prestige(user_id: int) -> dict:
|
||||||
"""Prestige: requires level 30, earns PP, resets balance/exp/items/cooldowns."""
|
"""Prestige: requires level 30, earns PP, resets balance/exp/items/cooldowns."""
|
||||||
try:
|
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:
|
async def do_prestige_buy(user_id: int, upgrade_id: str) -> dict:
|
||||||
"""Spend PP to buy a prestige upgrade level."""
|
"""Spend PP to buy a prestige upgrade level."""
|
||||||
if upgrade_id not in PRESTIGE_SHOP:
|
if upgrade_id not in PRESTIGE_SHOP:
|
||||||
@@ -1293,6 +1370,7 @@ _CRIME_WIN = strings.CRIME_WIN
|
|||||||
_CRIME_LOSE = strings.CRIME_LOSE
|
_CRIME_LOSE = strings.CRIME_LOSE
|
||||||
|
|
||||||
|
|
||||||
|
@_locked_by(0)
|
||||||
async def do_crime(user_id: int) -> dict:
|
async def do_crime(user_id: int) -> dict:
|
||||||
try:
|
try:
|
||||||
user = await get_user(user_id)
|
user = await get_user(user_id)
|
||||||
@@ -1349,6 +1427,7 @@ async def do_crime(user_id: int) -> dict:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# /jailbreak (Monopoly-style dice rolls)
|
# /jailbreak (Monopoly-style dice rolls)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
@_locked_by(0)
|
||||||
async def set_jailbreak_used(user_id: int) -> None:
|
async def set_jailbreak_used(user_id: int) -> None:
|
||||||
"""Mark that the user has consumed their dice attempt for this jail sentence."""
|
"""Mark that the user has consumed their dice attempt for this jail sentence."""
|
||||||
user = await get_user(user_id)
|
user = await get_user(user_id)
|
||||||
@@ -1356,6 +1435,7 @@ async def set_jailbreak_used(user_id: int) -> None:
|
|||||||
await _commit(user_id, user)
|
await _commit(user_id, user)
|
||||||
|
|
||||||
|
|
||||||
|
@_locked_by(0)
|
||||||
async def do_jail_free(user_id: int) -> dict:
|
async def do_jail_free(user_id: int) -> dict:
|
||||||
"""Remove jail status after rolling doubles."""
|
"""Remove jail status after rolling doubles."""
|
||||||
user = await get_user(user_id)
|
user = await get_user(user_id)
|
||||||
@@ -1368,6 +1448,7 @@ async def do_jail_free(user_id: int) -> dict:
|
|||||||
|
|
||||||
MIN_BAIL = 350
|
MIN_BAIL = 350
|
||||||
|
|
||||||
|
@_locked_by(0)
|
||||||
async def do_bail(user_id: int) -> dict:
|
async def do_bail(user_id: int) -> dict:
|
||||||
"""Charge bail fine after exhausting jailbreak rolls and free the user.
|
"""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."""
|
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
|
# /rob
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
@_locked_by(0, 1)
|
||||||
async def do_rob(robber_id: int, target_id: int) -> dict:
|
async def do_rob(robber_id: int, target_id: int) -> dict:
|
||||||
try:
|
try:
|
||||||
robber = await get_user(robber_id)
|
robber = await get_user(robber_id)
|
||||||
@@ -1490,6 +1572,7 @@ async def do_rob(robber_id: int, target_id: int) -> dict:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# /roulette
|
# /roulette
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
@_locked_by(0)
|
||||||
async def do_roulette(user_id: int, bet: int, colour: str) -> dict:
|
async def do_roulette(user_id: int, bet: int, colour: str) -> dict:
|
||||||
try:
|
try:
|
||||||
user = await get_user(user_id)
|
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)
|
# /rps (bet resolution)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
@_locked_by(0)
|
||||||
async def do_game_bet(user_id: int, bet: int, outcome: str) -> dict:
|
async def do_game_bet(user_id: int, bet: int, outcome: str) -> dict:
|
||||||
"""Settle a simple win/tie/lose bet. outcome: 'win' | 'tie' | 'lose'."""
|
"""Settle a simple win/tie/lose bet. outcome: 'win' | 'tie' | 'lose'."""
|
||||||
try:
|
try:
|
||||||
@@ -1565,6 +1649,7 @@ async def do_game_bet(user_id: int, bet: int, outcome: str) -> dict:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# /rps PvP escrow (deposit/payout/refund)
|
# /rps PvP escrow (deposit/payout/refund)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
@_locked_by(0)
|
||||||
async def do_rps_pvp_deposit(user_id: int, bet: int) -> dict:
|
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."""
|
"""Hold `bet` coins from a player as escrow for a PvP RPS duel."""
|
||||||
try:
|
try:
|
||||||
@@ -1587,6 +1672,7 @@ async def do_rps_pvp_deposit(user_id: int, bet: int) -> dict:
|
|||||||
return {"ok": True, "balance": user["balance"]}
|
return {"ok": True, "balance": user["balance"]}
|
||||||
|
|
||||||
|
|
||||||
|
@_locked_by(0)
|
||||||
async def do_rps_pvp_payout(winner_id: int, bet: int) -> dict:
|
async def do_rps_pvp_payout(winner_id: int, bet: int) -> dict:
|
||||||
"""Credit the duel winner with 2*bet (their stake back + opponent's)."""
|
"""Credit the duel winner with 2*bet (their stake back + opponent's)."""
|
||||||
try:
|
try:
|
||||||
@@ -1606,6 +1692,7 @@ async def do_rps_pvp_payout(winner_id: int, bet: int) -> dict:
|
|||||||
return {"ok": True, "balance": user["balance"]}
|
return {"ok": True, "balance": user["balance"]}
|
||||||
|
|
||||||
|
|
||||||
|
@_locked_by(0)
|
||||||
async def do_rps_pvp_refund(user_id: int, bet: int) -> dict:
|
async def do_rps_pvp_refund(user_id: int, bet: int) -> dict:
|
||||||
"""Refund a previously escrowed bet (tie / timeout / cancel)."""
|
"""Refund a previously escrowed bet (tie / timeout / cancel)."""
|
||||||
try:
|
try:
|
||||||
@@ -1649,6 +1736,7 @@ def _spin() -> str:
|
|||||||
return random.choices(list(symbols), weights=list(weights), k=1)[0]
|
return random.choices(list(symbols), weights=list(weights), k=1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
@_locked_by(0)
|
||||||
async def do_slots(user_id: int, bet: int) -> dict:
|
async def do_slots(user_id: int, bet: int) -> dict:
|
||||||
try:
|
try:
|
||||||
user = await get_user(user_id)
|
user = await get_user(user_id)
|
||||||
@@ -1705,6 +1793,7 @@ async def do_slots(user_id: int, bet: int) -> dict:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# /give
|
# /give
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
@_locked_by(0, 1)
|
||||||
async def do_give(giver_id: int, receiver_id: int, amount: int) -> dict:
|
async def do_give(giver_id: int, receiver_id: int, amount: int) -> dict:
|
||||||
try:
|
try:
|
||||||
giver = await get_user(giver_id)
|
giver = await get_user(giver_id)
|
||||||
@@ -1756,6 +1845,7 @@ async def do_give(giver_id: int, receiver_id: int, amount: int) -> dict:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# /buy
|
# /buy
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
@_locked_by(0)
|
||||||
async def do_buy(user_id: int, item_id: str) -> dict:
|
async def do_buy(user_id: int, item_id: str) -> dict:
|
||||||
if item_id not in SHOP:
|
if item_id not in SHOP:
|
||||||
return {"ok": False, "reason": "not_found"}
|
return {"ok": False, "reason": "not_found"}
|
||||||
@@ -1797,6 +1887,7 @@ async def do_buy(user_id: int, item_id: str) -> dict:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Admin actions
|
# Admin actions
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
@_locked_by(0)
|
||||||
async def do_admin_coins(target_id: int, amount: int, admin_id: int, reason: str) -> dict:
|
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."""
|
"""Give (positive) or take (negative) coins from a user. Balance is floored at 0."""
|
||||||
user = await get_user(target_id)
|
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}
|
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:
|
async def do_admin_jail(target_id: int, minutes: int, admin_id: int, reason: str) -> dict:
|
||||||
"""Manually jail a user for `minutes` minutes."""
|
"""Manually jail a user for `minutes` minutes."""
|
||||||
user = await get_user(target_id)
|
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"]}
|
return {"ok": True, "jailed_until": user["jailed_until"]}
|
||||||
|
|
||||||
|
|
||||||
|
@_locked_by(0)
|
||||||
async def do_admin_unjail(target_id: int, admin_id: int) -> dict:
|
async def do_admin_unjail(target_id: int, admin_id: int) -> dict:
|
||||||
"""Remove jail from a user."""
|
"""Remove jail from a user."""
|
||||||
user = await get_user(target_id)
|
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}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@_locked_by(0)
|
||||||
async def do_admin_ban(target_id: int, admin_id: int, reason: str) -> dict:
|
async def do_admin_ban(target_id: int, admin_id: int, reason: str) -> dict:
|
||||||
"""Ban a user from all economy commands."""
|
"""Ban a user from all economy commands."""
|
||||||
user = await get_user(target_id)
|
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}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@_locked_by(0)
|
||||||
async def do_admin_unban(target_id: int, admin_id: int) -> dict:
|
async def do_admin_unban(target_id: int, admin_id: int) -> dict:
|
||||||
"""Lift an economy ban."""
|
"""Lift an economy ban."""
|
||||||
user = await get_user(target_id)
|
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}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@_locked_by(0)
|
||||||
async def do_admin_reset(target_id: int, admin_id: int) -> dict:
|
async def do_admin_reset(target_id: int, admin_id: int) -> dict:
|
||||||
"""Wipe a user's economy data back to defaults."""
|
"""Wipe a user's economy data back to defaults."""
|
||||||
user = await get_user(target_id)
|
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)}
|
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:
|
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."""
|
"""Give (positive) or take (negative) EXP from a user. EXP is floored at 0."""
|
||||||
user = await get_user(target_id)
|
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:
|
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."""
|
"""Give or remove an item. action='give'|'remove'. Returns ok/reason."""
|
||||||
if item_id not in SHOP:
|
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
|
# /reminders
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
@_locked_by(0)
|
||||||
async def do_set_reminders(user_id: int, commands: list[str]) -> None:
|
async def do_set_reminders(user_id: int, commands: list[str]) -> None:
|
||||||
"""Overwrite the user's reminder list with the given command names."""
|
"""Overwrite the user's reminder list with the given command names."""
|
||||||
user = await get_user(user_id)
|
user = await get_user(user_id)
|
||||||
@@ -1925,6 +2024,7 @@ async def do_set_reminders(user_id: int, commands: list[str]) -> None:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# /blackjack
|
# /blackjack
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
@_locked_by(0)
|
||||||
async def do_blackjack_bet(user_id: int, bet: int) -> dict:
|
async def do_blackjack_bet(user_id: int, bet: int) -> dict:
|
||||||
"""Deduct the initial blackjack bet. Returns ok/fail."""
|
"""Deduct the initial blackjack bet. Returns ok/fail."""
|
||||||
try:
|
try:
|
||||||
@@ -1942,6 +2042,7 @@ async def do_blackjack_bet(user_id: int, bet: int) -> dict:
|
|||||||
return {"ok": True, "balance": user["balance"]}
|
return {"ok": True, "balance": user["balance"]}
|
||||||
|
|
||||||
|
|
||||||
|
@_locked_by(0)
|
||||||
async def do_blackjack_payout(user_id: int, payout: int, total_invested: int = 0) -> dict:
|
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."""
|
"""Credit the net payout. House receives the difference when payout < total_invested."""
|
||||||
user = await get_user(user_id)
|
user = await get_user(user_id)
|
||||||
@@ -2009,19 +2110,20 @@ async def do_heist_resolve(user_ids: list[int], success: bool) -> dict:
|
|||||||
if success and HOUSE_ID is not None:
|
if success and HOUSE_ID is not None:
|
||||||
try:
|
try:
|
||||||
house = await get_user(HOUSE_ID)
|
house = await get_user(HOUSE_ID)
|
||||||
except DatabaseError:
|
|
||||||
return {"ok": False, "reason": "db_error"}
|
|
||||||
pct = random.uniform(0.20, 0.55)
|
pct = random.uniform(0.20, 0.55)
|
||||||
total = max(300, int(house["balance"] * pct))
|
total = max(300, int(house["balance"] * pct))
|
||||||
payout_each = total // len(user_ids)
|
payout_each = total // len(user_ids)
|
||||||
house["balance"] = max(0, house["balance"] - total)
|
# Atomic decrement (capped at the balance we read) instead of a full
|
||||||
try:
|
# record commit, so concurrent _credit_house increments aren't lost.
|
||||||
await _commit(HOUSE_ID, house)
|
debit = min(total, house["balance"])
|
||||||
|
if debit > 0:
|
||||||
|
await pb_client.update_record(house["_pb_id"], {"balance-": debit}) # type: ignore[typeddict-item]
|
||||||
except DatabaseError:
|
except DatabaseError:
|
||||||
return {"ok": False, "reason": "db_error"}
|
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:
|
for uid in user_ids:
|
||||||
|
async with _user_lock(uid):
|
||||||
try:
|
try:
|
||||||
user = await get_user(uid)
|
user = await get_user(uid)
|
||||||
except DatabaseError:
|
except DatabaseError:
|
||||||
@@ -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:
|
if HOUSE_ID is None or amount <= 0:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
house = await get_user(HOUSE_ID)
|
await _credit_house(amount)
|
||||||
house["balance"] = house.get("balance", 0) + amount
|
|
||||||
await _commit(HOUSE_ID, house)
|
|
||||||
except DatabaseError as exc:
|
except DatabaseError as exc:
|
||||||
_log.critical(
|
_log.critical(
|
||||||
"House compensation failed (%s, related uid %s, amount %s): %s",
|
"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:
|
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).
|
"""Best-effort atomic debit of `amount` from the house (compensates a failed
|
||||||
|
user fine). Logs critical if it fails."""
|
||||||
Reads house, subtracts amount, commits. Logs critical if it fails.
|
|
||||||
"""
|
|
||||||
if HOUSE_ID is None or amount <= 0:
|
if HOUSE_ID is None or amount <= 0:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
house = await get_user(HOUSE_ID)
|
record_id = await _house_record_id()
|
||||||
house["balance"] = max(0, house.get("balance", 0) - amount)
|
if record_id:
|
||||||
await _commit(HOUSE_ID, house)
|
await pb_client.update_record(record_id, {"balance-": amount})
|
||||||
except DatabaseError as exc:
|
except DatabaseError as exc:
|
||||||
_log.critical(
|
_log.critical(
|
||||||
"House debit compensation failed (%s, related uid %s, amount %s): %s",
|
"House debit compensation failed (%s, related uid %s, amount %s): %s",
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ Format each version with a `## ` header (e.g. `## v0.1.0 — 2026-05-03`).
|
|||||||
|
|
||||||
## v0.2.0 — 2026-07-22
|
## 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
|
## v0.1.0 — 2026-05-03
|
||||||
|
|
||||||
|
|||||||
2
requirements-dev.txt
Normal file
2
requirements-dev.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest>=8.0
|
||||||
@@ -25,7 +25,9 @@ import config # noqa: E402
|
|||||||
PB_URL = config.PB_URL
|
PB_URL = config.PB_URL
|
||||||
PB_ADMIN_EMAIL = config.PB_ADMIN_EMAIL
|
PB_ADMIN_EMAIL = config.PB_ADMIN_EMAIL
|
||||||
PB_ADMIN_PASSWORD = config.PB_ADMIN_PASSWORD
|
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
|
# New fields to add
|
||||||
@@ -55,13 +57,16 @@ async def main() -> None:
|
|||||||
|
|
||||||
hdrs = {"Authorization": token}
|
hdrs = {"Authorization": token}
|
||||||
|
|
||||||
# ── Fetch current collection ─────────────────────────────────────────
|
for collection in COLLECTIONS:
|
||||||
|
print(f"── {collection} ──")
|
||||||
|
|
||||||
|
# ── Fetch current collection ─────────────────────────────────────
|
||||||
async with session.get(
|
async with session.get(
|
||||||
f"{PB_URL}/api/collections/{COLLECTION}", headers=hdrs
|
f"{PB_URL}/api/collections/{collection}", headers=hdrs
|
||||||
) as resp:
|
) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
print(f"Could not fetch collection ({resp.status}): {await resp.text()}")
|
print(f"Could not fetch collection ({resp.status}): {await resp.text()}\n")
|
||||||
return
|
continue
|
||||||
col = await resp.json()
|
col = await resp.json()
|
||||||
|
|
||||||
existing = {f["name"] for f in col.get("fields", [])}
|
existing = {f["name"] for f in col.get("fields", [])}
|
||||||
@@ -76,20 +81,20 @@ async def main() -> None:
|
|||||||
print(f" = {name} (already exists)")
|
print(f" = {name} (already exists)")
|
||||||
|
|
||||||
if not new_fields:
|
if not new_fields:
|
||||||
print("\nNothing to add - schema already up to date.")
|
print("Nothing to add - schema already up to date.\n")
|
||||||
return
|
continue
|
||||||
|
|
||||||
# ── Patch collection schema ──────────────────────────────────────────
|
# ── Patch collection schema ──────────────────────────────────────
|
||||||
updated_fields = col.get("fields", []) + new_fields
|
updated_fields = col.get("fields", []) + new_fields
|
||||||
async with session.patch(
|
async with session.patch(
|
||||||
f"{PB_URL}/api/collections/{COLLECTION}",
|
f"{PB_URL}/api/collections/{collection}",
|
||||||
json={"fields": updated_fields},
|
json={"fields": updated_fields},
|
||||||
headers=hdrs,
|
headers=hdrs,
|
||||||
) as resp:
|
) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
print(f"\nSchema update failed ({resp.status}): {await resp.text()}")
|
print(f"Schema update failed ({resp.status}): {await resp.text()}\n")
|
||||||
return
|
continue
|
||||||
print(f"\n✅ Added {len(new_fields)} field(s) successfully.")
|
print(f"✅ Added {len(new_fields)} field(s) successfully.\n")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
108
tests/conftest.py
Normal file
108
tests/conftest.py
Normal file
@@ -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)
|
||||||
204
tests/test_economy_flows.py
Normal file
204
tests/test_economy_flows.py
Normal file
@@ -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
|
||||||
86
tests/test_economy_pure.py
Normal file
86
tests/test_economy_pure.py
Normal file
@@ -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
|
||||||
116
tests/test_quests.py
Normal file
116
tests/test_quests.py
Normal file
@@ -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"])
|
||||||
Reference in New Issue
Block a user