17 Commits

Author SHA1 Message Date
Rene Arumetsa
8d16da268d Fix eco bot service
All checks were successful
Test & Deploy / test (push) Successful in 21s
Test & Deploy / deploy (push) Successful in 7s
2026-07-27 15:52:51 +03:00
Rene Arumetsa
4dd7379e03 Fix workflow
Some checks failed
Test & Deploy / test (push) Successful in 22s
Test & Deploy / deploy (push) Failing after 7s
2026-07-27 15:22:56 +03:00
Rene Arumetsa
7fc0ccc77f Fix deploy.yml
Some checks failed
Test & Deploy / test (push) Successful in 57s
Test & Deploy / deploy (push) Failing after 5s
2026-07-27 01:04:26 +03:00
Rene Arumetsa
83a60cda55 Refator eco code. Split into modules
Some checks failed
Test & Deploy / test (push) Has been cancelled
Test & Deploy / deploy (push) Has been cancelled
2026-07-27 00:49:34 +03:00
Rene Arumetsa
d89383ee20 CI test
Some checks failed
Test & Deploy / test (push) Failing after 12m44s
Test & Deploy / deploy (push) Has been cancelled
2026-07-27 00:25:25 +03:00
Rene Arumetsa
8884cce9b5 Added a runner, ci/cd 2026-07-26 21:45:23 +03:00
Rene Arumetsa
94fe57a596 Add startup scheme 2026-07-26 21:36:26 +03:00
Rene Arumetsa
c40c871a4b Sync pb 2026-07-26 21:29:35 +03:00
Rene Arumetsa
5ba5642694 Added tests, fix README. 2026-07-26 20:32:13 +03:00
Rene Arumetsa
cb18d9b882 Added quests 2026-07-26 20:11:14 +03:00
Rene Arumetsa
0cdd8dac63 Update env variables 2026-06-21 22:49:46 +03:00
Rene Arumetsa
6101a278e7 Admin check now a dict 2026-06-21 22:40:57 +03:00
Rene Arumetsa
1e9ec56761 Update tipidice emoij to be animated 2026-06-03 18:50:06 +03:00
Rene Arumetsa
a96488fe9e Added eco server emoij ID 2026-06-03 18:45:46 +03:00
Rene Arumetsa
25cf60d2e1 Fix conflict 2026-06-03 18:38:40 +03:00
Rene Arumetsa
3939c879c9 Refacor emoijs to use application emoijs 2026-06-03 18:34:29 +03:00
Rene Arumetsa
b0e23c1a17 Change admin command permissions 2026-06-01 22:11:44 +03:00
36 changed files with 3634 additions and 2097 deletions

View File

@@ -1,17 +1,35 @@
name: Deploy name: Test & Deploy
on: on:
push: push:
branches: [master] branches: [master]
jobs: jobs:
test:
runs-on: linux
steps:
- name: Run test suite
run: |
REPO=$(git -C /root/tipibot remote get-url origin)
rm -rf /tmp/tipibot-ci
git clone --quiet "$REPO" /tmp/tipibot-ci
cd /tmp/tipibot-ci
git checkout --quiet ${{ github.sha }}
python3 -m venv /opt/tipibot-ci-venv
/opt/tipibot-ci-venv/bin/pip install -q -r requirements-dev.txt
/opt/tipibot-ci-venv/bin/python -m pytest tests/ -q
deploy: deploy:
runs-on: linux runs-on: linux
needs: test
steps: steps:
- name: Deploy - name: Deploy
run: | run: |
cd ~/tipibot cd /root/tipibot
git pull git pull
source .venv/bin/activate source .venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
sudo systemctl restart tipibot sudo systemctl restart tipibot_dev # dev bot first, as canary
sleep 5
systemctl is-active --quiet tipibot_dev # dev crashed on startup -> abort
sudo systemctl restart tipibot_eco # eco bot only if dev survived

View File

@@ -70,7 +70,7 @@ The economy system stores all player data in [PocketBase](https://pocketbase.io/
1. Download `pocketbase.exe` (Windows) from https://pocketbase.io/docs/ and place it in the project root. 1. Download `pocketbase.exe` (Windows) from https://pocketbase.io/docs/ and place it in the project root.
2. Start PocketBase: `.\pocketbase.exe serve` 2. Start PocketBase: `.\pocketbase.exe serve`
3. Open the admin UI at http://127.0.0.1:8090/_/ and create a superuser account. 3. Open the admin UI at http://127.0.0.1:8090/_/ and create a superuser account.
4. Create two collections for profile separation: `economy_users_dev` and `economy_users_prod` - see `docs/POCKETBASE_SETUP.md` for schema notes. 4. Create two collections for profile separation: `economy_users_dev` and `economy_users_prod` - see `docs/POCKETBASE_SETUP.md` for schema notes, then run `python scripts/sync_pb_schema.py` to add every field the bot persists. Re-run it after any update that introduces new fields (PocketBase silently drops writes to fields missing from the schema).
5. Set `PB_URL`, `PB_ADMIN_EMAIL`, `PB_ADMIN_PASSWORD` in `.env`. 5. Set `PB_URL`, `PB_ADMIN_EMAIL`, `PB_ADMIN_PASSWORD` in `.env`.
6. **One-time data migration** (only if you have an existing `data/economy.json`): `python scripts/migrate_to_pb.py` 6. **One-time data migration** (only if you have an existing `data/economy.json`): `python scripts/migrate_to_pb.py`
@@ -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 | 1575 ⬡ | 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 | 1575 ⬡ | 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 | 1040 ⬡ | XL hiirematt reduces cooldown to 3min. Mehhaaniline klaviatuur multiplies earnings ×2. | | `/beg` | 5min | 1040 ⬡ | Hiirematt reduces cooldown to 3min. Mehaaniline klaviatuur multiplies earnings ×2. |
| `/crime` | 2h | 200500 ⬡ | 60% success rate (75% with CAT6). +30% earnings with Mikrofon on win. Fail = fine + 30min jail. Mänguritool skips jail on fail. | | `/crime` | 2h | 200500 ⬡ | 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
| 713 days | ×2.0 | 300 ⬡ | | 713 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 23, uncommon 67, rare 10, epic 1415, legendary 25) | | `/fish` catch | +2 to +25 (varies by rarity: common 23, uncommon 67, rare 10, epic 1415, 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/sync_pb_schema.py` once (it reconciles both the dev and prod collections with everything the bot persists).
---
### 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 |
--- ---
@@ -477,7 +486,20 @@ Every slash command invocation is logged with the user ID, display name, and all
├── strings.py # All user-facing strings (command descriptions, help text, errors) ├── strings.py # All user-facing strings (command descriptions, help text, errors)
├── config.py # Environment variable loader ├── config.py # Environment variable loader
├── core/ ├── core/
│ ├── economy.py # TipiCOIN business logic, constants (SHOP, COOLDOWNS, EXP_REWARDS, ...) │ ├── economy/ # TipiCOIN business logic (re-exported via core/economy/__init__.py)
│ │ ├── store.py # User records, per-user locks, time/cooldown helpers, txn log
│ │ ├── house.py # House account (fines in, heists out)
│ │ ├── levels.py # EXP, levels, vanity roles
│ │ ├── shop.py # Item catalogue + /buy
│ │ ├── income.py # /daily, /work, /beg, /crime, /rob, /give
│ │ ├── gambling.py # /roulette, /slots, /rps, /blackjack
│ │ ├── fishing.py # Fish catalogue + minigame flows
│ │ ├── quests.py # Daily/weekly quest system
│ │ ├── prestige.py # Prestige resets + upgrade shop
│ │ ├── jail.py # Jail, jailbreak, bail
│ │ ├── heist.py # Group heists against the house
│ │ ├── leaderboards.py # All leaderboard queries
│ │ └── admin.py # Admin mutations + season reset
│ ├── pb_client.py # Async PocketBase REST client (auth + CRUD for economy_users) │ ├── pb_client.py # Async PocketBase REST client (auth + CRUD for economy_users)
│ ├── sheets.py # Google Sheets read/write + in-memory cache │ ├── sheets.py # Google Sheets read/write + in-memory cache
│ └── member_sync.py # Role/nickname/birthday sync logic │ └── member_sync.py # Role/nickname/birthday sync logic
@@ -491,6 +513,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 +524,9 @@ 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 │ ├── sync_pb_schema.py # Reconcile PB collections with the full user schema (dev + prod)
│ ├── add_stats_fields.py # Older partial schema migration (superseded by sync_pb_schema.py)
│ ├── add_quest_fields.py # Older partial schema migration (superseded by sync_pb_schema.py)
│ └── 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

72
bot.py
View File

@@ -32,6 +32,7 @@ from commands.economy_fish_commands import register_economy_fish_commands
from commands.economy_games_commands import register_economy_games_commands from commands.economy_games_commands import register_economy_games_commands
from commands.economy_income_commands import register_economy_income_commands from commands.economy_income_commands import register_economy_income_commands
from commands.economy_prestige_commands import register_prestige_commands from commands.economy_prestige_commands import register_prestige_commands
from commands.economy_quests_commands import register_economy_quests_commands
from commands.economy_profile_commands import register_economy_profile_commands from commands.economy_profile_commands import register_economy_profile_commands
from commands.economy_support_commands import register_economy_support_commands from commands.economy_support_commands import register_economy_support_commands
from commands.ops_channel_commands import register_ops_channel_commands from commands.ops_channel_commands import register_ops_channel_commands
@@ -243,7 +244,6 @@ async def _award_exp(interaction: discord.Interaction, amount: int) -> None:
pass pass
@tree.interaction_check
async def _log_command(interaction: discord.Interaction) -> bool: async def _log_command(interaction: discord.Interaction) -> bool:
"""Log every slash command invocation and enforce allowed-channel restriction.""" """Log every slash command invocation and enforce allowed-channel restriction."""
if interaction.command: if interaction.command:
@@ -286,6 +286,11 @@ async def _log_command(interaction: discord.Interaction) -> bool:
return False return False
# CommandTree.interaction_check is a method meant to be overridden, not a
# decorator - `@tree.interaction_check` silently registers nothing.
tree.interaction_check = _log_command
def _load_bday_log() -> dict: def _load_bday_log() -> dict:
try: try:
return json.loads(_BDAY_LOG.read_text(encoding="utf-8")) return json.loads(_BDAY_LOG.read_text(encoding="utf-8"))
@@ -336,6 +341,8 @@ async def before_birthday_daily():
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_presence_index = 0 _presence_index = 0
_economy_count: int = 0 _economy_count: int = 0
_economy_count_fetched: float = 0.0
_ECONOMY_COUNT_TTL = 300 # seconds; the player count changes rarely
_PRESENCES: list = [ _PRESENCES: list = [
lambda g: discord.Activity( lambda g: discord.Activity(
type=discord.ActivityType.watching, type=discord.ActivityType.watching,
@@ -357,14 +364,20 @@ _PRESENCES: list = [
@tasks.loop(seconds=20) @tasks.loop(seconds=20)
async def _rotate_presence() -> None: async def _rotate_presence() -> None:
global _presence_index, _economy_count global _presence_index, _economy_count, _economy_count_fetched
guild = bot.get_guild(config.GUILD_ID) guild = bot.get_guild(config.GUILD_ID)
try: if time.monotonic() - _economy_count_fetched > _ECONOMY_COUNT_TTL:
_economy_count = await pb_client.count_records() try:
except Exception as e: _economy_count = await pb_client.count_records()
log.warning("Presence: failed to fetch economy count: %s", e) _economy_count_fetched = time.monotonic()
except Exception as e:
log.warning("Presence: failed to fetch economy count: %s", e)
activity = _PRESENCES[_presence_index % len(_PRESENCES)](guild) activity = _PRESENCES[_presence_index % len(_PRESENCES)](guild)
await bot.change_presence(status=discord.Status.online, activity=activity) try:
await bot.change_presence(status=discord.Status.online, activity=activity)
except Exception as e:
# gateway may be mid-reconnect; skip this rotation instead of tracebacking
log.warning("Presence update skipped: %s", e)
_presence_index += 1 _presence_index += 1
@@ -381,6 +394,22 @@ async def on_ready():
"""Load sheet data and sync slash commands on startup.""" """Load sheet data and sync slash commands on startup."""
log.info("Logged in as %s (ID: %s)", bot.user, bot.user.id) log.info("Logged in as %s (ID: %s)", bot.user, bot.user.id)
economy.set_house(bot.user.id) economy.set_house(bot.user.id)
_log_config_gaps()
# PocketBase silently drops writes to fields missing from the collection
# schema, so surface any drift loudly instead of letting features no-op.
try:
missing = await economy.missing_schema_fields()
if missing:
log.error(
"PocketBase collection '%s' is missing %d schema field(s): %s "
"- writes to them are silently dropped! Run scripts/sync_pb_schema.py.",
config.PB_ECONOMY_COLLECTION, len(missing), ", ".join(missing),
)
else:
log.info("PocketBase schema check passed (%s)", config.PB_ECONOMY_COLLECTION)
except Exception as e:
log.warning("Could not verify PocketBase schema: %s", e)
_apply_profile_command_filters() _apply_profile_command_filters()
@@ -594,8 +623,7 @@ class HelpSelect(discord.ui.Select):
@tree.command(name="help", description=S.CMD["help"]) @tree.command(name="help", description=S.CMD["help"])
async def cmd_help(interaction: discord.Interaction): async def cmd_help(interaction: discord.Interaction):
member = interaction.user is_admin = is_bot_admin(interaction.user)
is_admin = isinstance(member, discord.Member) and is_bot_admin(member)
await interaction.response.send_message( await interaction.response.send_message(
embed=_help_embed("üldine"), view=HelpView(is_admin), ephemeral=True embed=_help_embed("üldine"), view=HelpView(is_admin), ephemeral=True
) )
@@ -816,6 +844,13 @@ register_economy_fish_commands(
active_games=_active_games, active_games=_active_games,
) )
register_economy_quests_commands(
tree,
bot,
coin=_coin,
award_exp=_award_exp,
)
register_economy_games_commands( register_economy_games_commands(
tree, tree,
coin=_coin, coin=_coin,
@@ -860,6 +895,25 @@ async def on_app_command_error(interaction: discord.Interaction, error: app_comm
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _log_config_gaps() -> None:
"""One startup line per unset optional config, so 'why doesn't X work on
prod' starts with a log grep instead of guesswork."""
gaps: list[str] = []
if IS_DEV_PROFILE:
if not config.SHEET_ID:
gaps.append("SHEET_ID not set - member sync/birthdays have no sheet")
if not config.BIRTHDAY_CHANNEL_ID:
gaps.append("BIRTHDAY_CHANNEL_ID not set - birthday pings disabled")
if not config.PB_ADMIN_EMAIL or not config.PB_ADMIN_PASSWORD:
gaps.append("PB_ADMIN_EMAIL/PB_ADMIN_PASSWORD not set - economy database writes will fail")
if not config.BOT_ADMIN_ROLES:
gaps.append("DISCORD_ADMIN_ROLES not set - role-based bot-admin checks disabled")
for gap in gaps:
log.warning("Config: %s", gap)
if not gaps:
log.info("Config: all expected settings present for profile '%s'", config.BOT_PROFILE)
def _log_sync_result(member: discord.Member, result: SyncResult): def _log_sync_result(member: discord.Member, result: SyncResult):
if result.nickname_changed: if result.nickname_changed:
log.info(" → Nickname set for %s", member) log.info(" → Nickname set for %s", member)

View File

@@ -10,6 +10,7 @@ import discord
from discord import app_commands from discord import app_commands
from core import economy from core import economy
from core.emoji import EMOJI as E
import strings as S import strings as S
@@ -288,12 +289,12 @@ def register_economy_extra_commands(
# /jailbreak - Monopoly-style dice escape # /jailbreak - Monopoly-style dice escape
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
_DICE_EMOJI = [ _DICE_EMOJI = [
"<:TipiYKS:1483103190491856916>", E["TipiYKS"],
"<:TipiKAKS:1483103215841972404>", E["TipiKAKS"],
"<:TipiKOLM:1483103217846980781>", E["TipiKOLM"],
"<:TipiNELI:1483103237585240114>", E["TipiNELI"],
"<:TipiVIIS:1483103239036469289>", E["TipiVIIS"],
"<:TipiKUUS:1483103253163020348>", E["TipiKUUS"],
] ]
class JailbreakView(discord.ui.View): class JailbreakView(discord.ui.View):

View File

@@ -9,6 +9,7 @@ import discord
from discord import app_commands from discord import app_commands
from core import economy from core import economy
from core.emoji import EMOJI as E
import strings as S import strings as S
@@ -611,7 +612,7 @@ def register_economy_games_commands(
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
# /slots # /slots
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
_SLOTS_SPIN = "<a:TipiSLOTS:1483444233863037101>" _SLOTS_SPIN = E["TipiSLOTS"]
_SLOTS_DELAY = 0.7 _SLOTS_DELAY = 0.7
def _slots_embed( def _slots_embed(

View File

@@ -0,0 +1,108 @@
from __future__ import annotations
import asyncio
import datetime
from collections.abc import Awaitable, Callable
import discord
from discord import app_commands
from core import economy
import strings as S
_BAR_SLOTS = 8
def _bar(progress: int, goal: int) -> str:
"""Return an 8-slot ▰/▱ progress bar for a quest."""
filled = 0 if goal <= 0 else min(_BAR_SLOTS, round(_BAR_SLOTS * progress / goal))
return "" * filled + "" * (_BAR_SLOTS - filled)
def _has_claimable(view_data: dict) -> bool:
return any(
q["done"] and not q["claimed"]
for q in view_data["daily"] + view_data["weekly"]
)
def register_economy_quests_commands(
tree: app_commands.CommandTree,
bot: discord.Client,
coin: Callable[[int], str],
award_exp: Callable[[discord.Interaction, int], Awaitable[None]],
) -> None:
def _render(view_data: dict) -> discord.Embed:
embed = discord.Embed(title=S.TITLE["quests"], color=0xF4C430)
sections = (
(S.QUEST_UI["daily_header"], view_data["daily"]),
(S.QUEST_UI["weekly_header"], view_data["weekly"]),
)
for header, quests in sections:
if quests:
blocks = []
for q in quests:
desc = S.QUEST_DESCRIPTIONS.get(q["id"], q["id"])
if q["claimed"]:
status = S.QUEST_UI["completed"]
elif q["done"]:
status = S.QUEST_UI["ready"]
else:
status = S.QUEST_UI["progress"].format(progress=q["progress"], max=q["goal"])
reward = S.QUEST_UI["reward"].format(coins=f"{q['coins']:,}", exp=q["exp"])
blocks.append(
f"{_bar(q['progress'], q['goal'])} **{desc}**\n{status} · {reward}"
)
value = "\n\n".join(blocks)
else:
value = S.QUEST_UI["empty"]
embed.add_field(name=header, value=value, inline=False)
return embed
class QuestView(discord.ui.View):
def __init__(self, invoker_id: int, view_data: dict):
super().__init__(timeout=180)
self.invoker_id = invoker_id
btn = discord.ui.Button(
label=S.QUEST_UI["claim_btn"],
style=discord.ButtonStyle.success,
disabled=not _has_claimable(view_data),
)
btn.callback = self._claim
self.add_item(btn)
async def _claim(self, interaction: discord.Interaction):
if interaction.user.id != self.invoker_id:
await interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True)
return
try:
res = await economy.claim_quests(self.invoker_id)
except economy.DatabaseError:
await interaction.response.send_message(S.QUEST_UI["error"], ephemeral=True)
return
if not res["ok"]:
await interaction.response.send_message(S.QUEST_UI["nothing"], ephemeral=True)
return
new_data = await economy.get_quests(self.invoker_id)
await interaction.response.edit_message(
embed=_render(new_data), view=QuestView(self.invoker_id, new_data)
)
await interaction.followup.send(
S.QUEST_UI["claimed_msg"].format(
count=res["claimed"], coins=coin(res["coins"]), exp=res["exp"]
),
ephemeral=True,
)
if res["exp"]:
asyncio.create_task(award_exp(interaction, res["exp"]))
@tree.command(name="quests", description=S.CMD["quests"])
async def cmd_quests(interaction: discord.Interaction):
await interaction.response.defer()
try:
data = await economy.get_quests(interaction.user.id)
except economy.DatabaseError:
await interaction.followup.send(S.QUEST_UI["error"], ephemeral=True)
return
await interaction.followup.send(embed=_render(data), view=QuestView(interaction.user.id, data))

View File

@@ -13,6 +13,7 @@ from pathlib import Path
import discord import discord
from discord import app_commands from discord import app_commands
from core.admin import bot_admin_check
import strings as S import strings as S
from core.admin import bot_admin_check from core.admin import bot_admin_check

View File

@@ -42,6 +42,30 @@ BIRTHDAY_CHANNEL_ID = (
BIRTHDAY_WINDOW_DAYS = int(os.getenv("BIRTHDAY_WINDOW_DAYS", "7")) BIRTHDAY_WINDOW_DAYS = int(os.getenv("BIRTHDAY_WINDOW_DAYS", "7"))
BASE_ROLE_IDS: list[int] = [1478304631930228779, 1478302278862766190] BASE_ROLE_IDS: list[int] = [1478304631930228779, 1478302278862766190]
def _parse_admin_roles(raw: str) -> dict[int, set[int]]:
"""Parse DISCORD_ADMIN_ROLES env var as "guild_id:role_id[:role_id...],guild_id:role_id...".
Multiple admin roles per guild are colon-separated; guild entries are comma-separated.
Repeating a guild_id across entries merges its roles.
"""
result: dict[int, set[int]] = {}
for entry in raw.split(","):
entry = entry.strip()
if not entry:
continue
parts = entry.split(":")
if len(parts) < 2 or not all(p.strip() for p in parts):
raise SystemExit(
f"DISCORD_ADMIN_ROLES: expected 'guild_id:role_id[:role_id...]', got {entry!r}"
)
guild_id = int(parts[0].strip())
result.setdefault(guild_id, set()).update(int(p.strip()) for p in parts[1:])
return result
BOT_ADMIN_ROLES: dict[int, set[int]] = _parse_admin_roles(os.getenv("DISCORD_ADMIN_ROLES", ""))
PB_URL = os.getenv("PB_URL", "http://127.0.0.1:8090") PB_URL = os.getenv("PB_URL", "http://127.0.0.1:8090")
PB_ADMIN_EMAIL = os.getenv("PB_ADMIN_EMAIL", "") PB_ADMIN_EMAIL = os.getenv("PB_ADMIN_EMAIL", "")
PB_ADMIN_PASSWORD = os.getenv("PB_ADMIN_PASSWORD", "") PB_ADMIN_PASSWORD = os.getenv("PB_ADMIN_PASSWORD", "")
@@ -58,26 +82,3 @@ PB_ECONOMY_COLLECTION_ECONOMY = (
PB_ECONOMY_COLLECTION = ( PB_ECONOMY_COLLECTION = (
PB_ECONOMY_COLLECTION_ECONOMY if BOT_PROFILE == "economy" else PB_ECONOMY_COLLECTION_DEV PB_ECONOMY_COLLECTION_ECONOMY if BOT_PROFILE == "economy" else PB_ECONOMY_COLLECTION_DEV
) )
def _parse_admin_roles() -> dict[int, int]:
"""Parse BOT_ADMIN_ROLES env var (format: guild_id:role_id,guild_id:role_id)."""
raw = os.getenv("BOT_ADMIN_ROLES", "").strip()
if not raw:
return {}
result: dict[int, int] = {}
for pair in raw.split(","):
pair = pair.strip()
if not pair:
continue
parts = pair.split(":")
if len(parts) != 2:
continue
try:
result[int(parts[0].strip())] = int(parts[1].strip())
except ValueError:
continue
return result
BOT_ADMIN_ROLES: dict[int, int] = _parse_admin_roles()

View File

@@ -6,22 +6,22 @@ from discord import app_commands
import config import config
def is_bot_admin(member: discord.Member) -> bool: def is_bot_admin(member: discord.abc.User | None) -> bool:
"""Return True if the member has the configured bot-admin role for their guild.""" """True when the member has any of the configured admin roles for their guild."""
role_id = config.BOT_ADMIN_ROLES.get(member.guild.id) if not isinstance(member, discord.Member) or member.guild is None:
if role_id is None:
return False return False
return any(r.id == role_id for r in member.roles) admin_role_ids = config.BOT_ADMIN_ROLES.get(member.guild.id)
if not admin_role_ids:
return False
return any(r.id in admin_role_ids for r in member.roles)
def bot_admin_check(): def bot_admin_check():
"""Slash-command check decorator: raises MissingPermissions if not a bot admin.""" """Slash-command decorator that gates execution behind ``is_bot_admin``."""
def predicate(interaction: discord.Interaction) -> bool:
member = interaction.user async def predicate(interaction: discord.Interaction) -> bool:
if not isinstance(member, discord.Member): if is_bot_admin(interaction.user):
raise app_commands.MissingPermissions(["bot_admin"]) return True
if not is_bot_admin(member): raise app_commands.MissingPermissions(["bot_admin_role"])
raise app_commands.MissingPermissions(["bot_admin"])
return True
return app_commands.check(predicate) return app_commands.check(predicate)

File diff suppressed because it is too large Load Diff

35
core/economy/__init__.py Normal file
View File

@@ -0,0 +1,35 @@
"""TipiCOIN economy - data layer and business logic.
Storage: PocketBase (see core/pb_client.py). All public async functions are the
single source of truth for mutations; see store.py for the locking rules.
This package re-exports everything so callers keep using `from core import
economy` + attribute access.
"""
from ..pb_client import DatabaseError
from .store import *
from .store import (
_commit, _default_user, _is_jailed, _locked_by, _now, _parse_dt,
_prestige_mult, _txn, _user_lock, _user_locks,
)
from .house import *
from .house import _credit_house, _house_record_id
from .levels import *
from .shop import *
from .fishing import *
from .quests import *
from .quests import _ensure_quests, _pick_quests, _quest_view
from .income import *
from .jail import *
from .gambling import *
from .prestige import *
from .leaderboards import *
from .heist import *
from .admin import *
from . import ( # noqa: E402 (submodules addressable as economy.store etc.)
admin, fishing, gambling, heist, house, income, jail, leaderboards,
levels, prestige, quests, shop, store,
)

165
core/economy/admin.py Normal file
View File

@@ -0,0 +1,165 @@
"""Admin mutations and the season reset."""
from __future__ import annotations
from datetime import timedelta
from .. import pb_client
from .store import _commit, _default_user, _locked_by, _now, _txn, get_user
from .levels import get_level
from .shop import SHOP
async def do_season_reset(top_n: int = 10) -> list[tuple[str, int, int]]:
"""Snapshot top_n by EXP, then full wipe: EXP, balance, items, item_uses.
Returns top list (uid, exp, level) captured before the reset."""
records = await pb_client.list_all_records()
top = sorted(
((r["user_id"], r.get("exp", 0)) for r in records if r.get("user_id")),
key=lambda x: x[1],
reverse=True,
)[:top_n]
reset_fields = {
"exp": 0,
"balance": 0,
"items": [],
"item_uses": {},
"last_daily": None,
"last_work": None,
"last_beg": None,
"last_crime": None,
"last_rob": None,
"last_fish": None,
"daily_streak": 0,
"last_streak_date": None,
"season_total_exp": 0,
}
for record in records:
await pb_client.update_record(record["id"], reset_fields)
return [(uid, exp, get_level(exp)) for uid, exp in top]
# ---------------------------------------------------------------------------
# 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)
user["balance"] = max(0, user["balance"] + amount)
await _commit(target_id, user)
verb = f"+{amount}" if amount >= 0 else str(amount)
_txn("ADMIN_COINS", admin=admin_id, target=target_id, amount=verb, reason=reason, bal=user["balance"])
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)
user["jailed_until"] = (_now() + timedelta(minutes=minutes)).isoformat()
user["jailbreak_used"] = False
await _commit(target_id, user)
_txn("ADMIN_JAIL", admin=admin_id, target=target_id, minutes=minutes, reason=reason)
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)
user["jailed_until"] = None
user["jailbreak_used"] = False
await _commit(target_id, user)
_txn("ADMIN_UNJAIL", admin=admin_id, target=target_id)
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)
user["eco_banned"] = True
await _commit(target_id, user)
_txn("ADMIN_BAN", admin=admin_id, target=target_id, reason=reason)
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)
user["eco_banned"] = False
await _commit(target_id, user)
_txn("ADMIN_UNBAN", admin=admin_id, target=target_id)
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)
fresh = _default_user()
fresh["_pb_id"] = user.get("_pb_id") # type: ignore[typeddict-unknown-key]
await _commit(target_id, fresh)
_txn("ADMIN_RESET", admin=admin_id, target=target_id)
return {"ok": True}
async def do_admin_inspect(target_id: int) -> dict:
"""Return the user's full raw economy data."""
user = await get_user(target_id)
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)
old_exp = user.get("exp", 0)
old_level = get_level(old_exp)
user["exp"] = max(0, old_exp + amount)
user["season_total_exp"] = max(0, user.get("season_total_exp", 0) + amount)
new_level = get_level(user["exp"])
await _commit(target_id, user)
verb = f"+{amount}" if amount >= 0 else str(amount)
_txn("ADMIN_EXP", admin=admin_id, target=target_id, amount=verb, reason=reason, exp=user["exp"])
return {
"ok": True,
"exp": user["exp"],
"change": amount,
"old_level": old_level,
"new_level": new_level,
"level_changed": new_level != old_level,
}
@_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:
return {"ok": False, "reason": "invalid_item"}
user = await get_user(target_id)
items: list = list(user.get("items") or [])
item_uses: dict = dict(user.get("item_uses") or {})
if action == "give":
if item_id not in items:
items.append(item_id)
if item_id == "anticheat":
item_uses["anticheat"] = 2
user["items"] = items
user["item_uses"] = item_uses
await _commit(target_id, user)
_txn("ADMIN_ITEM_GIVE", admin=admin_id, target=target_id, item=item_id)
return {"ok": True, "action": "given", "item_id": item_id}
elif action == "remove":
if item_id not in items:
return {"ok": False, "reason": "not_owned"}
items.remove(item_id)
item_uses.pop(item_id, None)
user["items"] = items
user["item_uses"] = item_uses
await _commit(target_id, user)
_txn("ADMIN_ITEM_REMOVE", admin=admin_id, target=target_id, item=item_id)
return {"ok": True, "action": "removed", "item_id": item_id}
return {"ok": False, "reason": "invalid_action"}

194
core/economy/fishing.py Normal file
View File

@@ -0,0 +1,194 @@
"""Fishing minigame: catalogue, rolls, catch/sell flows."""
from __future__ import annotations
import random
from datetime import timedelta
from ..pb_client import DatabaseError
from .store import (
COOLDOWNS, _cooldown_remaining, _commit, _is_jailed, _locked_by, _now,
_prestige_mult, _txn, get_user,
)
# ---------------------------------------------------------------------------
# Fish catalogue
# ---------------------------------------------------------------------------
FISH_CATALOGUE: dict[str, dict] = {
# id: { rarity, weight=(min_g, max_g), coins=(min, max), exp }
"sarj": {"rarity": "common", "weight": (50, 500), "coins": (3, 18), "exp": 3},
"ahven": {"rarity": "common", "weight": (80, 700), "coins": (5, 22), "exp": 3},
"koger": {"rarity": "common", "weight": (100, 800), "coins": (5, 20), "exp": 3},
"viidikas": {"rarity": "common", "weight": (10, 120), "coins": (2, 8), "exp": 2},
"latikas": {"rarity": "uncommon", "weight": (300, 2500), "coins": (20, 70), "exp": 6},
"karpkala": {"rarity": "uncommon", "weight": (500, 4000), "coins": (25, 80), "exp": 7},
"linask": {"rarity": "uncommon", "weight": (200, 2000), "coins": (18, 60), "exp": 6},
"haug": {"rarity": "rare", "weight": (500, 6000), "coins": (50, 180), "exp": 10},
"angerjas": {"rarity": "rare", "weight": (200, 1800), "coins": (40, 120), "exp": 10},
"siig": {"rarity": "rare", "weight": (200, 2000), "coins": (45, 130), "exp": 10},
"forell": {"rarity": "epic", "weight": (400, 4500), "coins": (100, 280), "exp": 15},
"koha": {"rarity": "epic", "weight": (600, 7000), "coins": (120, 300), "exp": 15},
"tougjas": {"rarity": "epic", "weight": (400, 4000), "coins": (90, 250), "exp": 14},
"lohe": {"rarity": "legendary","weight": (1500, 12000), "coins": (250, 700), "exp": 25},
"vimb": {"rarity": "legendary","weight": (200, 1200), "coins": (200, 600), "exp": 25},
}
FISH_RARITY_WEIGHTS: dict[str, int] = {
"junk": 15,
"common": 45,
"uncommon": 22,
"rare": 12,
"epic": 5,
"legendary": 1,
}
def roll_fish(rarity_bump: bool = False) -> tuple[str, int]:
"""Roll a random fish. Returns (fish_id, weight_grams) or ('junk', 0).
rarity_bump=True (kalavork item) shifts each catch one tier up.
"""
rarity_pool = list(FISH_RARITY_WEIGHTS.keys())
weights = list(FISH_RARITY_WEIGHTS.values())
chosen_rarity = random.choices(rarity_pool, weights=weights)[0]
if chosen_rarity == "junk":
return ("junk", 0)
if rarity_bump:
order = ["common", "uncommon", "rare", "epic", "legendary"]
idx = order.index(chosen_rarity) if chosen_rarity in order else 0
chosen_rarity = order[min(idx + 1, len(order) - 1)]
fish_of_rarity = [k for k, v in FISH_CATALOGUE.items() if v["rarity"] == chosen_rarity]
if not fish_of_rarity:
return ("junk", 0)
fish_id = random.choice(fish_of_rarity)
fish = FISH_CATALOGUE[fish_id]
weight = random.randint(fish["weight"][0], fish["weight"][1])
return (fish_id, weight)
# ---------------------------------------------------------------------------
# /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:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
if jail := _is_jailed(user):
return {"ok": False, "reason": "jailed", "remaining": jail}
fish_cd = timedelta(seconds=90) if "ussipurk" in user["items"] else COOLDOWNS["fish"]
if cd := _cooldown_remaining(user, "fish", override_cd=fish_cd):
return {"ok": False, "reason": "cooldown", "remaining": cd}
user["last_fish"] = _now().isoformat()
await _commit(user_id, user)
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)
if fish_id == "junk":
_txn("FISH_JUNK", user=user_id)
return {"ok": True, "type": "junk", "coins": 0, "exp": 0}
if fish_id not in FISH_CATALOGUE:
return {"ok": False, "reason": "invalid_fish"}
fish = FISH_CATALOGUE[fish_id]
min_c, max_c = fish["coins"]
w_min, w_max = fish["weight"]
weight_ratio = (weight - w_min) / max(1, w_max - w_min)
base_coins = int(min_c + weight_ratio * (max_c - min_c))
coin_mult, _ = _prestige_mult(user)
value = int(base_coins * coin_mult)
exp = fish["exp"]
book: dict = user.get("fish_book") or {}
prev_count = book.get(fish_id, 0)
book[fish_id] = prev_count + 1
user["fish_book"] = book
user["total_fish_caught"] = user.get("total_fish_caught", 0) + 1
inv: list = list(user.get("fish_inventory") or [])
inv.append({"fish_id": fish_id, "weight": weight, "value": value})
user["fish_inventory"] = inv
await _commit(user_id, user)
_txn("FISH", user=user_id, fish=fish_id, weight=weight, value=value)
return {
"ok": True,
"type": "fish",
"fish_id": fish_id,
"weight": weight,
"value": value,
"exp": exp,
"is_new": prev_count == 0,
"total_caught": book[fish_id],
}
@_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)
inv: list = list(user.get("fish_inventory") or [])
if not inv:
return {"ok": False, "reason": "empty"}
if indices is None:
to_sell = inv
remaining = []
else:
sell_idx = {
(i if i >= 0 else len(inv) + i)
for i in indices
}
sell_idx = {i for i in sell_idx if 0 <= i < len(inv)}
to_sell = [inv[i] for i in sorted(sell_idx)]
keep_idx = set(range(len(inv))) - sell_idx
remaining = [inv[i] for i in sorted(keep_idx)]
if not to_sell:
return {"ok": False, "reason": "empty"}
total_coins = sum(entry["value"] for entry in to_sell)
user["fish_inventory"] = remaining
user["balance"] = user.get("balance", 0) + total_coins
user["lifetime_earned"] = user.get("lifetime_earned", 0) + total_coins
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
await _commit(user_id, user)
_txn("FISH_SELL", user=user_id, count=len(to_sell), coins=f"+{total_coins}", bal=user["balance"])
return {
"ok": True,
"coins": total_coins,
"count": len(to_sell),
"balance": user["balance"],
}
async def do_fishbook(user_id: int) -> dict:
"""Return the user's fish book data including per-species inventory counts."""
user = await get_user(user_id)
book: dict = user.get("fish_book") or {}
inv: list = user.get("fish_inventory") or []
inv_counts: dict[str, int] = {}
for entry in inv:
fid = entry.get("fish_id", "")
inv_counts[fid] = inv_counts.get(fid, 0) + 1
return {
"ok": True,
"book": book,
"inv_counts": inv_counts,
"total_fish_caught": user.get("total_fish_caught", 0),
"unique_caught": len(book),
"total_species": len(FISH_CATALOGUE),
}

275
core/economy/gambling.py Normal file
View File

@@ -0,0 +1,275 @@
"""Casino games: roulette, slots, RPS bets and escrow, blackjack."""
from __future__ import annotations
import random
from ..pb_client import DatabaseError
from ..emoji import EMOJI as E
from .store import _commit, _is_jailed, _locked_by, _txn, get_user
from .house import _credit_house
# ---------------------------------------------------------------------------
# /roulette
# ---------------------------------------------------------------------------
@_locked_by(0)
async def do_roulette(user_id: int, bet: int, colour: str) -> dict:
try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
if jail := _is_jailed(user):
return {"ok": False, "reason": "jailed", "remaining": jail}
if user["balance"] < bet:
return {"ok": False, "reason": "insufficient"}
# Wheel: 18 red, 18 black, 1 green (37 slots - real roulette proportions)
result = random.choices(["punane", "must", "roheline"], weights=[18, 18, 1])[0]
won = result == colour
mult = 14 if colour == "roheline" else 1
change = bet * mult if won else -bet
user["balance"] = max(0, user["balance"] + change)
user["total_wagered"] = user.get("total_wagered", 0) + bet
if won:
user["lifetime_earned"] = user.get("lifetime_earned", 0) + abs(change)
user["biggest_win"] = max(user.get("biggest_win", 0), abs(change))
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
else:
user["lifetime_lost"] = user.get("lifetime_lost", 0) + bet
user["biggest_loss"] = max(user.get("biggest_loss", 0), bet)
await _commit(user_id, user)
if not won:
await _credit_house(bet)
_txn("ROULETTE_" + ("WIN" if won else "LOSE"), user=user_id, bet=bet, colour=colour, result=result, mult=mult, bal=user["balance"])
return {
"ok": True, "won": won,
"result": result, "change": abs(change), "mult": mult,
"balance": user["balance"],
}
# ---------------------------------------------------------------------------
# /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:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
if jail := _is_jailed(user):
return {"ok": False, "reason": "jailed", "remaining": jail}
if user["balance"] < bet:
return {"ok": False, "reason": "insufficient"}
user["total_wagered"] = user.get("total_wagered", 0) + bet
if outcome == "win":
user["balance"] += bet
user["lifetime_earned"] = user.get("lifetime_earned", 0) + bet
user["biggest_win"] = max(user.get("biggest_win", 0), bet)
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
elif outcome == "lose":
user["balance"] = max(0, user["balance"] - bet)
user["lifetime_lost"] = user.get("lifetime_lost", 0) + bet
user["biggest_loss"] = max(user.get("biggest_loss", 0), bet)
# tie: no change
await _commit(user_id, user)
if outcome == "lose" and bet > 0:
await _credit_house(bet)
_txn("RPS_" + outcome.upper(), user=user_id, bet=bet, bal=user["balance"])
return {"ok": True, "balance": user["balance"]}
# ---------------------------------------------------------------------------
# /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:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
if jail := _is_jailed(user):
return {"ok": False, "reason": "jailed", "remaining": jail}
if user["balance"] < bet:
return {"ok": False, "reason": "insufficient"}
user["balance"] -= bet
user["total_wagered"] = user.get("total_wagered", 0) + bet
try:
await _commit(user_id, user)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
_txn("RPS_PVP_DEPOSIT", user=user_id, bet=bet, bal=user["balance"])
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:
user = await get_user(winner_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
payout = bet * 2
user["balance"] = user.get("balance", 0) + payout
user["lifetime_earned"] = user.get("lifetime_earned", 0) + bet
user["biggest_win"] = max(user.get("biggest_win", 0), bet)
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
try:
await _commit(winner_id, user)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
_txn("RPS_PVP_PAYOUT", user=winner_id, payout=f"+{payout}", bal=user["balance"])
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:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
user["balance"] = user.get("balance", 0) + bet
user["total_wagered"] = max(0, user.get("total_wagered", 0) - bet)
try:
await _commit(user_id, user)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
_txn("RPS_PVP_REFUND", user=user_id, bet=bet, bal=user["balance"])
return {"ok": True, "balance": user["balance"]}
# ---------------------------------------------------------------------------
# /slots
# ---------------------------------------------------------------------------
_SLOTS_SYMBOLS: list[tuple[str, int]] = [
(E["TipiHEART"], 27),
(E["TipiFIRE"], 22),
(E["TipiTROLL"], 18),
(E["TipICRY"], 15),
(E["TipiSKULL"], 10),
(E["TipiKARIKAS"], 8),
]
_SLOTS_JACKPOT = E["TipiKARIKAS"]
_SLOTS_TRIPLE_MULT: dict[str, int] = {
E["TipiHEART"]: 4,
E["TipiFIRE"]: 5,
E["TipiTROLL"]: 7,
E["TipICRY"]: 10,
E["TipiSKULL"]: 15,
E["TipiKARIKAS"]: 25, # jackpot
}
def _spin() -> str:
symbols, weights = zip(*_SLOTS_SYMBOLS)
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)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
if jail := _is_jailed(user):
return {"ok": False, "reason": "jailed", "remaining": jail}
if user["balance"] < bet:
return {"ok": False, "reason": "insufficient"}
reels = [_spin(), _spin(), _spin()]
a, b, c = reels
has_360 = "monitor_360" in user["items"]
if a == b == c:
tier = "jackpot" if a == _SLOTS_JACKPOT else "triple"
base_mult = _SLOTS_TRIPLE_MULT.get(a, 4)
mult = int(base_mult * 1.5) if has_360 else base_mult
change = bet * (mult - 1)
elif a == b or b == c or a == c:
tier = "pair"
change = bet // 2
else:
tier = "miss"
change = -bet
user["balance"] = max(0, user["balance"] + change)
user["total_wagered"] = user.get("total_wagered", 0) + bet
if tier in ("jackpot", "triple", "pair"):
user["lifetime_earned"] = user.get("lifetime_earned", 0) + change
user["biggest_win"] = max(user.get("biggest_win", 0), change)
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
if tier == "jackpot":
user["slots_jackpots"] = user.get("slots_jackpots", 0) + 1
else:
user["lifetime_lost"] = user.get("lifetime_lost", 0) + bet
user["biggest_loss"] = max(user.get("biggest_loss", 0), bet)
await _commit(user_id, user)
if tier == "miss":
await _credit_house(bet)
_txn("SLOTS_" + tier.upper(), user=user_id, bet=bet, change=change, bal=user["balance"])
return {
"ok": True,
"reels": reels,
"tier": tier,
"change": change,
"balance": user["balance"],
}
# ---------------------------------------------------------------------------
# /blackjack
# ---------------------------------------------------------------------------
@_locked_by(0)
async def do_blackjack_bet(user_id: int, bet: int) -> dict:
"""Deduct the initial blackjack bet. Returns ok/fail."""
try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
if jail := _is_jailed(user):
return {"ok": False, "reason": "jailed", "remaining": jail}
if user["balance"] < bet:
return {"ok": False, "reason": "insufficient", "balance": user["balance"]}
user["balance"] -= bet
await _commit(user_id, user)
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)
user["balance"] += payout
user["balance"] = max(0, user["balance"])
user["total_wagered"] = user.get("total_wagered", 0) + total_invested
net = payout - total_invested
if net > 0:
user["lifetime_earned"] = user.get("lifetime_earned", 0) + net
user["biggest_win"] = max(user.get("biggest_win", 0), net)
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
elif net < 0:
user["lifetime_lost"] = user.get("lifetime_lost", 0) + abs(net)
user["biggest_loss"] = max(user.get("biggest_loss", 0), abs(net))
await _commit(user_id, user)
house_gain = total_invested - payout
if house_gain > 0:
await _credit_house(house_gain)
_txn("BLACKJACK", user=user_id, payout=f"{payout:+}", net=f"{net:+}", bal=user["balance"])
return {"ok": True, "balance": user["balance"]}

97
core/economy/heist.py Normal file
View File

@@ -0,0 +1,97 @@
"""Bank heist: group robbery of the house."""
from __future__ import annotations
import random
from .. import pb_client
from ..pb_client import DatabaseError
from . import house
from .store import HEIST_JAIL, _commit, _now, _txn, _user_lock, get_user
from .house import _credit_house, _refund_house_safe, _refund_user_safe
# ---------------------------------------------------------------------------
# /heist
# ---------------------------------------------------------------------------
async def do_heist_check(user_id: int) -> dict:
"""Check whether a user is eligible to join a heist."""
try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
if jail := _is_jailed(user):
return {"ok": False, "reason": "jailed", "remaining": jail}
return {"ok": True}
async def do_heist_resolve(user_ids: list[int], success: bool) -> dict:
"""Apply heist outcome to all participants. On win, steals from house.
Per-user commit failures attempt to compensate the house so the economy
stays balanced. If compensation also fails, a CRITICAL log is emitted.
"""
now = _now()
payout_each = 0
failed_users: list[int] = []
if success and house.HOUSE_ID is not None:
try:
house = await get_user(house.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"}
_txn("HEIST_HOUSE", change=f"-{debit}", house_bal=house["balance"] - debit)
for uid in user_ids:
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.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}

91
core/economy/house.py Normal file
View File

@@ -0,0 +1,91 @@
"""House account: the bot's own balance, fed by fines and lost bets."""
from __future__ import annotations
from .. import pb_client
from ..pb_client import DatabaseError
from .store import _log, _now, get_user, _commit
# ---------------------------------------------------------------------------
# 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, _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 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
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:
"""Return unix timestamp until which no new heist can start. Persisted on house record."""
if HOUSE_ID is None:
return 0.0
house = await get_user(HOUSE_ID)
return float(house.get("heist_global_cd_until") or 0)
async def set_heist_global_cd(until: float) -> None:
"""Persist heist global cooldown expiry to the house account in PocketBase."""
record_id = await _house_record_id()
if record_id is None:
return
await pb_client.update_record(record_id, {"heist_global_cd_until": until})
async def _refund_house_safe(amount: int, context: str, related_uid: int) -> None:
"""Best-effort refund of `amount` to the house. Logs critical if it fails."""
if HOUSE_ID is None or amount <= 0:
return
try:
await _credit_house(amount)
except DatabaseError as exc:
_log.critical(
"House compensation failed (%s, related uid %s, amount %s): %s",
context, related_uid, amount, exc,
)
async def _refund_user_safe(_unused_house_id, amount: int, context: str, uid: int) -> None:
"""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:
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",
context, uid, amount, exc,
)

403
core/economy/income.py Normal file
View File

@@ -0,0 +1,403 @@
"""Income and social commands: daily, work, beg, crime, rob, give."""
from __future__ import annotations
import random
from datetime import date, timedelta
import strings
from ..pb_client import DatabaseError
from . import house
from .store import (
COOLDOWNS, JAIL_DURATION, PRESTIGE_SHOP, _commit, _cooldown_remaining,
_is_jailed, _locked_by, _log, _now, _prestige_mult, _txn, get_user,
)
from .house import _credit_house
# ---------------------------------------------------------------------------
# /daily
# ---------------------------------------------------------------------------
@_locked_by(0)
async def do_daily(user_id: int) -> dict:
try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
daily_cd = timedelta(hours=18) if "korvaklapid" in user["items"] else COOLDOWNS["daily"]
if cd := _cooldown_remaining(user, "daily", override_cd=daily_cd):
return {"ok": False, "reason": "cooldown", "remaining": cd}
today = _now().date()
last_str = user.get("last_streak_date")
last_date = date.fromisoformat(last_str) if last_str else None
if last_date is None:
streak = 1
elif (today - last_date).days == 1:
streak = user["daily_streak"] + 1
elif "karikas" in user["items"]:
streak = user["daily_streak"] # karikas: streak survives missed days
else:
streak = 1 # streak broken
# Streak multiplier tiers
if streak >= 14:
streak_mult = 3.0
elif streak >= 7:
streak_mult = 2.0
elif streak >= 3:
streak_mult = 1.5
else:
streak_mult = 1.0
vip = "lan_pass" in user["items"]
vip_mult = 2.0 if vip else 1.0
daily_plus_level = (user.get("prestige_upgrades") or {}).get("daily_plus", 0)
base = int(150 * (1.0 + daily_plus_level * PRESTIGE_SHOP["daily_plus"]["effect"]))
earned = int(base * streak_mult * vip_mult)
if "korvaklapid" in user["items"]:
earned += 25
coin_mult, _ = _prestige_mult(user)
earned = int(earned * coin_mult)
# Investor interest (capped at 500/day to prevent runaway wealth)
interest = 0
if "gaming_laptop" in user["items"] and user["balance"] > 0:
interest = min(int(user["balance"] * 0.05), 500)
earned += interest
user["balance"] += earned
user["last_daily"] = _now().isoformat()
user["daily_streak"] = streak
user["last_streak_date"] = today.isoformat()
user["lifetime_earned"] = user.get("lifetime_earned", 0) + earned
user["best_daily_streak"] = max(user.get("best_daily_streak", 0), streak)
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
await _commit(user_id, user)
_txn("DAILY", user=user_id, earned=f"+{earned}", streak=streak, bal=user["balance"])
return {
"ok": True,
"earned": earned,
"interest": interest,
"streak": streak,
"streak_mult": streak_mult,
"vip": vip,
"balance": user["balance"],
}
# ---------------------------------------------------------------------------
# /work
# ---------------------------------------------------------------------------
_WORK_JOBS = strings.WORK_JOBS
@_locked_by(0)
async def do_work(user_id: int) -> dict:
try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
work_cd = timedelta(minutes=40) if "monitor" in user["items"] else COOLDOWNS["work"]
if cd := _cooldown_remaining(user, "work", override_cd=work_cd):
return {"ok": False, "reason": "cooldown", "remaining": cd}
if jail := _is_jailed(user):
return {"ok": False, "reason": "jailed", "remaining": jail}
job, job_mult = random.choice(_WORK_JOBS)
base = random.randint(15, 75)
worker_mult = 1.5 if "gaming_hiir" in user["items"] else 1.0
desk_mult = 1.25 if "reguleeritav_laud" in user["items"] else 1.0
lucky = False
if "energiajook" in user["items"] and random.random() < 0.30:
lucky = True
work_plus_level = (user.get("prestige_upgrades") or {}).get("work_plus", 0)
work_plus_mult = 1.0 + work_plus_level * PRESTIGE_SHOP["work_plus"]["effect"]
coin_mult, _ = _prestige_mult(user)
earned = int(base * job_mult * worker_mult * desk_mult * (3.0 if lucky else 1.0) * work_plus_mult * coin_mult)
user["balance"] += earned
user["last_work"] = _now().isoformat()
user["work_count"] = user.get("work_count", 0) + 1
user["lifetime_earned"] = user.get("lifetime_earned", 0) + earned
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
await _commit(user_id, user)
_txn("WORK", user=user_id, earned=f"+{earned}", lucky=lucky, bal=user["balance"])
return {
"ok": True,
"earned": earned,
"job": job,
"lucky": lucky,
"hiir": worker_mult > 1.0,
"laud": desk_mult > 1.0,
"balance": user["balance"],
}
# ---------------------------------------------------------------------------
# /beg
# ---------------------------------------------------------------------------
_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)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
beg_cd = timedelta(minutes=3) if "hiirematt" in user["items"] else COOLDOWNS["beg"]
if cd := _cooldown_remaining(user, "beg", override_cd=beg_cd):
return {"ok": False, "reason": "cooldown", "remaining": cd}
jailed = bool(_is_jailed(user))
beg_mult = 2 if "klaviatuur" in user["items"] else 1
coin_mult, _ = _prestige_mult(user)
earned = int(random.randint(10, 40) * beg_mult * coin_mult)
user["balance"] += earned
user["last_beg"] = _now().isoformat()
user["beg_count"] = user.get("beg_count", 0) + 1
user["lifetime_earned"] = user.get("lifetime_earned", 0) + earned
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
await _commit(user_id, user)
_txn("BEG", user=user_id, earned=f"+{earned}", jailed=jailed, bal=user["balance"])
return {
"ok": True,
"earned": earned,
"text": random.choice(_BEG_JAIL_LINES if jailed else _BEG_LINES),
"klaviatuur": beg_mult > 1,
"jailed": jailed,
"balance": user["balance"],
}
# ---------------------------------------------------------------------------
# /crime
# ---------------------------------------------------------------------------
_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)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
if cd := _cooldown_remaining(user, "crime"):
return {"ok": False, "reason": "cooldown", "remaining": cd}
if jail := _is_jailed(user):
return {"ok": False, "reason": "jailed", "remaining": jail}
user["last_crime"] = _now().isoformat()
win_chance = 0.75 if "cat6" in user["items"] else 0.60
user["crimes_attempted"] = user.get("crimes_attempted", 0) + 1
if random.random() < win_chance:
earned = random.randint(200, 500)
if "mikrofon" in user["items"]:
earned = int(earned * 1.3)
user["balance"] += earned
user["crimes_succeeded"] = user.get("crimes_succeeded", 0) + 1
user["lifetime_earned"] = user.get("lifetime_earned", 0) + earned
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
await _commit(user_id, user)
_txn("CRIME_WIN", user=user_id, earned=f"+{earned}", bal=user["balance"])
return {
"ok": True, "success": True,
"earned": earned, "text": random.choice(_CRIME_WIN),
"mikrofon": "mikrofon" in user["items"],
"balance": user["balance"],
}
else:
fine = random.randint(50, 150)
user["balance"] = max(0, user["balance"] - fine)
jailed = "gaming_tool" not in user["items"]
if jailed:
user["jailed_until"] = (_now() + JAIL_DURATION).isoformat()
user["jailbreak_used"] = False
user["times_jailed"] = user.get("times_jailed", 0) + 1
user["lifetime_lost"] = user.get("lifetime_lost", 0) + fine
await _commit(user_id, user)
await _credit_house(fine)
_txn("CRIME_FAIL", user=user_id, fine=f"-{fine}", jailed=jailed, bal=user["balance"])
return {
"ok": True, "success": False,
"fine": fine, "text": random.choice(_CRIME_LOSE),
"jailed": jailed,
"balance": user["balance"],
}
# ---------------------------------------------------------------------------
# /rob
# ---------------------------------------------------------------------------
@_locked_by(0, 1)
async def do_rob(robber_id: int, target_id: int) -> dict:
try:
robber = await get_user(robber_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if robber.get("eco_banned"):
return {"ok": False, "reason": "banned"}
try:
target = await get_user(target_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if cd := _cooldown_remaining(robber, "rob"):
return {"ok": False, "reason": "cooldown", "remaining": cd}
target_jailed = bool(_is_jailed(target))
if jail := _is_jailed(robber):
if not target_jailed:
return {"ok": False, "reason": "jailed", "remaining": jail}
elif target_jailed:
return {"ok": False, "reason": "target_jailed"}
is_house = (house.HOUSE_ID is not None and target_id == house.HOUSE_ID)
if is_house and target["balance"] < 50:
return {"ok": False, "reason": "broke"}
if not is_house and target["balance"] < 100:
return {"ok": False, "reason": "broke"}
robber["last_rob"] = _now().isoformat()
if "anticheat" in target["items"] and not is_house:
fine = random.randint(100, 200)
robber["balance"] = max(0, robber["balance"] - fine)
# Decrement anticheat uses
uses = target.get("item_uses", {}).get("anticheat", 2) - 1
if "item_uses" not in target:
target["item_uses"] = {}
if uses <= 0:
target["items"] = [i for i in target["items"] if i != "anticheat"]
target["item_uses"].pop("anticheat", None)
else:
target["item_uses"]["anticheat"] = uses
robber["lifetime_lost"] = robber.get("lifetime_lost", 0) + fine
await _commit(robber_id, robber)
await _commit(target_id, target)
await _credit_house(fine)
_txn("ROB_BLOCKED", robber=robber_id, victim=target_id, fine=f"-{fine}", robber_bal=robber["balance"], ac_uses_left=uses)
return {"ok": True, "success": False, "reason": "valvur", "fine": fine}
# Robbing the house has lower success (35%) but jackpot chance
success_chance = 0.35 if is_house else (0.60 if "jellyfin" in robber["items"] else 0.45)
if random.random() < success_chance:
jackpot = is_house and random.random() < 0.10
if jackpot:
pct = 0.40
elif is_house:
pct = random.uniform(0.05, 0.15)
else:
pct = random.uniform(0.10, 0.25)
stolen = max(10, min(int(target["balance"] * pct), target["balance"]))
target["balance"] -= stolen
prev_lifetime_earned = robber.get("lifetime_earned", 0)
prev_biggest_win = robber.get("biggest_win", 0)
prev_peak_balance = robber.get("peak_balance", 0)
robber["balance"] += stolen
robber["lifetime_earned"] = prev_lifetime_earned + stolen
robber["biggest_win"] = max(prev_biggest_win, stolen)
robber["peak_balance"] = max(prev_peak_balance, robber["balance"])
try:
await _commit(robber_id, robber)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
try:
await _commit(target_id, target)
except DatabaseError:
robber["balance"] -= stolen
robber["lifetime_earned"] = prev_lifetime_earned
robber["biggest_win"] = prev_biggest_win
robber["peak_balance"] = prev_peak_balance
try:
await _commit(robber_id, robber)
except DatabaseError as exc2:
_log.critical(
"do_rob rollback failed for robber %s after target commit failed: %s",
robber_id, exc2,
)
return {"ok": False, "reason": "db_error"}
_txn("ROB_WIN", robber=robber_id, victim=target_id, stolen=f"+{stolen}", jackpot=jackpot, robber_bal=robber["balance"], victim_bal=target["balance"])
return {"ok": True, "success": True, "stolen": stolen, "balance": robber["balance"], "jackpot": jackpot}
else:
fine = random.randint(100, 250)
robber["balance"] = max(0, robber["balance"] - fine)
robber["lifetime_lost"] = robber.get("lifetime_lost", 0) + fine
robber["biggest_loss"] = max(robber.get("biggest_loss", 0), fine)
await _commit(robber_id, robber)
await _credit_house(fine)
_txn("ROB_FAIL", robber=robber_id, victim=target_id, fine=f"-{fine}", robber_bal=robber["balance"])
return {"ok": True, "success": False, "reason": "caught", "fine": fine, "balance": robber["balance"]}
# ---------------------------------------------------------------------------
# /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)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if giver.get("eco_banned"):
return {"ok": False, "reason": "banned"}
if rem := _is_jailed(giver):
return {"ok": False, "reason": "jailed", "remaining": rem}
if giver["balance"] < amount:
return {"ok": False, "reason": "insufficient"}
try:
receiver = await get_user(receiver_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
giver["balance"] -= amount
receiver["balance"] += amount
giver["total_given"] = giver.get("total_given", 0) + amount
receiver["total_received"] = receiver.get("total_received", 0) + amount
try:
await _commit(giver_id, giver)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
try:
await _commit(receiver_id, receiver)
except DatabaseError:
giver["balance"] += amount
giver["total_given"] = max(0, giver.get("total_given", 0) - amount)
try:
await _commit(giver_id, giver)
except DatabaseError as exc2:
_log.critical(
"do_give rollback failed for giver %s after receiver commit failed: %s",
giver_id, exc2,
)
return {"ok": False, "reason": "db_error"}
_txn("GIVE", from_=giver_id, to=receiver_id, amount=amount, from_bal=giver["balance"], to_bal=receiver["balance"])
return {
"ok": True,
"giver_balance": giver["balance"],
"receiver_balance": receiver["balance"],
}

79
core/economy/jail.py Normal file
View File

@@ -0,0 +1,79 @@
"""Jail, jailbreak and bail."""
from __future__ import annotations
import random
from datetime import timedelta
from .store import (
_commit, _is_jailed, _locked_by, _now, _txn, get_all_users_raw, get_user,
)
@_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)
user["jailed_until"] = (_now() + timedelta(minutes=30)).isoformat()
user["jailbreak_used"] = False
user["times_jailed"] = user.get("times_jailed", 0) + 1
await _commit(user_id, user)
_txn("SPAM_JAIL", user=user_id, until=user["jailed_until"])
# ---------------------------------------------------------------------------
# /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)
user["jailbreak_used"] = True
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)
user["jailed_until"] = None
user["jailbreak_used"] = False
await _commit(user_id, user)
_txn("JAIL_FREE", user=user_id, method="doubles")
return {"ok": True, "balance": user["balance"]}
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."""
user = await get_user(user_id)
if user["balance"] < MIN_BAIL:
return {"ok": False, "reason": "broke", "balance": user["balance"]}
pct = random.uniform(0.20, 0.30)
fine = max(MIN_BAIL, int(user["balance"] * pct))
user["balance"] = max(0, user["balance"] - fine)
user["jailed_until"] = None
user["jailbreak_used"] = False
user["lifetime_lost"] = user.get("lifetime_lost", 0) + fine
user["total_bail_paid"] = user.get("total_bail_paid", 0) + fine
await _commit(user_id, user)
_txn("BAIL_PAID", user=user_id, fine=f"-{fine}", pct=f"{pct:.0%}", bal=user["balance"])
return {"ok": True, "fine": fine, "balance": user["balance"]}
# ---------------------------------------------------------------------------
# /jailed
# ---------------------------------------------------------------------------
async def do_get_jailed() -> list[tuple[int, timedelta]]:
"""Return [(user_id, remaining)] for every user currently in jail."""
all_users = await get_all_users_raw()
result: list[tuple[int, timedelta]] = []
for uid_str, user in all_users.items():
if rem := _is_jailed(user):
result.append((int(uid_str), rem))
result.sort(key=lambda x: x[1])
return result

View File

@@ -0,0 +1,82 @@
"""Leaderboard queries over the full collection."""
from __future__ import annotations
from .. import pb_client
from .levels import get_level
async def get_leaderboard(top_n: int | None = 10) -> list[tuple[str, int]]:
"""Return top_n (user_id_str, balance) pairs sorted descending."""
records = await pb_client.list_all_records()
result = sorted(
((r["user_id"], r.get("balance", 0)) for r in records if r.get("user_id")),
key=lambda x: x[1],
reverse=True,
)
return result if top_n is None else result[:top_n]
async def get_leaderboard_exp(top_n: int | None = 10) -> list[tuple[str, int, int]]:
"""Return top_n (user_id_str, exp, level) sorted by EXP descending."""
records = await pb_client.list_all_records()
result = sorted(
((r["user_id"], r.get("exp", 0)) for r in records if r.get("user_id")),
key=lambda x: x[1],
reverse=True,
)
entries = [(uid, exp, get_level(exp)) for uid, exp in result]
return entries if top_n is None else entries[:top_n]
# ---------------------------------------------------------------------------
# Extended leaderboards
# ---------------------------------------------------------------------------
async def get_leaderboard_season_exp(top_n: int | None = 10) -> list[tuple[str, int, int]]:
"""Return (user_id, season_total_exp, prestige_level) sorted by season EXP."""
records = await pb_client.list_all_records()
result = sorted(
(
(r["user_id"], r.get("season_total_exp", 0), r.get("prestige_level", 0))
for r in records if r.get("user_id")
),
key=lambda x: x[1],
reverse=True,
)
return result if top_n is None else result[:top_n]
async def get_leaderboard_prestige(top_n: int | None = 10) -> list[tuple[str, int, int]]:
"""Return (user_id, prestige_level, prestige_points) sorted by prestige_level then PP."""
records = await pb_client.list_all_records()
result = sorted(
(
(r["user_id"], r.get("prestige_level", 0), r.get("prestige_points", 0))
for r in records if r.get("user_id")
),
key=lambda x: (x[1], x[2]),
reverse=True,
)
return result if top_n is None else result[:top_n]
async def get_leaderboard_wagered(top_n: int | None = 10) -> list[tuple[str, int]]:
"""Return (user_id, total_wagered) sorted descending."""
records = await pb_client.list_all_records()
result = sorted(
((r["user_id"], r.get("total_wagered", 0)) for r in records if r.get("user_id")),
key=lambda x: x[1],
reverse=True,
)
return result if top_n is None else result[:top_n]
async def get_leaderboard_fish(top_n: int | None = 10) -> list[tuple[str, int]]:
"""Return (user_id, total_fish_caught) sorted descending."""
records = await pb_client.list_all_records()
result = sorted(
((r["user_id"], r.get("total_fish_caught", 0)) for r in records if r.get("user_id")),
key=lambda x: x[1],
reverse=True,
)
return result if top_n is None else result[:top_n]

80
core/economy/levels.py Normal file
View File

@@ -0,0 +1,80 @@
"""EXP, levels and vanity role thresholds."""
from __future__ import annotations
import math
from .store import _locked_by, _prestige_mult, get_user, _commit
# ---------------------------------------------------------------------------
# EXP / Level system
# ---------------------------------------------------------------------------
# EXP awarded per successful action
EXP_REWARDS: dict[str, int] = {
"daily": 50,
"work": 25,
"beg": 5,
"crime_win": 15,
"rob_win": 15,
"gamble_win": 10,
"heist_win": 25,
}
def gamble_exp(bet: int) -> int:
"""Scale EXP for a gambling win by bet size.
Returns 0 for bets < 10 coins to close micro-bet EXP farming.
10-99 → 5, 100-999 → 10, 1 000-9 999 → 15, 10 000+ → 20, 100 000+ → 25 (cap).
"""
return min(25, max(0, int(math.log10(max(1, bet))) * 5))
ECONOMY_ROLE = "ECONOMY"
# Vanity role milestones: (min_level, role_name) - highest first
LEVEL_ROLES: list[tuple[int, str]] = [
(30, "TipiLEGEND"),
(20, "TipiCHAD"),
(10, "TipiHUSTLER"),
(5, "TipiGRINDER"),
(1, "TipiNOOB"),
]
def get_level(exp: int) -> int:
"""Level = max(1, floor(sqrt(exp/10))).
Level 5 @ 250 EXP, 10 @ 1000, 20 @ 4000, 30 @ 9000."""
return max(1, int(math.sqrt(max(0, exp) / 10)))
def exp_for_level(level: int) -> int:
"""Minimum cumulative EXP to reach this level.
Recurrence: exp_for_level(L) = L*20 - 10 + exp_for_level(L-1), base 0.
Closed form: 10*level^2."""
if level <= 1:
return 0
return 10 * level * level
def level_role_name(level: int) -> str:
"""Return the vanity role name for a given level."""
for threshold, name in LEVEL_ROLES:
if level >= threshold:
return name
return LEVEL_ROLES[-1][1]
@_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)
_, exp_mult = _prestige_mult(user)
gained = max(1, int(amount * exp_mult))
old_exp = user.get("exp", 0)
new_exp = old_exp + gained
old_level = get_level(old_exp)
new_level = get_level(new_exp)
user["exp"] = new_exp
user["season_total_exp"] = user.get("season_total_exp", 0) + gained
await _commit(user_id, user)
return {"old_level": old_level, "new_level": new_level, "exp": new_exp, "gained": gained}

106
core/economy/prestige.py Normal file
View File

@@ -0,0 +1,106 @@
"""Prestige resets and the prestige upgrade shop."""
from __future__ import annotations
from ..pb_client import DatabaseError
from ..emoji import EMOJI as E
from .store import PRESTIGE_SHOP, _commit, _locked_by, _txn, get_user
from .levels import get_level
PRESTIGE_ROLE = "TipiPRESTIGE"
PRESTIGE_MIN_LEVEL = 30 # minimum level required to prestige
# ---------------------------------------------------------------------------
# /prestige
# ---------------------------------------------------------------------------
@_locked_by(0)
async def do_prestige(user_id: int) -> dict:
"""Prestige: requires level 30, earns PP, resets balance/exp/items/cooldowns."""
try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
exp = user.get("exp", 0)
level = get_level(exp)
if level < PRESTIGE_MIN_LEVEL:
return {"ok": False, "reason": "level_too_low", "level": level, "required": PRESTIGE_MIN_LEVEL}
pp_earned = max(1, exp // 1000)
new_prestige_level = user.get("prestige_level", 0) + 1
# Preserve: fish_book, fish_inventory, lifetime stats, prestige_points, season_total_exp, prestige_upgrades
user["balance"] = 0
user["exp"] = 0
user["items"] = []
user["item_uses"] = {}
user["last_daily"] = None
user["last_work"] = None
user["last_beg"] = None
user["last_crime"] = None
user["last_rob"] = None
user["last_fish"] = None
user["last_heist"] = None
user["daily_streak"] = 0
user["last_streak_date"] = None
user["jailed_until"] = None
user["jailbreak_used"] = False
user["prestige_level"] = new_prestige_level
user["prestige_points"] = user.get("prestige_points", 0) + pp_earned
await _commit(user_id, user)
_txn("PRESTIGE", user=user_id, pp_earned=pp_earned, prestige=new_prestige_level, old_exp=exp)
return {
"ok": True,
"pp_earned": pp_earned,
"prestige_level": new_prestige_level,
"prestige_points": user["prestige_points"],
"old_exp": exp,
}
@_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:
return {"ok": False, "reason": "not_found"}
try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
upgrade = PRESTIGE_SHOP[upgrade_id]
upgrades: dict = user.get("prestige_upgrades") or {}
current_level = upgrades.get(upgrade_id, 0)
if current_level >= upgrade["max_level"]:
return {"ok": False, "reason": "maxed", "max": upgrade["max_level"]}
pp = user.get("prestige_points", 0)
cost = upgrade["pp_cost"]
if pp < cost:
return {"ok": False, "reason": "insufficient_pp", "have": pp, "need": cost}
upgrades[upgrade_id] = current_level + 1
user["prestige_upgrades"] = upgrades
user["prestige_points"] = pp - cost
await _commit(user_id, user)
_txn("PRESTIGE_BUY", user=user_id, upgrade=upgrade_id,
new_level=upgrades[upgrade_id], pp_left=user["prestige_points"])
return {
"ok": True,
"upgrade_id": upgrade_id,
"new_level": upgrades[upgrade_id],
"max_level": upgrade["max_level"],
"pp_remaining": user["prestige_points"],
}

159
core/economy/quests.py Normal file
View File

@@ -0,0 +1,159 @@
"""Daily/weekly quests tracked from lifetime counters."""
from __future__ import annotations
import random
from typing import TypedDict
from .store import (
UserData, _commit, _locked_by, _log, _now, _prestige_mult, get_user,
)
# ---------------------------------------------------------------------------
# Quest system
# ---------------------------------------------------------------------------
# Quests reuse the monotonic lifetime counters already tracked on each user.
# Progress = (current counter value) - (snapshot taken when the quest was rolled).
# Reset is lazy & per-user: the active set is regenerated the first time a user
# interacts after the day / ISO-week rolls over (mirrors the streak-date logic).
# Rotation is seeded by user id + period key, so each player gets their own set.
class QuestDef(TypedDict):
stat: str # UserData counter field the quest tracks
goal: int
coins: int
exp: int
QUESTS_DAILY: dict[str, QuestDef] = {
"work3": {"stat": "work_count", "goal": 3, "coins": 150, "exp": 20},
"beg5": {"stat": "beg_count", "goal": 5, "coins": 100, "exp": 15},
"wager500": {"stat": "total_wagered", "goal": 500, "coins": 150, "exp": 20},
"fish2": {"stat": "total_fish_caught", "goal": 2, "coins": 150, "exp": 20},
"crime1": {"stat": "crimes_succeeded", "goal": 1, "coins": 200, "exp": 25},
"earn1000": {"stat": "lifetime_earned", "goal": 1000, "coins": 150, "exp": 20},
"give200": {"stat": "total_given", "goal": 200, "coins": 100, "exp": 15},
}
QUESTS_WEEKLY: dict[str, QuestDef] = {
"work20": {"stat": "work_count", "goal": 20, "coins": 1000, "exp": 100},
"fish15": {"stat": "total_fish_caught", "goal": 15, "coins": 1200, "exp": 100},
"wager5000": {"stat": "total_wagered", "goal": 5000, "coins": 1000, "exp": 100},
"crime5": {"stat": "crimes_succeeded", "goal": 5, "coins": 1200, "exp": 120},
"heist1": {"stat": "heists_joined", "goal": 1, "coins": 800, "exp": 80},
"earn10000": {"stat": "lifetime_earned", "goal": 10000, "coins": 1500, "exp": 150},
}
DAILY_QUEST_COUNT = 3
WEEKLY_QUEST_COUNT = 2
def _period_keys() -> tuple[str, str]:
"""Return (day_key, week_key) for the current UTC time."""
today = _now().date()
iso = today.isocalendar()
return today.isoformat(), f"{iso[0]}-W{iso[1]:02d}"
def _pick_quests(pool: dict[str, QuestDef], count: int, seed: str) -> list[str]:
"""Deterministically choose `count` quest ids from `pool` for a period."""
rng = random.Random(seed)
return rng.sample(sorted(pool.keys()), min(count, len(pool)))
def _new_quest_block(
user: UserData, user_id: int, pool: dict[str, QuestDef], count: int,
period_val: str, period_field: str
) -> dict:
chosen = _pick_quests(pool, count, f"{user_id}:{period_field}:{period_val}")
return {
period_field: period_val,
"quests": {
qid: {"snap": int(user.get(pool[qid]["stat"], 0) or 0), "claimed": False}
for qid in chosen
},
}
def _ensure_quests(user: UserData, 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, 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, user_id, QUESTS_WEEKLY, WEEKLY_QUEST_COUNT, week_key, "week")
changed = True
return changed
def _quest_progress(user: UserData, pool: dict[str, QuestDef], qid: str, state: dict) -> int:
cur = int(user.get(pool[qid]["stat"], 0) or 0)
return max(0, cur - int(state.get("snap", 0)))
def _quest_view(user: UserData) -> dict:
def build(pool: dict[str, QuestDef], block: dict) -> list[dict]:
out: list[dict] = []
for qid, state in (block.get("quests") or {}).items():
if qid not in pool:
continue
d = pool[qid]
prog = min(d["goal"], _quest_progress(user, pool, qid, state))
out.append({
"id": qid, "goal": d["goal"], "coins": d["coins"], "exp": d["exp"],
"progress": prog, "done": prog >= d["goal"], "claimed": bool(state.get("claimed")),
})
return out
return {
"daily": build(QUESTS_DAILY, user.get("quest_daily") or {}),
"weekly": build(QUESTS_WEEKLY, user.get("quest_weekly") or {}),
}
@_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, 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, user_id)
coin_mult, _ = _prestige_mult(user)
total_coins = total_exp = claimed = 0
for pool, block in (
(QUESTS_DAILY, user.get("quest_daily") or {}),
(QUESTS_WEEKLY, user.get("quest_weekly") or {}),
):
for qid, state in (block.get("quests") or {}).items():
if qid not in pool or state.get("claimed"):
continue
if _quest_progress(user, pool, qid, state) < pool[qid]["goal"]:
continue
total_coins += pool[qid]["coins"]
total_exp += pool[qid]["exp"]
state["claimed"] = True
claimed += 1
if not claimed:
return {"ok": False, "reason": "nothing"}
coins_awarded = int(total_coins * coin_mult)
user["balance"] += coins_awarded
user["lifetime_earned"] = user.get("lifetime_earned", 0) + coins_awarded
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
await _commit(user_id, user)
return {"ok": True, "claimed": claimed, "coins": coins_awarded, "exp": total_exp, "balance": user["balance"]}

208
core/economy/shop.py Normal file
View File

@@ -0,0 +1,208 @@
"""Shop catalogue and purchases."""
from __future__ import annotations
from typing import TypedDict
import strings
from ..pb_client import DatabaseError
from ..emoji import EMOJI as E
from .store import _locked_by, _txn, get_user, _commit
from .levels import get_level
# ---------------------------------------------------------------------------
# Shop catalogue
# ---------------------------------------------------------------------------
class ShopItem(TypedDict):
name: str
emoji: str
cost: int
description: str
SHOP: dict[str, ShopItem] = {
"gaming_hiir": {
"name": "Mängurihiir",
"emoji": E["TipiHIIR"],
"cost": 500,
"description": strings.ITEM_DESCRIPTIONS["gaming_hiir"],
},
"hiirematt": {
"name": "Hiirematt",
"emoji": E["TipiMATT"],
"cost": 600,
"description": strings.ITEM_DESCRIPTIONS["hiirematt"],
},
"korvaklapid": {
"name": "K\u00f5rvaklapid",
"emoji": E["TipiKLAPID"],
"cost": 1200,
"description": strings.ITEM_DESCRIPTIONS["korvaklapid"],
},
"lan_pass": {
"name": "LAN pilet",
"emoji": E["TipiPILET"],
"cost": 1200,
"description": strings.ITEM_DESCRIPTIONS["lan_pass"],
},
"energiajook": {
"name": "Red Bull",
"emoji": E["TipiBULL"],
"cost": 800,
"description": strings.ITEM_DESCRIPTIONS["energiajook"],
},
"gaming_laptop": {
"name": "Bot Farm",
"emoji": E["TipiLAP"],
"cost": 1500,
"description": strings.ITEM_DESCRIPTIONS["gaming_laptop"],
},
"anticheat": {
"name": "Anticheat",
"emoji": E["TipiVAC"],
"cost": 1000,
"description": strings.ITEM_DESCRIPTIONS["anticheat"],
},
# ----- Tier 2 -----
"reguleeritav_laud": {
"name": "Reguleeritav laud",
"emoji": E["TipiLAUD"],
"cost": 3500,
"description": strings.ITEM_DESCRIPTIONS["reguleeritav_laud"],
},
"jellyfin": {
"name": "Jellyfin server",
"emoji": E["TipiSERVER"],
"cost": 4000,
"description": strings.ITEM_DESCRIPTIONS["jellyfin"],
},
"mikrofon": {
"name": "Eraldiseisev mikrofon",
"emoji": E["TipiMIC"],
"cost": 2800,
"description": strings.ITEM_DESCRIPTIONS["mikrofon"],
},
"klaviatuur": {
"name": "Mehaaniline klaviatuur",
"emoji": E["TipiKLAVA"],
"cost": 1800,
"description": strings.ITEM_DESCRIPTIONS["klaviatuur"],
},
"monitor": {
"name": "Ultralai monitor",
"emoji": E["TipiMONITOR"],
"cost": 2500,
"description": strings.ITEM_DESCRIPTIONS["monitor"],
},
"cat6": {
"name": "Cat6 kaabel",
"emoji": E["TipiCAT"],
"cost": 3500,
"description": strings.ITEM_DESCRIPTIONS["cat6"],
},
# ----- Tier 3 -----
"monitor_360": {
"name": "360Hz monitor",
"emoji": E["TipiMONITOR2"],
"cost": 7500,
"description": strings.ITEM_DESCRIPTIONS["monitor_360"],
},
"karikas": {
"name": "TipiLAN karikas",
"emoji": E["TipiKARIKAS"],
"cost": 6000,
"description": strings.ITEM_DESCRIPTIONS["karikas"],
},
"gaming_tool": {
"name": "Gaming tool",
"emoji": E["TipiTOOL"],
"cost": 9000,
"description": strings.ITEM_DESCRIPTIONS["gaming_tool"],
},
# ----- Fishing items -----
"ussipurk": {
"name": "Ussipurk",
"emoji": "🪣",
"cost": 3500,
"description": strings.ITEM_DESCRIPTIONS["ussipurk"],
},
"kalavork": {
"name": "Kalavõrk",
"emoji": "🪝",
"cost": 5000,
"description": strings.ITEM_DESCRIPTIONS["kalavork"],
},
"echolood": {
"name": "Echolood",
"emoji": "📡",
"cost": 8000,
"description": strings.ITEM_DESCRIPTIONS["echolood"],
},
}
# Tier grouping (used by /shop pagination)
SHOP_TIERS: dict[int, list[str]] = {
1: ["gaming_hiir", "hiirematt", "korvaklapid", "lan_pass", "energiajook", "anticheat", "gaming_laptop"],
2: ["reguleeritav_laud", "jellyfin", "mikrofon", "klaviatuur", "monitor", "cat6", "ussipurk"],
3: ["monitor_360", "karikas", "gaming_tool", "kalavork", "echolood"],
}
# Minimum level required to purchase Tier 2 / Tier 3 shop items
SHOP_LEVEL_REQ: dict[str, int] = {
"reguleeritav_laud": 10,
"jellyfin": 10,
"mikrofon": 10,
"klaviatuur": 10,
"monitor": 10,
"cat6": 10,
"ussipurk": 10,
"monitor_360": 20,
"karikas": 20,
"gaming_tool": 20,
"kalavork": 20,
"echolood": 20,
}
# ---------------------------------------------------------------------------
# /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"}
try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
item = SHOP[item_id]
if item_id in user["items"]:
# Allow repurchase of anticheat if uses are depleted
if item_id == "anticheat" and user.get("item_uses", {}).get("anticheat", 2) <= 0:
user["items"] = [i for i in user["items"] if i != "anticheat"]
user.get("item_uses", {}).pop("anticheat", None)
else:
return {"ok": False, "reason": "owned"}
min_level = SHOP_LEVEL_REQ.get(item_id, 0)
if min_level > 0:
user_level = get_level(user.get("exp", 0))
if user_level < min_level:
return {"ok": False, "reason": "level_required", "min_level": min_level, "user_level": user_level}
if user["balance"] < item["cost"]:
return {"ok": False, "reason": "insufficient", "need": item["cost"] - user["balance"]}
user["balance"] -= item["cost"]
user["items"].append(item_id)
if item_id == "anticheat":
if "item_uses" not in user:
user["item_uses"] = {}
user["item_uses"]["anticheat"] = 2
await _commit(user_id, user)
_txn("BUY", user=user_id, item=item_id, cost=f"-{item['cost']}", bal=user["balance"])
return {"ok": True, "item": item, "balance": user["balance"]}

384
core/economy/store.py Normal file
View File

@@ -0,0 +1,384 @@
"""Shared foundation: user records, locks, time, cooldowns, txn log."""
from __future__ import annotations
import asyncio
import functools
import logging
from datetime import datetime, timedelta, timezone
from typing import TypedDict
import aiohttp
from .. import pb_client
from ..pb_client import DatabaseError
from ..emoji import EMOJI as E
def _clock() -> datetime:
"""Actual time source - a seam so tests can freeze time everywhere at once."""
return datetime.now(tz=timezone.utc)
def _now() -> datetime:
return _clock()
_txn_log = logging.getLogger("tipiCOIN.txn")
def _txn(event: str, **fields) -> None:
"""Log a single economy transaction to the transactions logger."""
body = " ".join(f"{k}={v}" for k, v in fields.items())
_txn_log.info("%-16s %s", event, body)
# Per-profile emoji values live in core/emoji.py; add new IDs there.
COIN = E["TipiCOIN"]
PP_EMOJI = E["TipiFIRE"]
# ---------------------------------------------------------------------------
# Prestige shop catalogue
# ---------------------------------------------------------------------------
class PrestigeItem(TypedDict):
emoji: str
max_level: int
pp_cost: int
effect: float
PRESTIGE_SHOP: dict[str, PrestigeItem] = {
"coin_mult": {
"emoji": E["TipiCOIN"],
"max_level": 5,
"pp_cost": 5,
"effect": 0.08,
},
"exp_mult": {
"emoji": "",
"max_level": 5,
"pp_cost": 5,
"effect": 0.08,
},
"daily_plus": {
"emoji": "📅",
"max_level": 3,
"pp_cost": 7,
"effect": 0.20,
},
"work_plus": {
"emoji": "💼",
"max_level": 3,
"pp_cost": 7,
"effect": 0.20,
},
}
# ---------------------------------------------------------------------------
# Cooldowns
# ---------------------------------------------------------------------------
COOLDOWNS: dict[str, timedelta] = {
"daily": timedelta(hours=20),
"work": timedelta(hours=1),
"beg": timedelta(minutes=5),
"crime": timedelta(hours=2),
"rob": timedelta(hours=2),
"fish": timedelta(minutes=2),
}
JAIL_DURATION = timedelta(minutes=30)
HEIST_JAIL = timedelta(hours=1, minutes=30)
# ---------------------------------------------------------------------------
# User schema
# ---------------------------------------------------------------------------
class UserData(TypedDict, total=False):
balance: int
exp: int # lifetime EXP (resets each season)
last_daily: str | None
last_work: str | None
last_beg: str | None
last_crime: str | None
last_rob: str | None
last_heist: str | None
daily_streak: int
last_streak_date: str | None # ISO date "YYYY-MM-DD"
items: list[str]
item_uses: dict # {item_id: remaining_uses} for consumables
jailed_until: str | None # ISO datetime or None
jailbreak_used: bool
reminders: list[str] # command names user wants DM reminders for
eco_banned: bool # if True, user cannot use any economy commands
# Lifetime statistics
peak_balance: int
lifetime_earned: int
lifetime_lost: int
work_count: int
beg_count: int
total_wagered: int
biggest_win: int
biggest_loss: int
slots_jackpots: int
crimes_attempted: int
crimes_succeeded: int
times_jailed: int
total_bail_paid: int
heists_joined: int
heists_won: int
total_given: int
total_received: int
best_daily_streak: int
heist_global_cd_until: float
# Prestige system
prestige_level: int
prestige_points: int
season_total_exp: int # cumulative EXP this season (survives prestige resets)
prestige_upgrades: dict # {upgrade_id: level}
# Fishing system
last_fish: str | None
fish_book: dict # {fish_id: times_caught}
total_fish_caught: int
fish_inventory: list # [{fish_id, weight, value}] - survives prestige
# Quest system
quest_daily: dict # {"date": "YYYY-MM-DD", "quests": {qid: {snap, claimed}}}
quest_weekly: dict # {"week": "YYYY-Www", "quests": {qid: {snap, claimed}}}
def _default_user() -> UserData:
return {
"balance": 0,
"exp": 0,
"last_daily": None,
"last_work": None,
"last_beg": None,
"last_crime": None,
"last_rob": None,
"last_heist": None,
"daily_streak": 0,
"last_streak_date": None,
"items": [],
"item_uses": {},
"jailed_until": None,
"jailbreak_used": False,
"reminders": ["daily", "work", "beg", "crime", "rob"],
"eco_banned": False,
# ── Lifetime stats ──────────────────────────────────────────────────
"peak_balance": 0,
"lifetime_earned": 0,
"lifetime_lost": 0,
"work_count": 0,
"beg_count": 0,
"total_wagered": 0,
"biggest_win": 0,
"biggest_loss": 0,
"slots_jackpots": 0,
"crimes_attempted": 0,
"crimes_succeeded": 0,
"times_jailed": 0,
"total_bail_paid": 0,
"heists_joined": 0,
"heists_won": 0,
"total_given": 0,
"total_received": 0,
"best_daily_streak": 0,
"heist_global_cd_until": 0.0,
# ── Prestige ─────────────────────────────────────────────────────────
"prestige_level": 0,
"prestige_points": 0,
"season_total_exp": 0,
"prestige_upgrades": {},
# ── Fishing ──────────────────────────────────────────────────────────
"last_fish": None,
"fish_book": {},
"total_fish_caught": 0,
"fish_inventory": [],
# ── Quests ───────────────────────────────────────────────────────────
"quest_daily": {},
"quest_weekly": {},
}
# ---------------------------------------------------------------------------
# Persistence (PocketBase backend)
# ---------------------------------------------------------------------------
_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
# ---------------------------------------------------------------------------
# Public helpers
# ---------------------------------------------------------------------------
async def missing_schema_fields() -> list[str]:
"""Compare the live PocketBase collection schema against every field the
bot persists. PocketBase silently drops writes to undeclared fields, so
any name returned here means broken features without error messages."""
live = await pb_client.get_collection_fields()
expected = set(_default_user()) | {"user_id"}
return sorted(expected - live)
async def get_all_users_raw() -> dict[str, "UserData"]:
"""Return a snapshot of all user records."""
records = await pb_client.list_all_records()
result: dict[str, UserData] = {}
for record in records:
uid = record.get("user_id", "")
if not uid:
continue
user = _default_user()
for key in list(user.keys()):
if key in record:
user[key] = record[key] # type: ignore[literal-required]
user["_pb_id"] = record["id"] # type: ignore[typeddict-unknown-key]
result[uid] = user
return result
def _parse_dt(s: str | None) -> datetime | None:
if not s:
return None
dt = datetime.fromisoformat(s)
# Ensure timezone-aware
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
def _cooldown_remaining(
user: UserData, action: str, override_cd: timedelta | None = None
) -> timedelta | None:
"""Return remaining cooldown, or None if the action is ready."""
last = _parse_dt(user.get(f"last_{action}"))
if last is None:
return None
cd = override_cd if override_cd is not None else COOLDOWNS[action]
remaining = cd - (_now() - last)
return remaining if remaining.total_seconds() > 0 else None
def _is_jailed(user: UserData) -> timedelta | None:
"""Return remaining jail time, or None if free."""
until = _parse_dt(user.get("jailed_until"))
if until is None:
return None
remaining = until - _now()
return remaining if remaining.total_seconds() > 0 else None
def jailed_remaining(user: UserData) -> timedelta | None:
"""Public wrapper - return remaining jail time, or None if free."""
return _is_jailed(user)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def format_td(td: timedelta) -> str:
"""Human-readable timedelta: '1t 23m' / '45m 12s' / '8s'."""
total = int(td.total_seconds())
h, rem = divmod(total, 3600)
m, s = divmod(rem, 60)
if h:
return f"{h}t {m}m"
if m:
return f"{m}m {s}s"
return f"{s}s"
async def get_user(user_id: int) -> UserData:
"""Fetch user data from PocketBase, creating a default record if first seen."""
uid = str(user_id)
try:
record = await pb_client.get_record(uid)
if record is None:
default = _default_user()
default["user_id"] = uid # type: ignore[typeddict-unknown-key]
record = await pb_client.create_record(default)
except (aiohttp.ClientError, asyncio.TimeoutError, RuntimeError) as exc:
_log.error("PocketBase unreachable for user %s: %s", user_id, exc)
raise DatabaseError(f"Database unavailable: {exc}") from exc
user = _default_user()
for key in list(user.keys()):
if key in record:
user[key] = record[key] # type: ignore[literal-required]
user["_pb_id"] = record["id"] # type: ignore[typeddict-unknown-key]
return user
def _prestige_mult(user: UserData) -> tuple[float, float]:
"""Return (coin_mult, exp_mult) based on prestige upgrades. Both ≥1.0."""
upgrades: dict = user.get("prestige_upgrades") or {} # type: ignore[assignment]
coin_level = upgrades.get("coin_mult", 0)
exp_level = upgrades.get("exp_mult", 0)
return (
1.0 + coin_level * PRESTIGE_SHOP["coin_mult"]["effect"],
1.0 + exp_level * PRESTIGE_SHOP["exp_mult"]["effect"],
)
# ---------------------------------------------------------------------------
# Internal write helper
# ---------------------------------------------------------------------------
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:
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
# ---------------------------------------------------------------------------
# /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)
user["reminders"] = list(commands)
await _commit(user_id, user)

97
core/emoji.py Normal file
View File

@@ -0,0 +1,97 @@
"""Custom Discord emoji registry, keyed by symbolic name and resolved per BOT_PROFILE.
Emojis are uploaded as application emojis via the Discord Developer Portal
and are scoped to a single bot application. Dev and production are separate
applications, so the same logical emoji has a different ID in each — hence
two dicts below, selected by BOT_PROFILE.
To add a new emoji: upload it to both applications in the dev portal, grab
the two IDs, and add one line to each dict.
"""
from __future__ import annotations
from config import BOT_PROFILE
_DEV: dict[str, str] = {
"TipiCOIN": "<:TipiCOIN:1483000209188589628>",
"TipiFIRE": "<:TipiFIRE:1483431381668335687>",
"TipiHIIR": "<:TipiHIIR:1483004306012504128>",
"TipiMATT": "<:TipiMATT:1483387697132208128>",
"TipiKLAPID": "<:TipiKLAPID:1483387694083084349>",
"TipiPILET": "<:TipiPILET:1483004308353060904>",
"TipiBULL": "<:TipiBULL:1483004310924300409>",
"TipiLAP": "<:TipiLAP:1483004307161874566>",
"TipiVAC": "<:TipiVAC:1483004309510819860>",
"TipiLAUD": "<:TipiLAUD:1483387695576125440>",
"TipiSERVER": "<:TipiSERVER:1483387701032910969>",
"TipiMIC": "<:TipiMIC:1483387698499551313>",
"TipiKLAVA": "<:TipiKLAVA:1483014339228078140>",
"TipiMONITOR": "<:TipiMONITOR:1483014340327243908>",
"TipiCAT": "<:TipiCAT:1483014337663602718>",
"TipiMONITOR2": "<:TipiMONITOR2:1483387699514839162>",
"TipiKARIKAS": "<:TipiKARIKAS:1483014841148112977>",
"TipiTOOL": "<:TipiTOOL:1483014341648187613>",
"TipiHEART": "<:TipiHEART:1483431377561976853>",
"TipiTROLL": "<:TipiTROLL:1483431380166774895>",
"TipICRY": "<:TipICRY:1483431288852709387>",
"TipiSKULL": "<:TipiSKULL:1483431378929451028>",
"TipiDICE": "<a:TipiDICE:1485923107108556950>",
"TipiYKS": "<:TipiYKS:1483103190491856916>",
"TipiKAKS": "<:TipiKAKS:1483103215841972404>",
"TipiKOLM": "<:TipiKOLM:1483103217846980781>",
"TipiNELI": "<:TipiNELI:1483103237585240114>",
"TipiVIIS": "<:TipiVIIS:1483103239036469289>",
"TipiKUUS": "<:TipiKUUS:1483103253163020348>",
"TipiSLOTS": "<a:TipiSLOTS:1483444233863037101>",
}
# Production application emoji IDs (from the TipiBOT application's dev-portal
# Emojis tab). The display name in <:name:id> is cosmetic — Discord resolves
# by ID — so we keep the same logical names as _DEV even when the prod portal
# uses a different upload name (e.g. prod's "TipiFIVE" → key "TipiVIIS").
_ECONOMY: dict[str, str] = {
"TipiCOIN": "<:TipiCOIN:1511754551747940485>",
"TipiFIRE": "<:TipiFIRE:1511754615761272862>",
"TipiHIIR": "<:TipiHIIR:1511754556105822218>",
"TipiMATT": "<:TipiMATT:1511754595448131665>",
"TipiKLAPID": "<:TipiKLAPID:1511754589798666433>",
"TipiPILET": "<:TipiPILET:1511754560593727652>",
"TipiBULL": "<:TipiBULL:1511754564179595264>",
"TipiLAP": "<:TipiLAP:1511754558970269907>",
"TipiVAC": "<:TipiVAC:1511754562413924442>",
"TipiLAUD": "<:TipiLAUD:1511754592759713985>",
"TipiSERVER": "<:TipiSERVER:1511754601186066534>",
"TipiMIC": "<:TipiMIC:1511754597016801310>",
"TipiKLAVA": "<:TipiKLAVA:1511754567648542780>",
"TipiMONITOR": "<:TipiMONITOR:1511754570722971868>",
"TipiCAT": "<:TipiCAT:1511754566092193853>",
"TipiMONITOR2": "<:TipiMONITOR2:1511754598732402689>",
"TipiKARIKAS": "<:TipiKARIKAS:1511754574405435502>",
"TipiTOOL": "<:TipiTOOL:1511754572522061874>",
"TipiHEART": "<:TipiHEART:1511754608299737139>",
"TipiTROLL": "<:TipiTROLL:1511754612775063832>",
"TipICRY": "<:TipICRY:1511754603308515368>",
"TipiSKULL": "<:TipiSKULL:1511754610195566622>",
"TipiDICE": "<a:TipiDICE:1511753607119376504>",
"TipiYKS": "<:TipiYKS:1511754576368373951>",
"TipiKAKS": "<:TipiKAKS:1511754577928523997>",
"TipiKOLM": "<:TipiKOLM:1511754581078442005>",
"TipiNELI": "<:TipiNELI:1511754582571880509>",
"TipiVIIS": "<:TipiVIIS:1511754584182227005>",
"TipiKUUS": "<:TipiKUUS:1511754586262736977>",
"TipiSLOTS": "<a:TipiSLOTS:1511754521188106431>",
}
_EMOJI_SETS = {
"dev": _DEV,
"economy": _ECONOMY,
}
EMOJI: dict[str, str] = _EMOJI_SETS[BOT_PROFILE]
_missing = set(_DEV) - set(EMOJI)
if _missing:
raise RuntimeError(f"Emoji set {BOT_PROFILE!r} missing keys: {sorted(_missing)}")

View File

@@ -147,6 +147,12 @@ async def update_record(record_id: str, data: dict[str, Any]) -> dict[str, Any]:
) )
async def get_collection_fields() -> set[str]:
"""Return the field names defined on the economy collection's schema."""
data = await _request("GET", f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}")
return {f["name"] for f in data.get("fields", [])}
async def count_records() -> int: async def count_records() -> int:
"""Return the total number of records in the collection (single cheap request).""" """Return the total number of records in the collection (single cheap request)."""
data = await _request( data = await _request(

View File

@@ -3,6 +3,12 @@
Here you'll find an overview of TipiBOT updates. Latest changes are at the top. Here you'll find an overview of TipiBOT updates. Latest changes are at the top.
Format each version with a `## ` header (e.g. `## v0.1.0 — 2026-05-03`). Format each version with a `## ` header (e.g. `## v0.1.0 — 2026-05-03`).
## v0.2.0 — 2026-07-22
- Added `/quests` — daily and weekly quests with a "claim rewards" button. Three daily quests refresh every day and two weekly quests refresh every week; 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
- Fixed the maintenance pause and channel restrictions never actually being enforced — the global command check was registered incorrectly and silently did nothing (command logging was also affected)
## v0.1.0 — 2026-05-03 ## v0.1.0 — 2026-05-03
- Added `/patchnotes` - Added `/patchnotes`

View File

@@ -44,6 +44,17 @@ Add the following fields:
> **Tip:** Set `user_id` as a unique index under **Indexes** tab. > **Tip:** Set `user_id` as a unique index under **Indexes** tab.
The table above is only the base schema. After creating the collections, run:
```bash
python scripts/sync_pb_schema.py
```
It derives the complete field list from the bot's user model and adds whatever
is missing to **both** collections (stats, fishing, prestige, quests, ...).
Re-run it after every update - PocketBase silently drops writes to fields that
are not in the collection schema, which breaks features without any error.
Set **API rules** (all four: list, view, create, update) to admin-only (leave blank / locked). Set **API rules** (all four: list, view, create, update) to admin-only (leave blank / locked).
--- ---

2
requirements-dev.txt Normal file
View File

@@ -0,0 +1,2 @@
-r requirements.txt
pytest>=8.0

101
scripts/add_quest_fields.py Normal file
View File

@@ -0,0 +1,101 @@
"""Add the quest-system JSON fields to the economy_users PocketBase collection.
Run once after pulling the quest changes:
python scripts/add_quest_fields.py
Requirements:
- PocketBase running and reachable at PB_URL
- PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD set in .env
"""
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
import aiohttp
from dotenv import load_dotenv
sys.path.insert(0, str(Path(__file__).parent.parent))
load_dotenv()
import config # noqa: E402
PB_URL = config.PB_URL
PB_ADMIN_EMAIL = config.PB_ADMIN_EMAIL
PB_ADMIN_PASSWORD = config.PB_ADMIN_PASSWORD
# 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_JSON_FIELDS = [
"quest_daily",
"quest_weekly",
]
def _json_field(name: str) -> dict:
return {"name": name, "type": "json", "required": False}
async def main() -> None:
timeout = aiohttp.ClientTimeout(total=15)
async with aiohttp.ClientSession(timeout=timeout) as session:
# ── Authenticate ────────────────────────────────────────────────────
async with session.post(
f"{PB_URL}/api/collections/_superusers/auth-with-password",
json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD},
) as resp:
if resp.status != 200:
print(f"Auth failed ({resp.status}): {await resp.text()}")
return
token = (await resp.json())["token"]
hdrs = {"Authorization": token}
for collection in COLLECTIONS:
print(f"── {collection} ──")
# ── 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()
existing = {f["name"] for f in col.get("fields", [])}
print(f"Existing fields ({len(existing)}): {sorted(existing)}\n")
new_fields = []
for name in _NEW_JSON_FIELDS:
if name not in existing:
new_fields.append(_json_field(name))
print(f" + {name} (json)")
else:
print(f" = {name} (already exists)")
if not new_fields:
print("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__":
asyncio.run(main())

131
scripts/sync_pb_schema.py Normal file
View File

@@ -0,0 +1,131 @@
"""Reconcile the PocketBase economy collections with the bot's user schema.
The expected field list is derived from core.economy._default_user() - the
single source of truth for what the bot persists - so this supersedes
add_stats_fields.py / add_quest_fields.py and also covers the fields those
scripts never added (fishing, prestige, quests, last_heist, ...).
PocketBase silently drops record fields that are missing from the collection
schema, so any drift here breaks features without a single error message.
Patches BOTH profile collections regardless of BOT_PROFILE. Only ever adds
fields; existing fields are never removed or retyped (type mismatches are
reported for manual review).
python scripts/sync_pb_schema.py # add whatever is missing
python scripts/sync_pb_schema.py --check # report only; exit 1 on drift
"""
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
import aiohttp
from dotenv import load_dotenv
sys.path.insert(0, str(Path(__file__).parent.parent))
load_dotenv()
import config # noqa: E402
from core import economy # noqa: E402
PB_URL = config.PB_URL
COLLECTIONS = sorted({config.PB_ECONOMY_COLLECTION_DEV, config.PB_ECONOMY_COLLECTION_ECONOMY})
# _default_user() keys whose default is None, all ISO date/datetime strings
_TEXT_FIELDS = {
"last_daily", "last_work", "last_beg", "last_crime", "last_rob",
"last_heist", "last_fish", "last_streak_date", "jailed_until",
}
def _expected_fields() -> dict[str, str]:
"""Map every persisted field name to its PocketBase field type."""
expected = {"user_id": "text"}
for key, default in economy._default_user().items():
if key in _TEXT_FIELDS:
expected[key] = "text"
elif isinstance(default, bool):
expected[key] = "bool"
elif isinstance(default, (int, float)):
expected[key] = "number"
elif isinstance(default, (list, dict)):
expected[key] = "json"
else:
raise SystemExit(
f"Cannot infer a PocketBase type for {key!r} (default {default!r}). "
"Add it to _TEXT_FIELDS or extend the mapping."
)
return expected
async def main() -> int:
check_only = "--check" in sys.argv
expected = _expected_fields()
drift = False
timeout = aiohttp.ClientTimeout(total=15)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(
f"{PB_URL}/api/collections/_superusers/auth-with-password",
json={"identity": config.PB_ADMIN_EMAIL, "password": config.PB_ADMIN_PASSWORD},
) as resp:
if resp.status != 200:
print(f"Auth failed ({resp.status}): {await resp.text()}")
return 1
hdrs = {"Authorization": (await resp.json())["token"]}
for collection in COLLECTIONS:
print(f"── {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")
drift = True
continue
col = await resp.json()
existing = {f["name"]: f.get("type", "?") for f in col.get("fields", [])}
missing = [name for name in expected if name not in existing]
mismatched = [
(name, existing[name], ftype)
for name, ftype in expected.items()
if name in existing and existing[name] != ftype
]
for name, have, want in mismatched:
drift = True
print(f" ! {name}: schema has type '{have}', bot expects '{want}' - fix manually")
if not missing:
print(" ✓ no missing fields\n" if not mismatched else "")
continue
drift = True
for name in missing:
print(f" + {name} ({expected[name]})")
if check_only:
print()
continue
new_fields = [{"name": name, "type": expected[name]} for name in missing]
async with session.patch(
f"{PB_URL}/api/collections/{collection}",
json={"fields": col.get("fields", []) + new_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)\n")
return 1 if (check_only and drift) else 0
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))

View File

@@ -4,6 +4,8 @@ Edit this file to change any message, description, or flavour text
without touching any logic code. without touching any logic code.
""" """
from core.emoji import EMOJI as E
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Flavour text # Flavour text
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -172,6 +174,7 @@ CMD: dict[str, str] = {
"fishbook": "Vaata oma kalakogu ja kogutud kalaliike", "fishbook": "Vaata oma kalakogu ja kogutud kalaliike",
"fishsell": "Müü kalu oma inventarist", "fishsell": "Müü kalu oma inventarist",
"patchnotes": "Vaata TipiBOTi viimaseid muudatusi ja uuendusi", "patchnotes": "Vaata TipiBOTi viimaseid muudatusi ja uuendusi",
"quests": "Vaata oma päeva- ja nädalaülesandeid ning nõua auhinnad",
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -244,6 +247,7 @@ HELP_CATEGORIES: dict[str, dict] = {
("/heist", "Alusta grupiröövi pangahoidlasse. Min 2 mängijat, max 8. 5 min ühinemisaeg. Õnnestumisel jagatakse saak võrdselt - ebaõnnestumisel 1h 30min vangis + trahv. 4h serveri ooteaeg (ei ole isiklik)."), ("/heist", "Alusta grupiröövi pangahoidlasse. Min 2 mängijat, max 8. 5 min ühinemisaeg. Õnnestumisel jagatakse saak võrdselt - ebaõnnestumisel 1h 30min vangis + trahv. 4h serveri ooteaeg (ei ole isiklik)."),
("/jailbreak", "Proovi vanglas olles täringuid visata, et duublit saada (3 katset). Duubli korral saad vabaks. Ebaõnnestumisel saad valida: maksa kautsjon (20-30% saldost, min 350 ⬡) või jää vanglasse kuni aja lõpuni."), ("/jailbreak", "Proovi vanglas olles täringuid visata, et duublit saada (3 katset). Duubli korral saad vabaks. Ebaõnnestumisel saad valida: maksa kautsjon (20-30% saldost, min 350 ⬡) või jää vanglasse kuni aja lõpuni."),
("/give @user <amount>", "Anna TipiCOINe teisele mängijale"), ("/give @user <amount>", "Anna TipiCOINe teisele mängijale"),
("/quests", "Vaata oma päeva- ja nädalaülesandeid ning nõua auhinnad (uueneb iga päev/nädal)."),
("/leaderboard", "TipiBOTi edetabel - kes on kõige rikkam?"), ("/leaderboard", "TipiBOTi edetabel - kes on kõige rikkam?"),
("/shop", "Sirvi TipiBOTi poodi"), ("/shop", "Sirvi TipiBOTi poodi"),
("/buy <item>", "Osta ese TipiBOTi poodist"), ("/buy <item>", "Osta ese TipiBOTi poodist"),
@@ -256,22 +260,22 @@ HELP_CATEGORIES: dict[str, dict] = {
"description": "TipiBOTi poe esemed ja nende efektid", "description": "TipiBOTi poe esemed ja nende efektid",
"color": 0xF4C430, "color": 0xF4C430,
"fields": [ "fields": [
("<:TipiHIIR:1483004306012504128> Mängurihiir - 500 ⬡", "Teeni töötades 50% rohkem TipiCOINe."), (f"{E['TipiHIIR']} Mängurihiir - 500 ⬡", "Teeni töötades 50% rohkem TipiCOINe."),
("<:TipiMATT:1483387697132208128> XL hiirematt - 600 ⬡", "Kerjamise ooteaeg 5min → 3min."), (f"{E['TipiMATT']} XL hiirematt - 600 ⬡", "Kerjamise ooteaeg 5min → 3min."),
("<:TipiKLAPID:1483387694083084349> Kõrvaklapid - 1200 ⬡", "Päevase boonuse ooteaeg 20h → 18h."), (f"{E['TipiKLAPID']} Kõrvaklapid - 1200 ⬡", "Päevase boonuse ooteaeg 20h → 18h."),
("<:TipiPILET:1483004308353060904> LAN pilet (2025) - 1200 ⬡", "Päevane boonus on duubeldatud."), (f"{E['TipiPILET']} LAN pilet (2025) - 1200 ⬡", "Päevane boonus on duubeldatud."),
("<:TipiVAC:1483004309510819860> Anticheat - 750 ⬡", "Röövimine sinu vastu ebaõnnestub. Pärast 2 kasutust pead ostma uue."), (f"{E['TipiVAC']} Anticheat - 750 ⬡", "Röövimine sinu vastu ebaõnnestub. Pärast 2 kasutust pead ostma uue."),
("<:TipiBULL:1483004310924300409> Red Bull - 800 ⬡", "30% tõenäosus, et teenid töötades 3x rohkem."), (f"{E['TipiBULL']} Red Bull - 800 ⬡", "30% tõenäosus, et teenid töötades 3x rohkem."),
("<:TipiLAP:1483004307161874566> Botikoobas - 1500 ⬡", "RTX 5090 jooksutab botte 24/7. Päevane boonus genereerib 5% intressi sinu saldo pealt."), (f"{E['TipiLAP']} Botikoobas - 1500 ⬡", "RTX 5090 jooksutab botte 24/7. Päevane boonus genereerib 5% intressi sinu saldo pealt."),
("<:TipiLAUD:1483387695576125440> Reguleeritav laud - 3500 ⬡ *(T2)*", "/work teenib 25% rohkem (stackib mängurihiirega)."), (f"{E['TipiLAUD']} Reguleeritav laud - 3500 ⬡ *(T2)*", "/work teenib 25% rohkem (stackib mängurihiirega)."),
("<:TipiSERVER:1483387701032910969> Jellyfin server - 4000 ⬡ *(T2)*", "Röövimise edu tõenäosus 45% → 60%."), (f"{E['TipiSERVER']} Jellyfin server - 4000 ⬡ *(T2)*", "Röövimise edu tõenäosus 45% → 60%."),
("<:TipiMIC:1483387698499551313> Mikrofon - 2800 ⬡ *(T2)*", "Teeni 30% rohkem eduka /crime puhul."), (f"{E['TipiMIC']} Mikrofon - 2800 ⬡ *(T2)*", "Teeni 30% rohkem eduka /crime puhul."),
("<:TipiKLAVA:1483014339228078140> Mehhaaniline klaviatuur - 1800 ⬡ *(T2)*", "/beg teenib 2x rohkem."), (f"{E['TipiKLAVA']} Mehhaaniline klaviatuur - 1800 ⬡ *(T2)*", "/beg teenib 2x rohkem."),
("<:TipiMONITOR:1483014340327243908> Ultralai monitor - 2500 ⬡ *(T2)*", "/work ooteaeg: 1h → 40min."), (f"{E['TipiMONITOR']} Ultralai monitor - 2500 ⬡ *(T2)*", "/work ooteaeg: 1h → 40min."),
("<:TipiCAT:1483014337663602718> CAT6 netikaabel - 3500 ⬡ *(T2)*", "/crime edu tõenäosus tõuseb 60% → 75%."), (f"{E['TipiCAT']} CAT6 netikaabel - 3500 ⬡ *(T2)*", "/crime edu tõenäosus tõuseb 60% → 75%."),
("<:TipiMONITOR2:1483387699514839162> 360hz monitor - 7500 ⬡ *(T3)*", "Mänguautomaadi jackpot 10x → 15x, kolmik 4x → 6x."), (f"{E['TipiMONITOR2']} 360hz monitor - 7500 ⬡ *(T3)*", "Mänguautomaadi jackpot 10x → 15x, kolmik 4x → 6x."),
("<:TipiKARIKAS:1483014841148112977> TipiLANi trofee - 6000 ⬡ *(T3)*", "Streak ei nulli, kui sa mõne päeva vahele jätad."), (f"{E['TipiKARIKAS']} TipiLANi trofee - 6000 ⬡ *(T3)*", "Streak ei nulli, kui sa mõne päeva vahele jätad."),
("<:TipiTOOL:1483014341648187613> Mänguritool - 9000 ⬡ *(T3)*", "/crime ebaõnnestumine ei saada sind vanglasse."), (f"{E['TipiTOOL']} Mänguritool - 9000 ⬡ *(T3)*", "/crime ebaõnnestumine ei saada sind vanglasse."),
], ],
}, },
"games": { "games": {
@@ -347,10 +351,10 @@ REMINDER_OPTS: list[tuple[str, str, str]] = [
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
SLOTS_TIERS: dict[str, tuple[str, int]] = { SLOTS_TIERS: dict[str, tuple[str, int]] = {
"jackpot": ("<:TipiFIRE:1483431381668335687> JACKPOT!!!", 0xF4C430), "jackpot": (f"{E['TipiFIRE']} JACKPOT!!!", 0xF4C430),
"triple": ("🎰 Kolmik!", 0x57F287), "triple": ("🎰 Kolmik!", 0x57F287),
"pair": ("🎰 Paar", 0x99AAB5), "pair": ("🎰 Paar", 0x99AAB5),
"miss": ("<:TipICRY:1483431288852709387> Ei õnnestunud", 0xED4245), "miss": (f"{E['TipICRY']} Ei õnnestunud", 0xED4245),
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -534,22 +538,22 @@ TITLE: dict[str, str] = {
"daily": "📅 Päevane boonus", "daily": "📅 Päevane boonus",
"work": "💼 Töö", "work": "💼 Töö",
"beg": "🙏 Kerjamine", "beg": "🙏 Kerjamine",
"crime_win": "<:TipiFIRE:1483431381668335687> Kuritegu õnnestus!", "crime_win": f"{E['TipiFIRE']} Kuritegu õnnestus!",
"crime_fail": "<:TipiTROLL:1483431380166774895> Vahele jäid!", "crime_fail": f"{E['TipiTROLL']} Vahele jäid!",
"rob_win": "<:TipiFIRE:1483431381668335687> Rööv õnnestus!", "rob_win": f"{E['TipiFIRE']} Rööv õnnestus!",
"rob_fail": "<:TipiTROLL:1483431380166774895> Rööv ebaõnnestus!", "rob_fail": f"{E['TipiTROLL']} Rööv ebaõnnestus!",
"rob_anticheat": "<:TipiVAC:1483004309510819860> Anticheat peatas sind!", "rob_anticheat": f"{E['TipiVAC']} Anticheat peatas sind!",
"jailbreak": "🎲 Vanglast põgenemine", "jailbreak": "🎲 Vanglast põgenemine",
"jailbreak_free": "🎲 <:TipiFIRE:1483431381668335687> DUUBEL! Oled vaba!", "jailbreak_free": f"🎲 {E['TipiFIRE']} DUUBEL! Oled vaba!",
"jailbreak_fail": "<:TipICRY:1483431288852709387> Kolm katset läbi!", "jailbreak_fail": f"{E['TipICRY']} Kolm katset läbi!",
"jailbreak_miss": "🎲 <:TipICRY:1483431288852709387> Ei saanud duublit ({tries}/{max})", "jailbreak_miss": "🎲 " + E["TipICRY"] + " Ei saanud duublit ({tries}/{max})",
"jailbreak_bail": "💸 Kautsjon", "jailbreak_bail": "💸 Kautsjon",
"give": "<:TipiHEART:1483431377561976853> TipiCOINi ülekanne", "give": f"{E['TipiHEART']} TipiCOINi ülekanne",
"stats": "📊 Mängustatistika", "stats": "📊 Mängustatistika",
"leaderboard_coins": "🪙 TipiBOTi edetabel - Mündid", "leaderboard_coins": "🪙 TipiBOTi edetabel - Mündid",
"leaderboard_exp": "📊 TipiBOTi edetabel - EXP / Tase", "leaderboard_exp": "📊 TipiBOTi edetabel - EXP / Tase",
"leaderboard_season": "🏆 TipiBOTi edetabel - Hooaja EXP", "leaderboard_season": "🏆 TipiBOTi edetabel - Hooaja EXP",
"leaderboard_prestige": "<:TipiFIRE:1483431381668335687> TipiBOTi edetabel - Prestiiž", "leaderboard_prestige": f"{E['TipiFIRE']} TipiBOTi edetabel - Prestiiž",
"leaderboard_wagered": "🎲 TipiBOTi edetabel - Hasartmängud", "leaderboard_wagered": "🎲 TipiBOTi edetabel - Hasartmängud",
"leaderboard_fish": "🎣 TipiBOTi edetabel - Kalapüük", "leaderboard_fish": "🎣 TipiBOTi edetabel - Kalapüük",
"rps": "⚔️ Kivi, Paber, Käärid", "rps": "⚔️ Kivi, Paber, Käärid",
@@ -560,32 +564,70 @@ TITLE: dict[str, str] = {
"rps_duel_expire": "⚔️ KPK duell - aegus", "rps_duel_expire": "⚔️ KPK duell - aegus",
"rps_duel_decline": "⚔️ KPK duell - keelduti", "rps_duel_decline": "⚔️ KPK duell - keelduti",
"heist_lobby": "🔫 Grupirööv - kogunemine", "heist_lobby": "🔫 Grupirööv - kogunemine",
"heist_win": "<:TipiFIRE:1483431381668335687> Grupirööv õnnestus!", "heist_win": f"{E['TipiFIRE']} Grupirööv õnnestus!",
"heist_fail": "<:TipiSKULL:1483431378929451028> Grupirööv ebaõnnestus!", "heist_fail": f"{E['TipiSKULL']} Grupirööv ebaõnnestus!",
"heist_cancel": "🔫 Grupirööv tühistatud", "heist_cancel": "🔫 Grupirööv tühistatud",
"request": "<:TipiHEART:1483431377561976853> Rahataotlus", "request": f"{E['TipiHEART']} Rahataotlus",
"reminders": "⏰ Meeldetuletused", "reminders": "⏰ Meeldetuletused",
"cooldowns": "⏱️ Sinu ooteajad", "cooldowns": "⏱️ Sinu ooteajad",
"adminseason": "🏆 Hooaeg lõppes!", "adminseason": "🏆 Hooaeg lõppes!",
"economysetup": "⚙️ Majanduse seadistamine", "economysetup": "⚙️ Majanduse seadistamine",
"blackjack": "🃏 Blackjack", "blackjack": "🃏 Blackjack",
"blackjack_bj": "🃏 <:TipiFIRE:1483431381668335687> BLACKJACK!", "blackjack_bj": f"🃏 {E['TipiFIRE']} BLACKJACK!",
"blackjack_win": "<:TipiFIRE:1483431381668335687> Võitsid!", "blackjack_win": f"{E['TipiFIRE']} Võitsid!",
"blackjack_lose": "<:TipiSKULL:1483431378929451028> Kaotasid!", "blackjack_lose": f"{E['TipiSKULL']} Kaotasid!",
"blackjack_bust": "<:TipiSKULL:1483431378929451028> Üle 21 - kaotasid!", "blackjack_bust": f"{E['TipiSKULL']} Üle 21 - kaotasid!",
"blackjack_push": "🤝 Viik!", "blackjack_push": "🤝 Viik!",
"blackjack_dbust": "<:TipiSKULL:1483431378929451028> Üle 21 - mõlemad kaotasid!", "blackjack_dbust": f"{E['TipiSKULL']} Üle 21 - mõlemad kaotasid!",
"blackjack_dwin": "<:TipiFIRE:1483431381668335687> Topeltpanus võitis!", "blackjack_dwin": f"{E['TipiFIRE']} Topeltpanus võitis!",
"prestige_confirm": "🔥 Prestiiž - kinnita", "prestige_confirm": "🔥 Prestiiž - kinnita",
"prestige_success": "<:TipiFIRE:1483431381668335687> Prestiiž {level} saavutatud!", "prestige_success": E["TipiFIRE"] + " Prestiiž {level} saavutatud!",
"prestige_too_low": "❌ Prestiiž pole saadaval", "prestige_too_low": "❌ Prestiiž pole saadaval",
"prestige_shop": "<:TipiFIRE:1483431381668335687> Prestiižipood", "prestige_shop": f"{E['TipiFIRE']} Prestiižipood",
"prestige_buy_ok": "✅ Uuendus ostetud!", "prestige_buy_ok": "✅ Uuendus ostetud!",
"fish_cast": "🎣 Otsid kala...", "fish_cast": "🎣 Otsid kala...",
"fish_bite": "🐟 KALA NÄKKAB!", "fish_bite": "🐟 KALA NÄKKAB!",
"fish_escape": "🎣 Kala pääses!", "fish_escape": "🎣 Kala pääses!",
"fish_junk": "🗑️ Ai ai ai...", "fish_junk": "🗑️ Ai ai ai...",
"fishbook": "📖 Kalakogu", "fishbook": "📖 Kalakogu",
"quests": "🎯 Ülesanded",
}
# ---------------------------------------------------------------------------
# Quest system (/quests)
# ---------------------------------------------------------------------------
QUEST_UI: dict[str, str] = {
"daily_header": "📅 Päevaülesanded",
"weekly_header": "📆 Nädalaülesanded",
"progress": "{progress}/{max}",
"ready": "✅ Valmis - nõua auhind!",
"completed": "🏆 Nõutud",
"reward": "🎁 {coins} ⬡ · {exp} EXP",
"empty": "Ühtegi ülesannet pole. Proovi hiljem uuesti!",
"claim_btn": "🎁 Nõua auhinnad",
"claimed_msg": "🏆 Nõudsid {count} ülesande auhinnad: +{coins} · +{exp} EXP!",
"nothing": "Sul pole ühtegi valmis ülesannet, mida nõuda.",
"error": "❌ Ülesannete laadimine ebaõnnestus. Proovi hiljem uuesti.",
}
# Estonian one-line description per quest id (keys match economy.QUESTS_*).
QUEST_DESCRIPTIONS: dict[str, str] = {
# Daily
"work3": "Tööta 3 korda (/work)",
"beg5": "Kerja 5 korda (/beg)",
"wager500": "Panusta kokku 500 ⬡ hasartmängudes",
"fish2": "Püüa 2 kala (/fish)",
"crime1": "Soorita edukalt 1 kuritegu (/crime)",
"earn1000": "Teeni kokku 1 000 ⬡",
"give200": "Kingi teistele kokku 200 ⬡ (/give)",
# Weekly
"work20": "Tööta 20 korda (/work)",
"fish15": "Püüa 15 kala (/fish)",
"wager5000": "Panusta kokku 5 000 ⬡ hasartmängudes",
"crime5": "Soorita edukalt 5 kuritegu (/crime)",
"heist1": "Osale 1 grupiröövis (/heist)",
"earn10000": "Teeni kokku 10 000 ⬡",
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -609,8 +651,8 @@ ERR: dict[str, str] = {
"heist_active": "❌ Serveris on juba aktiivne grupirööv käimas! Oota, kuni see lõpeb.", "heist_active": "❌ Serveris on juba aktiivne grupirööv käimas! Oota, kuni see lõpeb.",
"heist_full": "❌ Grupirööv on täis!", "heist_full": "❌ Grupirööv on täis!",
"heist_min_players": "❌ Grupiröövi alustamiseks on vaja vähemalt **{min}** osalejat.", "heist_min_players": "❌ Grupiröövi alustamiseks on vaja vähemalt **{min}** osalejat.",
"broke": "<:TipICRY:1483431288852709387> Sul pole piisavalt TipiCOINe. Saldo: {bal}", "broke": E["TipICRY"] + " Sul pole piisavalt TipiCOINe. Saldo: {bal}",
"broke_need": "<:TipICRY:1483431288852709387> Sul pole piisavalt TipiCOINe. Vajad veel {need}.", "broke_need": E["TipICRY"] + " Sul pole piisavalt TipiCOINe. Vajad veel {need}.",
"item_owned": "❌ Sul on see ese juba olemas.", "item_owned": "❌ Sul on see ese juba olemas.",
"item_not_found": "❌ Eset ei leitud.", "item_not_found": "❌ Eset ei leitud.",
"item_level_req": "🔒 Selle eseme ostmiseks vajad **taset {min_level}** (sul on tase {user_level}). Teeni EXP-id kõiki käske kasutades.", "item_level_req": "🔒 Selle eseme ostmiseks vajad **taset {min_level}** (sul on tase {user_level}). Teeni EXP-id kõiki käske kasutades.",
@@ -653,7 +695,7 @@ CD_MSG: dict[str, str] = {
"rob": "⏳ Saad uuesti röövida {ts}.", "rob": "⏳ Saad uuesti röövida {ts}.",
"heist": "⏳ Saad uuesti heisti teha {ts}.", "heist": "⏳ Saad uuesti heisti teha {ts}.",
"heist_global": "⏳ Pangahoidla alles kosub eelmisest röövist. Järgmine heist võimalik {ts}.", "heist_global": "⏳ Pangahoidla alles kosub eelmisest röövist. Järgmine heist võimalik {ts}.",
"jailed": "<:TipiTROLL:1483431380166774895> Oled vangis! Pääsed välja {ts}. Kasuta `/jailbreak`, et varem välja pääseda.", "jailed": E["TipiTROLL"] + " Oled vangis! Pääsed välja {ts}. Kasuta `/jailbreak`, et varem välja pääseda.",
"fish": "🎣 Saad uuesti kalastada {ts}.", "fish": "🎣 Saad uuesti kalastada {ts}.",
} }
@@ -837,8 +879,8 @@ CHANNEL_UI: dict[str, str] = {
DAILY_UI: dict[str, str] = { DAILY_UI: dict[str, str] = {
"earned": "✅ Said {earned}!", "earned": "✅ Said {earned}!",
"interest": "<:TipiLAP:1483004307161874566> Bot Farm tootis: +{interest}", "interest": E["TipiLAP"] + " Bot Farm tootis: +{interest}",
"vip": "<:TipiPILET:1483004308353060904> LAN pileti boonus rakendus!", "vip": f"{E['TipiPILET']} LAN pileti boonus rakendus!",
"footer": "Streak: {streak_str} · Saldo: {balance}", "footer": "Streak: {streak_str} · Saldo: {balance}",
} }
@@ -1050,9 +1092,9 @@ RANK_UI: dict[str, str] = {
WORK_UI: dict[str, str] = { WORK_UI: dict[str, str] = {
"desc": "Sa {job} ja teenisid {earned}!", "desc": "Sa {job} ja teenisid {earned}!",
"redbull": "\n<:TipiBULL:1483004310924300409> Red Bull aktiveerus - 3x boonus!", "redbull": f"\n{E['TipiBULL']} Red Bull aktiveerus - 3x boonus!",
"hiir": "\n<:TipiHIIR:1483004306012504128> Mängurihiir: +50% palk", "hiir": f"\n{E['TipiHIIR']} Mängurihiir: +50% palk",
"laud": "\n<:TipiLAUD:1483387695576125440> Reguleeritav laud: +25% palk", "laud": f"\n{E['TipiLAUD']} Reguleeritav laud: +25% palk",
"balance": "\nSaldo: {balance}", "balance": "\nSaldo: {balance}",
} }
@@ -1062,7 +1104,7 @@ WORK_UI: dict[str, str] = {
BEG_UI: dict[str, str] = { BEG_UI: dict[str, str] = {
"desc": "Sa {text} ja said {earned}.", "desc": "Sa {text} ja said {earned}.",
"klaviatuur": "<:TipiKLAVA:1483014339228078140> Mehhaaniline klaviatuur: 2x tulu", "klaviatuur": f"{E['TipiKLAVA']} Mehhaaniline klaviatuur: 2x tulu",
"balance": "Saldo: {balance}", "balance": "Saldo: {balance}",
} }
@@ -1075,8 +1117,8 @@ CRIME_UI: dict[str, str] = {
"fail_base": "Sa {text} ja said trahvi {fine}.", "fail_base": "Sa {text} ja said trahvi {fine}.",
"fail_jailed": "\n\ud83d\udd12 Oled vangis! P\u00e4\u00e4sed {ts}.", "fail_jailed": "\n\ud83d\udd12 Oled vangis! P\u00e4\u00e4sed {ts}.",
"fail_shield": "\n\ud83d\udee1\ufe0f Gaming Tool hoidis sind vanglast!", "fail_shield": "\n\ud83d\udee1\ufe0f Gaming Tool hoidis sind vanglast!",
"mikrofon": "\n<:TipiMIC:1483387698499551313> Mikrofon: +30% saak", "mikrofon": f"\n{E['TipiMIC']} Mikrofon: +30% saak",
"cat6": "\n<:TipiCAT:1483014337663602718> CAT6: 75% edu t\u00f5en\u00e4osus", "cat6": f"\n{E['TipiCAT']} CAT6: 75% edu t\u00f5en\u00e4osus",
"balance": "\nSaldo: {balance}", "balance": "\nSaldo: {balance}",
} }
@@ -1116,7 +1158,7 @@ BUY_UI: dict[str, str] = {
JAILBREAK_UI: dict[str, str] = { JAILBREAK_UI: dict[str, str] = {
"btn_roll": "🎲 Viska täringud ({try_}/{max})", "btn_roll": "🎲 Viska täringud ({try_}/{max})",
"rolling_desc": "<:TipiDICE:1485923107108556950> *Täringud lendavad...*", "rolling_desc": f"{E['TipiDICE']} *Täringud lendavad...*",
"free_desc": "{d1} {d2}\n\n✅ Viskasid duubli - pääsesid vanglast!", "free_desc": "{d1} {d2}\n\n✅ Viskasid duubli - pääsesid vanglast!",
"miss_desc": "{d1} {d2}\n\n{left} katset jäänud. Proovi uuesti!", "miss_desc": "{d1} {d2}\n\n{left} katset jäänud. Proovi uuesti!",
"intro_desc": "Oled vangis kuni {ts}.\n\nViska täringuid ja proovi **duublit** saada - siis pääsed tasuta vabaks!\nSul on **{tries} katset**. Ebaõnnestumisel saad valida: maksa kautsjon **(2030% saldost, min 350 ⬡)** või jää vanglasse kuni aja lõpuni.", "intro_desc": "Oled vangis kuni {ts}.\n\nViska täringuid ja proovi **duublit** saada - siis pääsed tasuta vabaks!\nSul on **{tries} katset**. Ebaõnnestumisel saad valida: maksa kautsjon **(2030% saldost, min 350 ⬡)** või jää vanglasse kuni aja lõpuni.",
@@ -1160,7 +1202,7 @@ LEADERBOARD_UI: dict[str, str] = {
SLOTS_UI: dict[str, str] = { SLOTS_UI: dict[str, str] = {
"playing": "🎰 Mängimas...", "playing": "🎰 Mängimas...",
"jackpot_footer": "<:TipiKARIKAS:1483014841148112977> Kolm karikat! +{change}", "jackpot_footer": E["TipiKARIKAS"] + " Kolm karikat! +{change}",
"triple_footer": "✅ Kolm ühesugust! +{change}", "triple_footer": "✅ Kolm ühesugust! +{change}",
"pair_footer": "Kaks ühesugust! +{change}", "pair_footer": "Kaks ühesugust! +{change}",
"miss_footer": "-{amount}", "miss_footer": "-{amount}",
@@ -1264,7 +1306,7 @@ PRESTIGE_SHOP_DESCRIPTIONS: dict[str, str] = {
PRESTIGE_UI: dict[str, str] = { PRESTIGE_UI: dict[str, str] = {
"confirm_desc": ( "confirm_desc": (
"Oled tasemel **{level}** ({exp} EXP).\n\n" "Oled tasemel **{level}** ({exp} EXP).\n\n"
"Prestiiži korral saad **{pp}** <:TipiFIRE:1483431381668335687> ja kõik lähtestub:\n" "Prestiiži korral saad **{pp}** " + E["TipiFIRE"] + " ja kõik lähtestub:\n"
"• Saldo, EXP, esemed, ooteajad\n\n" "• Saldo, EXP, esemed, ooteajad\n\n"
"**Kalakogu jääb alles!**\n\nKas oled kindel?" "**Kalakogu jääb alles!**\n\nKas oled kindel?"
), ),
@@ -1273,21 +1315,21 @@ PRESTIGE_UI: dict[str, str] = {
"btn_tab_status": "⭐ Prestiiz", "btn_tab_status": "⭐ Prestiiz",
"btn_tab_shop": "🛍️ Uuendused", "btn_tab_shop": "🛍️ Uuendused",
"success_desc": ( "success_desc": (
"Said **{pp}** <:TipiFIRE:1483431381668335687>\n" "Said **{pp}** " + E["TipiFIRE"] + "\n"
"Prestiiži tase: **{level}**\n" "Prestiiži tase: **{level}**\n"
"Kogutud PP: **{total_pp}** <:TipiFIRE:1483431381668335687>\n\n" "Kogutud PP: **{total_pp}** " + E["TipiFIRE"] + "\n\n"
"*Kõik lähtestati. Alusta otsast!*" "*Kõik lähtestati. Alusta otsast!*"
), ),
"too_low_desc": "Prestiiži jaoks vajad taset **{required}** (sul on tase {level}).", "too_low_desc": "Prestiiži jaoks vajad taset **{required}** (sul on tase {level}).",
"shop_desc": "Sul on **{pp}** <:TipiFIRE:1483431381668335687> · Vajuta nuppu uuenduse ostmiseks", "shop_desc": "Sul on **{pp}** " + E["TipiFIRE"] + " · Vajuta nuppu uuenduse ostmiseks",
"shop_maxed": "✅ Max", "shop_maxed": "✅ Max",
"shop_level_fmt": "Tase {cur}/{max}", "shop_level_fmt": "Tase {cur}/{max}",
"shop_cost_fmt": "{cost} <:TipiFIRE:1483431381668335687>", "shop_cost_fmt": "{cost} " + E["TipiFIRE"],
"buy_success_desc":"**{name}** uuendatud tasemele **{new_level}/{max_level}**!\nPP alles: **{pp}** <:TipiFIRE:1483431381668335687>", "buy_success_desc":"**{name}** uuendatud tasemele **{new_level}/{max_level}**!\nPP alles: **{pp}** " + E["TipiFIRE"],
"buy_no_pp": "<:TipICRY:1483431288852709387> Sul pole piisavalt PP. Sul on **{have}**, vajad **{need}** <:TipiFIRE:1483431381668335687>.", "buy_no_pp": E["TipICRY"] + " Sul pole piisavalt PP. Sul on **{have}**, vajad **{need}** " + E["TipiFIRE"] + ".",
"buy_maxed": "❌ See uuendus on juba maksimumtasemel.", "buy_maxed": "❌ See uuendus on juba maksimumtasemel.",
"buy_not_found": "❌ Sellist uuendust ei leitud. Vaata `/prestigeshop`.", "buy_not_found": "❌ Sellist uuendust ei leitud. Vaata `/prestigeshop`.",
"rank_line": "<:TipiFIRE:1483431381668335687> Prestiiž **{level}** · {pp} PP", "rank_line": E["TipiFIRE"] + " Prestiiž **{level}** · {pp} PP",
"rank_season": "🏆 Hooaja EXP: **{exp}**", "rank_season": "🏆 Hooaja EXP: **{exp}**",
"btn_buy_upgrade": "{emoji} {name} +1 ({cost} PP)", "btn_buy_upgrade": "{emoji} {name} +1 ({cost} PP)",
"status_footer": "⭐ Prestiiž {level} · {pp} PP", "status_footer": "⭐ Prestiiž {level} · {pp} PP",

114
tests/conftest.py Normal file
View File

@@ -0,0 +1,114 @@
"""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)
async def get_collection_fields(self) -> set[str]:
if self.schema_fields is None:
return set(economy._default_user()) | {"user_id"}
return set(self.schema_fields)
# -- 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", "get_collection_fields"):
monkeypatch.setattr(pb_client, name, getattr(fake, name))
# live house state is owned by economy.house (the package re-export is a snapshot)
monkeypatch.setattr(economy.house, "HOUSE_ID", None)
monkeypatch.setattr(economy.house, "_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)

205
tests/test_economy_flows.py Normal file
View File

@@ -0,0 +1,205 @@
"""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):
# _clock is the time seam shared by every economy submodule
monkeypatch.setattr(economy.store, "_clock", 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

View 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

122
tests/test_quests.py Normal file
View File

@@ -0,0 +1,122 @@
"""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.store, "_clock", 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_fields_reported(self, fake_pb_without_quest_fields):
assert run(economy.missing_schema_fields()) == ["quest_daily", "quest_weekly"]
def test_healthy_schema_reports_nothing(self, fake_pb):
assert run(economy.missing_schema_fields()) == []
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"])