forked from sass/tipibot
Compare commits
17 Commits
42f7bae681
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d16da268d | ||
|
|
4dd7379e03 | ||
|
|
7fc0ccc77f | ||
|
|
83a60cda55 | ||
|
|
d89383ee20 | ||
|
|
8884cce9b5 | ||
|
|
94fe57a596 | ||
|
|
c40c871a4b | ||
|
|
5ba5642694 | ||
|
|
cb18d9b882 | ||
|
|
0cdd8dac63 | ||
|
|
6101a278e7 | ||
|
|
1e9ec56761 | ||
|
|
a96488fe9e | ||
|
|
25cf60d2e1 | ||
|
|
3939c879c9 | ||
|
|
b0e23c1a17 |
@@ -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
|
||||||
|
|||||||
83
README.md
83
README.md
@@ -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 | 15–75 ⬡ | Random job flavour text. Mängurihiir +50%, Reguleeritav laud +25% (stacks). Red Bull: 30% chance of ×3. Ultralai monitor reduces cooldown to 40min. Prestige work_plus adds +20% per upgrade level. |
|
| `/work` | 1h | 15–75 ⬡ | Random job flavour text. Mängurihiir +50%, Reguleeritav laud +25% (stacks). Red Bull: 30% chance of ×3. Ultralai monitor reduces cooldown to 40min. Prestige work_plus adds +20% per upgrade level. |
|
||||||
| `/beg` | 5min | 10–40 ⬡ | XL hiirematt reduces cooldown to 3min. Mehhaaniline klaviatuur multiplies earnings ×2. |
|
| `/beg` | 5min | 10–40 ⬡ | Hiirematt reduces cooldown to 3min. Mehaaniline klaviatuur multiplies earnings ×2. |
|
||||||
| `/crime` | 2h | 200–500 ⬡ | 60% success rate (75% with CAT6). +30% earnings with Mikrofon on win. Fail = fine + 30min jail. Mänguritool skips jail on fail. |
|
| `/crime` | 2h | 200–500 ⬡ | 60% success rate (75% with Cat6 kaabel). +30% earnings with Mikrofon on win. Fail = fine + 30min jail. Gaming tool skips jail on fail. |
|
||||||
| `/fish` | 2min | varies | Interactive minigame. Cast → wait for bite → press button within 2s → keep in inventory or sell immediately. Ussipurk reduces cooldown to 90s. |
|
| `/fish` | 2min | varies | Interactive minigame. Cast → wait for bite → press button within 2s → keep in inventory or sell immediately. Ussipurk reduces cooldown to 90s. |
|
||||||
|
|
||||||
### Daily streak
|
### Daily streak
|
||||||
|
|
||||||
The streak increments each time you claim `/daily` within the cooldown window. Missing a day resets it to 1 **unless** you own the TipiLAN trofee item.
|
The streak increments each time you claim `/daily` within the cooldown window. Missing a day resets it to 1 **unless** you own the TipiLAN karikas item.
|
||||||
|
|
||||||
| Streak | Multiplier | Payout (base) |
|
| Streak | Multiplier | Payout (base) |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -262,7 +262,7 @@ The streak increments each time you claim `/daily` within the cooldown window. M
|
|||||||
| 7–13 days | ×2.0 | 300 ⬡ |
|
| 7–13 days | ×2.0 | 300 ⬡ |
|
||||||
| 14+ days | ×3.0 | 450 ⬡ |
|
| 14+ days | ×3.0 | 450 ⬡ |
|
||||||
|
|
||||||
> With LAN Pilet (×2 daily) and a 14-day streak (×3.0) the base payout reaches **900 ⬡**. Add Botikoobas 5% interest on top.
|
> With LAN pilet (×2 daily) and a 14-day streak (×3.0) the base payout reaches **900 ⬡**. Add Bot Farm 5% interest on top.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -281,15 +281,15 @@ Every successful economy action awards EXP:
|
|||||||
| `/beg` completed | +5 |
|
| `/beg` completed | +5 |
|
||||||
| `/fish` catch | +2 to +25 (varies by rarity: common 2–3, uncommon 6–7, rare 10, epic 14–15, legendary 25) |
|
| `/fish` catch | +2 to +25 (varies by rarity: common 2–3, uncommon 6–7, rare 10, epic 14–15, legendary 25) |
|
||||||
|
|
||||||
**Level formula:** `level = max(1, floor(√(total_exp ÷ 6)))`
|
**Level formula:** `level = max(1, floor(√(total_exp ÷ 10)))`
|
||||||
|
|
||||||
| Level | EXP required | Milestone |
|
| Level | EXP required | Milestone |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 | 0 | TipiNOOB role |
|
| 1 | 0 | TipiNOOB role |
|
||||||
| 5 | 150 | TipiGRINDER role |
|
| 5 | 250 | TipiGRINDER role |
|
||||||
| 10 | 600 | TipiHUSTLER role · **T2 shop unlocks** |
|
| 10 | 1 000 | TipiHUSTLER role · **T2 shop unlocks** |
|
||||||
| 20 | 2 400 | TipiCHAD role · **T3 shop unlocks** |
|
| 20 | 4 000 | TipiCHAD role · **T3 shop unlocks** |
|
||||||
| 30 | 5 400 | TipiLEGEND role |
|
| 30 | 9 000 | TipiLEGEND role |
|
||||||
|
|
||||||
Use `/rank` to see your current EXP, level, progress bar to the next level, and leaderboard position.
|
Use `/rank` to see your current EXP, level, progress bar to the next level, and leaderboard position.
|
||||||
|
|
||||||
@@ -340,6 +340,7 @@ The **ECONOMY** role is granted on your first EXP award (i.e. first successful e
|
|||||||
| `/shop` | Browse all items by tier. Shows owned status, Anticheat charges remaining, and level lock for T2/T3. |
|
| `/shop` | Browse all items by tier. Shows owned status, Anticheat charges remaining, and level lock for T2/T3. |
|
||||||
| `/buy <item>` | Purchase an item by name (partial match accepted). |
|
| `/buy <item>` | Purchase an item by name (partial match accepted). |
|
||||||
| `/reminders` | Toggle per-command DM notifications. Bot DMs you the moment each cooldown expires. |
|
| `/reminders` | Toggle per-command DM notifications. Bot DMs you the moment each cooldown expires. |
|
||||||
|
| `/quests` | Personal daily (3) and weekly (2) quests with progress bars and a claim button. |
|
||||||
| `/fish` | Interactive fishing minigame. Cast, wait for bite, pull, then keep or sell. 2min cooldown (90s with Ussipurk). |
|
| `/fish` | Interactive fishing minigame. Cast, wait for bite, pull, then keep or sell. 2min cooldown (90s with Ussipurk). |
|
||||||
| `/fishbook` | View your fish collection - all caught species, rarity, count, and current inventory amounts. |
|
| `/fishbook` | View your fish collection - all caught species, rarity, count, and current inventory amounts. |
|
||||||
| `/fishsell` | Sell all fish currently in your inventory at once. |
|
| `/fishsell` | Sell all fish currently in your inventory at once. |
|
||||||
@@ -351,7 +352,7 @@ The **ECONOMY** role is granted on your first EXP award (i.e. first successful e
|
|||||||
|
|
||||||
### Jail system
|
### Jail system
|
||||||
|
|
||||||
`/crime` fail (without Mänguritool) jails you for **30 minutes**. While jailed, `/work`, `/beg`, `/crime`, `/rob`, and `/give` are blocked.
|
`/crime` fail (without Gaming tool) jails you for **30 minutes**. While jailed, `/work`, `/beg`, `/crime`, `/rob`, and `/give` are blocked.
|
||||||
|
|
||||||
#### `/jailbreak`
|
#### `/jailbreak`
|
||||||
Press the roll button - both dice are rolled simultaneously with an animated reveal. **3 attempts** per sentence. Matching values (doubles) = free instantly. If all 3 fail you pay bail:
|
Press the roll button - both dice are rolled simultaneously with an animated reveal. **3 attempts** per sentence. Matching values (doubles) = free instantly. If all 3 fail you pay bail:
|
||||||
@@ -380,6 +381,19 @@ Spend PP in `/prestigeshop`:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Quests
|
||||||
|
|
||||||
|
`/quests` shows your personal quest board: **3 daily** and **2 weekly** quests with progress bars and a claim button.
|
||||||
|
|
||||||
|
- Quests track counters you're already grinding: work/beg counts, coins earned, amount wagered, fish caught, crimes succeeded, coins given, heists joined.
|
||||||
|
- Progress starts counting from the moment the quest set rolls - stats earned before that don't count.
|
||||||
|
- The daily set resets at **UTC midnight**, the weekly set on the **ISO week** rollover. Each player gets their own rotation.
|
||||||
|
- Rewards are TipiCOINs (prestige coin multiplier applies) plus EXP through the normal level-up path.
|
||||||
|
|
||||||
|
> Schema note: the quest fields live in PocketBase - after pulling this feature run `python scripts/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
72
bot.py
@@ -32,6 +32,7 @@ from commands.economy_fish_commands import register_economy_fish_commands
|
|||||||
from commands.economy_games_commands import register_economy_games_commands
|
from commands.economy_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)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
108
commands/economy_quests_commands.py
Normal file
108
commands/economy_quests_commands.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import datetime
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from discord import app_commands
|
||||||
|
|
||||||
|
from core import economy
|
||||||
|
import strings as S
|
||||||
|
|
||||||
|
|
||||||
|
_BAR_SLOTS = 8
|
||||||
|
|
||||||
|
|
||||||
|
def _bar(progress: int, goal: int) -> str:
|
||||||
|
"""Return an 8-slot ▰/▱ progress bar for a quest."""
|
||||||
|
filled = 0 if goal <= 0 else min(_BAR_SLOTS, round(_BAR_SLOTS * progress / goal))
|
||||||
|
return "▰" * filled + "▱" * (_BAR_SLOTS - filled)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_claimable(view_data: dict) -> bool:
|
||||||
|
return any(
|
||||||
|
q["done"] and not q["claimed"]
|
||||||
|
for q in view_data["daily"] + view_data["weekly"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register_economy_quests_commands(
|
||||||
|
tree: app_commands.CommandTree,
|
||||||
|
bot: discord.Client,
|
||||||
|
coin: Callable[[int], str],
|
||||||
|
award_exp: Callable[[discord.Interaction, int], Awaitable[None]],
|
||||||
|
) -> None:
|
||||||
|
def _render(view_data: dict) -> discord.Embed:
|
||||||
|
embed = discord.Embed(title=S.TITLE["quests"], color=0xF4C430)
|
||||||
|
sections = (
|
||||||
|
(S.QUEST_UI["daily_header"], view_data["daily"]),
|
||||||
|
(S.QUEST_UI["weekly_header"], view_data["weekly"]),
|
||||||
|
)
|
||||||
|
for header, quests in sections:
|
||||||
|
if quests:
|
||||||
|
blocks = []
|
||||||
|
for q in quests:
|
||||||
|
desc = S.QUEST_DESCRIPTIONS.get(q["id"], q["id"])
|
||||||
|
if q["claimed"]:
|
||||||
|
status = S.QUEST_UI["completed"]
|
||||||
|
elif q["done"]:
|
||||||
|
status = S.QUEST_UI["ready"]
|
||||||
|
else:
|
||||||
|
status = S.QUEST_UI["progress"].format(progress=q["progress"], max=q["goal"])
|
||||||
|
reward = S.QUEST_UI["reward"].format(coins=f"{q['coins']:,}", exp=q["exp"])
|
||||||
|
blocks.append(
|
||||||
|
f"{_bar(q['progress'], q['goal'])} **{desc}**\n{status} · {reward}"
|
||||||
|
)
|
||||||
|
value = "\n\n".join(blocks)
|
||||||
|
else:
|
||||||
|
value = S.QUEST_UI["empty"]
|
||||||
|
embed.add_field(name=header, value=value, inline=False)
|
||||||
|
return embed
|
||||||
|
|
||||||
|
class QuestView(discord.ui.View):
|
||||||
|
def __init__(self, invoker_id: int, view_data: dict):
|
||||||
|
super().__init__(timeout=180)
|
||||||
|
self.invoker_id = invoker_id
|
||||||
|
btn = discord.ui.Button(
|
||||||
|
label=S.QUEST_UI["claim_btn"],
|
||||||
|
style=discord.ButtonStyle.success,
|
||||||
|
disabled=not _has_claimable(view_data),
|
||||||
|
)
|
||||||
|
btn.callback = self._claim
|
||||||
|
self.add_item(btn)
|
||||||
|
|
||||||
|
async def _claim(self, interaction: discord.Interaction):
|
||||||
|
if interaction.user.id != self.invoker_id:
|
||||||
|
await interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
res = await economy.claim_quests(self.invoker_id)
|
||||||
|
except economy.DatabaseError:
|
||||||
|
await interaction.response.send_message(S.QUEST_UI["error"], ephemeral=True)
|
||||||
|
return
|
||||||
|
if not res["ok"]:
|
||||||
|
await interaction.response.send_message(S.QUEST_UI["nothing"], ephemeral=True)
|
||||||
|
return
|
||||||
|
new_data = await economy.get_quests(self.invoker_id)
|
||||||
|
await interaction.response.edit_message(
|
||||||
|
embed=_render(new_data), view=QuestView(self.invoker_id, new_data)
|
||||||
|
)
|
||||||
|
await interaction.followup.send(
|
||||||
|
S.QUEST_UI["claimed_msg"].format(
|
||||||
|
count=res["claimed"], coins=coin(res["coins"]), exp=res["exp"]
|
||||||
|
),
|
||||||
|
ephemeral=True,
|
||||||
|
)
|
||||||
|
if res["exp"]:
|
||||||
|
asyncio.create_task(award_exp(interaction, res["exp"]))
|
||||||
|
|
||||||
|
@tree.command(name="quests", description=S.CMD["quests"])
|
||||||
|
async def cmd_quests(interaction: discord.Interaction):
|
||||||
|
await interaction.response.defer()
|
||||||
|
try:
|
||||||
|
data = await economy.get_quests(interaction.user.id)
|
||||||
|
except economy.DatabaseError:
|
||||||
|
await interaction.followup.send(S.QUEST_UI["error"], ephemeral=True)
|
||||||
|
return
|
||||||
|
await interaction.followup.send(embed=_render(data), view=QuestView(interaction.user.id, data))
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
47
config.py
47
config.py
@@ -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()
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
1953
core/economy.py
1953
core/economy.py
File diff suppressed because it is too large
Load Diff
35
core/economy/__init__.py
Normal file
35
core/economy/__init__.py
Normal 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
165
core/economy/admin.py
Normal 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
194
core/economy/fishing.py
Normal 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
275
core/economy/gambling.py
Normal 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
97
core/economy/heist.py
Normal 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
91
core/economy/house.py
Normal 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
403
core/economy/income.py
Normal 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
79
core/economy/jail.py
Normal 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
|
||||||
82
core/economy/leaderboards.py
Normal file
82
core/economy/leaderboards.py
Normal 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
80
core/economy/levels.py
Normal 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
106
core/economy/prestige.py
Normal 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
159
core/economy/quests.py
Normal 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
208
core/economy/shop.py
Normal 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
384
core/economy/store.py
Normal 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
97
core/emoji.py
Normal 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)}")
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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
2
requirements-dev.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest>=8.0
|
||||||
101
scripts/add_quest_fields.py
Normal file
101
scripts/add_quest_fields.py
Normal 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
131
scripts/sync_pb_schema.py
Normal 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()))
|
||||||
162
strings.py
162
strings.py
@@ -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 **(20–30% 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 **(20–30% 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
114
tests/conftest.py
Normal 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
205
tests/test_economy_flows.py
Normal 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
|
||||||
86
tests/test_economy_pure.py
Normal file
86
tests/test_economy_pure.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"""Tests for the pure (no-database) economy math."""
|
||||||
|
|
||||||
|
import random
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from core import economy
|
||||||
|
|
||||||
|
|
||||||
|
class TestLevels:
|
||||||
|
def test_milestones(self):
|
||||||
|
assert economy.get_level(0) == 1
|
||||||
|
assert economy.get_level(249) == 4
|
||||||
|
assert economy.get_level(250) == 5
|
||||||
|
assert economy.get_level(1000) == 10
|
||||||
|
assert economy.get_level(4000) == 20
|
||||||
|
assert economy.get_level(9000) == 30
|
||||||
|
|
||||||
|
def test_exp_for_level_is_inverse(self):
|
||||||
|
for level in range(1, 41):
|
||||||
|
exp = economy.exp_for_level(level)
|
||||||
|
assert economy.get_level(exp) == level
|
||||||
|
if level > 1:
|
||||||
|
assert economy.get_level(exp - 1) == level - 1
|
||||||
|
|
||||||
|
def test_negative_exp_clamps_to_level_1(self):
|
||||||
|
assert economy.get_level(-500) == 1
|
||||||
|
|
||||||
|
def test_role_names(self):
|
||||||
|
assert economy.level_role_name(1) == "TipiNOOB"
|
||||||
|
assert economy.level_role_name(4) == "TipiNOOB"
|
||||||
|
assert economy.level_role_name(5) == "TipiGRINDER"
|
||||||
|
assert economy.level_role_name(10) == "TipiHUSTLER"
|
||||||
|
assert economy.level_role_name(20) == "TipiCHAD"
|
||||||
|
assert economy.level_role_name(30) == "TipiLEGEND"
|
||||||
|
assert economy.level_role_name(99) == "TipiLEGEND"
|
||||||
|
|
||||||
|
|
||||||
|
class TestGambleExp:
|
||||||
|
def test_tiers(self):
|
||||||
|
assert economy.gamble_exp(0) == 0
|
||||||
|
assert economy.gamble_exp(9) == 0
|
||||||
|
assert economy.gamble_exp(10) == 5
|
||||||
|
assert economy.gamble_exp(99) == 5
|
||||||
|
assert economy.gamble_exp(100) == 10
|
||||||
|
assert economy.gamble_exp(999) == 10
|
||||||
|
assert economy.gamble_exp(1_000) == 15
|
||||||
|
assert economy.gamble_exp(9_999) == 15
|
||||||
|
assert economy.gamble_exp(10_000) == 20
|
||||||
|
assert economy.gamble_exp(99_999) == 20
|
||||||
|
assert economy.gamble_exp(100_000) == 25
|
||||||
|
|
||||||
|
def test_cap(self):
|
||||||
|
assert economy.gamble_exp(10_000_000) == 25
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormatTd:
|
||||||
|
def test_hours(self):
|
||||||
|
assert economy.format_td(timedelta(hours=1, minutes=23, seconds=45)) == "1t 23m"
|
||||||
|
|
||||||
|
def test_minutes(self):
|
||||||
|
assert economy.format_td(timedelta(minutes=45, seconds=12)) == "45m 12s"
|
||||||
|
|
||||||
|
def test_seconds(self):
|
||||||
|
assert economy.format_td(timedelta(seconds=8)) == "8s"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRollFish:
|
||||||
|
def test_rolls_are_valid(self):
|
||||||
|
random.seed(42)
|
||||||
|
for _ in range(500):
|
||||||
|
fish_id, weight = economy.roll_fish()
|
||||||
|
if fish_id == "junk":
|
||||||
|
assert weight == 0
|
||||||
|
else:
|
||||||
|
fish = economy.FISH_CATALOGUE[fish_id]
|
||||||
|
assert fish["weight"][0] <= weight <= fish["weight"][1]
|
||||||
|
|
||||||
|
def test_rarity_bump_shifts_every_catch_up_a_tier(self):
|
||||||
|
random.seed(7)
|
||||||
|
rarities = {
|
||||||
|
economy.FISH_CATALOGUE[fid]["rarity"]
|
||||||
|
for fid, _ in (economy.roll_fish(rarity_bump=True) for _ in range(1000))
|
||||||
|
if fid != "junk"
|
||||||
|
}
|
||||||
|
assert "common" not in rarities
|
||||||
|
assert "legendary" in rarities
|
||||||
122
tests/test_quests.py
Normal file
122
tests/test_quests.py
Normal 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"])
|
||||||
Reference in New Issue
Block a user