diff --git a/.gitignore b/.gitignore index 80b6677..606bfc4 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ pocketbase.exe pocketbase pb_data/ pb_migrations/ +logs/ diff --git a/DEV_NOTES.md b/DEV_NOTES.md index 27470de..d8b6f12 100644 --- a/DEV_NOTES.md +++ b/DEV_NOTES.md @@ -11,8 +11,6 @@ | `sheets.py` | Google Sheets integration (member sync) | | `member_sync.py` | Birthday/member sync background task | | `config.py` | Environment variables (TOKEN, GUILD_ID, PB_URL, etc.) | -| `migrate_to_pb.py` | One-time utility: migrate `data/economy.json` → PocketBase | -| `add_stats_fields.py` | Schema migration: add new fields to `economy_users` PocketBase collection | --- @@ -23,18 +21,19 @@ Checklist - do all of these, in order: 1. **`economy.py`** - add the `do_` async function with cooldown check, logic, `_commit`, and `_txn` logging 2. **`economy.py`** - add the cooldown to `COOLDOWNS` dict if it has one 3. **`economy.py`** - add the EXP reward to `EXP_REWARDS` dict -3a. **PocketBase** - if the function stores new fields, add them as columns via `python scripts/add_stats_fields.py` (or manually in the PB admin UI at `http://127.0.0.1:8090/_/`). Fields not in the PB schema are silently dropped on PATCH. -4. **`strings.py` `CMD`** - add the slash command description -5. **`strings.py` `OPT`** - add any parameter descriptions -6. **`strings.py` `TITLE`** - add embed title(s) for success/fail states -7. **`strings.py` `ERR`** - add any error messages (banned, cooldown uses `CD_MSG`, jailed uses `CD_MSG["jailed"]`) -8. **`strings.py` `CD_MSG`** - add cooldown message if command has a cooldown -9. **`strings.py` `HELP_CATEGORIES["tipibot"]["fields"]`** - add the command to the help embed -10. **`bot.py`** - implement the `cmd_` function, handle all `res["reason"]` cases -11. **`bot.py`** - call `_maybe_remind` if the command has a cooldown and reminders make sense -12. **`bot.py`** - call `_award_exp(interaction, economy.EXP_REWARDS[""])` on success -13. **`strings.py` `REMINDER_OPTS`** - add a reminder option if the command needs one -14. **`bot.py` `_maybe_remind`** - if the command has an item-modified cooldown, add an `elif` branch +4. **PocketBase** - if the function stores new fields, add them manually in the PB admin UI at `http://127.0.0.1:8090/_/`. Fields not in the PB schema are silently dropped on PATCH. +5. **`strings.py` `CMD`** - add the slash command description +6. **`strings.py` `OPT`** - add any parameter descriptions +7. **`strings.py` `TITLE`** - add embed title(s) for success/fail states +8. **`strings.py` `ERR`** - add any error messages (banned, cooldown uses `CD_MSG`, jailed uses `CD_MSG["jailed"]`) +9. **`strings.py` `CD_MSG`** - add cooldown message if command has a cooldown +10. **`strings.py` `HELP_CATEGORIES["tipibot"]["fields"]`** - add the command to the help embed +11. **`bot.py`** - implement the `cmd_` function, handle all `res["reason"]` cases +12. **`bot.py`** - call `_maybe_remind` if the command has a cooldown and reminders make sense +13. **`bot.py`** - call `_award_exp(interaction, economy.EXP_REWARDS[""])` on success +14. **`strings.py` `REMINDER_OPTS`** - add a reminder option if the command needs one +15. **`bot.py` `_maybe_remind`** and **`_restore_reminders`** - if item-modified cooldown, add `elif` branch +16. **`bot.py` `_REMINDER_COOLDOWN_KEYS`** - add `"cmd": "last_cmd"` mapping if reminder-capable --- @@ -50,6 +49,7 @@ Checklist: 6. If the item modifies a cooldown: - **`economy.py`** - add the `if "item" in user["items"]` branch in the relevant `do_` function - **`bot.py` `_maybe_remind`** - add `elif cmd == "" and "" in items:` branch with the new delay + - **`bot.py` `_restore_reminders`** - add the same `elif` branch - **`bot.py` `cmd_cooldowns`** - add the item annotation to the relevant status line --- @@ -65,8 +65,11 @@ Checklist: ## Adding a New Admin Command 1. **`strings.py` `CMD`** - add `"[Admin] ..."` description -2. **`strings.py` `HELP_CATEGORIES["admin"]["fields"]`** - add the entry -3. **`bot.py`** - add `@app_commands.default_permissions(manage_guild=True)` and `@app_commands.guild_only()` +2. **`strings.py` `OPT`** - add parameter descriptions +3. **`strings.py` `ADMIN`** - add response and DM strings +4. **`strings.py` `HELP_CATEGORIES["admin"]["fields"]`** - add the entry +5. **`economy.py`** - add `do_admin_` function +6. **`bot.py`** - add command with `@app_commands.default_permissions(manage_guild=True)` and `@app_commands.guild_only()` --- @@ -80,16 +83,46 @@ All economy state is stored in **PocketBase** (`economy_users` collection). `pb_ | Command | Cooldown | Base Earn | Notes | |---|---|---|---| -| `/daily` | 20h (18h w/ korvaklapid) | 150⬡ | ×streak multiplier, ×2 w/ lan_pass, +5% interest w/ gaming_laptop | -| `/work` | 1h (40min w/ monitor) | 15-75⬡ | ×1.5 w/ gaming_hiir, ×1.25 w/ reguleeritav_laud, ×3 30% chance w/ energiajook | +| `/daily` | 20h (18h w/ korvaklapid) | 150⬡ | ×streak multiplier, ×2 w/ lan_pass, +5% interest w/ gaming_laptop; prestige daily_plus adds +20% per level | +| `/work` | 1h (40min w/ monitor) | 15-75⬡ | ×1.5 w/ gaming_hiir, ×1.25 w/ reguleeritav_laud, ×3 30% chance w/ energiajook; prestige work_plus +20%/level | | `/beg` | 5min (3min w/ hiirematt) | 10-40⬡ | ×2 w/ klaviatuur | | `/crime` | 2h | 200-500⬡ win | 60% success (75% w/ cat6), +30% w/ mikrofon; fail = fine + jail | -| `/rob` | 2h | 10–25% of target | 45% success (60% w/ jellyfin); fail = fine; cannot rob TipiBOT | +| `/rob` | 2h | 10–25% of target | 45% success (60% w/ jellyfin); fail = fine; cannot rob TipiBOT directly | | `/heist` | 4h personal + 1h global | 20–55% of house / n players | solo allowed, max 8; 35%+5%/player success (cap 65%); fail = 3h jail + ~15% balance fine | -| `/slots` | - | varies | pair=+0.5× bet; triple tiered: heart×4, fire×5, troll×7, cry×10, skull×15, karikas×25 (jackpot); ×1.5 w/ monitor_360; miss=lose bet; house edge ~5% | +| `/fish` | 2min (90s w/ ussipurk) | varies by fish rarity | Interactive minigame; catches go to inventory; sell with `/fishsell` | +| `/slots` | - | varies | pair=+0.5× bet; triple tiered; karikas jackpot ×25; ×1.5 w/ monitor_360; miss=lose bet | | `/roulette` | - | 2× red/black, 14× green | 1/37 green chance | | `/blackjack` | - | 1:1 win, 3:2 BJ, 2:1 double | Dealer stands on 17+; double down on first action only | +### Fishing System + +- `/fish` - interactive minigame: cast → wait 5–15s for bite → press button within 2s (3s w/ echolood) → keep or sell +- Fish stored in `fish_inventory` (list of `{fish_id, weight, value}` objects) +- `/fishbook` - paginated fish collection showing caught species and inventory counts +- `/fishsell` - sell all fish from inventory at once +- `fish_inventory` and `fish_book` **survive prestige resets** +- `kalavork` (T3, 5000⬡): bumps all caught fish up one rarity tier +- `ussipurk` (T2, 3500⬡): cooldown 2min → 90s +- `echolood` (T3, 8000⬡): bite window 2s → 3s + +### Prestige System + +- Requires level 30 (9000 EXP) +- Resets: balance, EXP, items, cooldowns, jail +- Preserves: fish_book, fish_inventory, lifetime stats, prestige_points, season_total_exp, prestige_upgrades +- Awards prestige_points = max(1, exp ÷ 1000) at time of prestige +- Each prestige increments `prestige_level` counter +- Prestige coin/exp multipliers apply to all earned values + +**Prestige Shop** (`PRESTIGE_SHOP` in economy.py): + +| Upgrade | Max Level | Cost/level | Effect | +|---|---|---|---| +| `coin_mult` | 5 | 5 PP | +8% coin multiplier per level | +| `exp_mult` | 5 | 5 PP | +8% EXP multiplier per level | +| `daily_plus` | 3 | 7 PP | +20% daily base reward per level | +| `work_plus` | 3 | 7 PP | +20% work earnings per level | + ### "all" Keyword Commands that accept a coin amount (`/give`, `/roulette`, `/rps`, `/slots`, `/blackjack`) accept `"all"` to mean the user's full current balance. Parsed by `_parse_amount(value, balance)` in `bot.py`. @@ -102,13 +135,35 @@ Commands that accept a coin amount (`/give`, `/roulette`, `/rps`, `/slots`, `/bl ### Jail - Normal duration: 30 minutes (`JAIL_DURATION`) -- Heist fail duration: 3 hours (`HEIST_JAIL`) + fine 15% of balance (min 150⨡, max 1000⨡) +- Heist fail duration: 3 hours (`HEIST_JAIL`) + fine 15% of balance (min 150⬡, max 1000⬡) - `gaming_tool`: prevents jail on crime fail -- `/jailbreak`: 3 dice rolls, need doubles to escape free. On fail - bail = 20-30% of balance, min 350⬡. If balance < 350⬡, player stays jailed until timer. +- `/jailbreak`: 3 single-button dice rolls (both dice at once), need doubles to escape free. Animated reveal with TipiDICE emoji. On fail after 3 tries - bail = 20-30% of balance, min 350⬡. If balance < 350⬡, stay jailed until timer. ### EXP Rewards (from `EXP_REWARDS` in economy.py) EXP is awarded on every successful command use. Level formula: `floor(sqrt(exp / 10))`, so Level 5 = 250 EXP, Level 10 = 1000, Level 20 = 4000, Level 30 = 9000. +Fish EXP is awarded per catch (varies by rarity, defined in `FISH_CATALOGUE`). Prestige `exp_mult` upgrade applies to fish EXP. + +--- + +## Admin Commands Reference + +| Command | What it does | +|---|---| +| `/pause` | Toggle maintenance mode - blocks all non-admin commands | +| `/admincoins @user ` | Give/take coins (positive/negative). DMs user. | +| `/adminexp @user ` | Give/take EXP (positive/negative). Auto-applies level roles on change. DMs user. | +| `/adminitem @user ` | Give or remove any shop item for free. DMs user. | +| `/adminjail @user ` | Manually jail a user. DMs user. | +| `/adminunjail @user` | Remove jail from a user. | +| `/adminban @user ` | Ban from all economy commands. DMs user. | +| `/adminunban @user` | Lift economy ban. | +| `/adminreset @user ` | Wipe balance, EXP, items, streak. DMs user. | +| `/adminview @user` | Full profile: balance, EXP/level, streak, prestige, fish stats, items, timestamps. | +| `/adminseason ` | End season: DM top N players, reset all EXP. | + +All admin commands require **Manage Guild** permission and work in any channel (bypass pause and channel restrictions). + --- ## Role Hierarchy (Discord) @@ -130,6 +185,7 @@ Run `/economysetup` to auto-create all roles and set their positions. The comman Role assignment: - **ECONOMY** role: given automatically on first EXP award (i.e. first successful economy command) - **Level roles**: given/swapped automatically on level-up; synced on `/rank` +- **`/adminexp`**: automatically re-applies level roles if level changes --- @@ -138,8 +194,8 @@ Role assignment: | Tier | Level Required | Items | |---|---|---| | T1 | 0 (any) | gaming_hiir, hiirematt, korvaklapid, lan_pass, anticheat, energiajook, gaming_laptop | -| T2 | 10 | reguleeritav_laud, jellyfin, mikrofon, klaviatuur, monitor, cat6 | -| T3 | 20 | monitor_360, karikas, gaming_tool | +| T2 | 10 | reguleeritav_laud, jellyfin, mikrofon, klaviatuur, monitor, cat6, **ussipurk** | +| T3 | 20 | monitor_360, karikas, gaming_tool, **kalavork**, **echolood** | Shop display is sorted by cost (ascending) within each tier. The `SHOP_LEVEL_REQ` dict in `economy.py` controls per-item lock thresholds. @@ -162,6 +218,9 @@ The `SHOP_LEVEL_REQ` dict in `economy.py` controls per-item lock thresholds. | Cooldown messages | `CD_MSG["cmd"].format(ts=_cd_ts(...))` | Cooldown responses | | Shop UI | `SHOP_UI["key"]` | `_shop_embed` | | Item descriptions | `ITEM_DESCRIPTIONS["item_key"]` | `economy.SHOP[key]["description"]` | +| Fish UI | `FISH_UI["key"]` | `/fish`, `/fishbook`, `/fishsell` | +| Fish names | `FISH_NAMES["fish_id"]` | Fish display name | +| Admin responses | `ADMIN["key"]` | Admin command success/DM messages | --- @@ -177,6 +236,8 @@ The `SHOP_LEVEL_REQ` dict in `economy.py` controls per-item lock thresholds. | `LEVEL_ROLES` | `economy.py` | `[(min_level, "RoleName"), ...]` highest first | | `ECONOMY_ROLE` | `economy.py` | Name of the base economy participation role | | `EXP_REWARDS` | `economy.py` | EXP per command | +| `FISH_CATALOGUE` | `economy.py` | All fish species (rarity, weight, coins, exp) | +| `PRESTIGE_SHOP` | `economy.py` | Prestige upgrade definitions | | `HOUSE_ID` | `economy.py` | Bot's user ID (house account for /rob) | | `MIN_BAIL` | `economy.py` | Minimum bail payment (350⬡) | | `COIN` | `economy.py` | The coin emoji string | @@ -193,3 +254,4 @@ The `SHOP_LEVEL_REQ` dict in `economy.py` controls per-item lock thresholds. - `anticheat` is consumable (2 uses) - only item that can be re-bought - `karikas` (T3) is the only item that preserves a daily streak across missed days - `reguleeritav_laud` (T2) stacks with `gaming_hiir`: combined ×1.5 × ×1.25 = ×1.875 +- **Fishing** is a steady passive income; `kalavork` (T3) dramatically increases fish value by bumping rarity diff --git a/README.md b/README.md index 4ee8a51..f47912e 100644 --- a/README.md +++ b/README.md @@ -154,16 +154,18 @@ Admins (bot lacks permission to modify them) are silently skipped and still mark | `/sync` | Manage Guild | Re-registers slash commands with Discord | | `/restart` | Manage Guild | Gracefully restarts the bot process; posts ✅ in the same channel when back up | | `/shutdown` | Manage Guild | Shuts the bot down cleanly without restarting | -| `/pause` | Manage Guild | Toggles maintenance mode — blocks all non-admin commands; calling again unpauses | +| `/pause` | Manage Guild | Toggles maintenance mode - blocks all non-admin commands; calling again unpauses | | `/send #channel message` | Manage Guild | Sends a message to any channel as the bot | | `/status` | Manage Guild | Bot uptime, RAM, CPU, latency, cache stats, economy user count | | `/admincoins @user ` | Manage Guild | Give (positive) or take (negative) TipiCOINi. Balance floored at 0. User gets a DM with reason. | +| `/adminexp @user ` | Manage Guild | Give (positive) or take (negative) EXP. Level roles auto-updated. User gets a DM. | +| `/adminitem @user ` | Manage Guild | Give or remove any shop item for free. `ese` is the internal item ID (e.g. `anticheat`). User gets a DM. | | `/adminjail @user ` | Manage Guild | Manually jail a user for N minutes. User gets a DM. | | `/adminunjail @user` | Manage Guild | Release a user from jail immediately. | | `/adminban @user ` | Manage Guild | Ban a user from all economy commands. User gets a DM. | | `/adminunban @user` | Manage Guild | Lift an economy ban. | | `/adminreset @user ` | Manage Guild | Wipe a user's balance, items, and streak to zero. User gets a DM. | -| `/adminview @user` | Manage Guild | Inspect a user's full economy profile: balance, streak, items, jail status, ban status. | +| `/adminview @user` | Manage Guild | Full profile: balance, EXP/level, streak, prestige, fish stats, items, ban/jail, all timestamps. | ### `/check` output example ``` @@ -226,10 +228,11 @@ The house is listed at **#0** on the leaderboard. Players can attempt to rob it | Command | Cooldown | Base payout | Notes | |---|---|---|---| -| `/daily` | 20h | 150 ⬡ | Streak multiplier applied (see below). Kõrvaklapid reduces cooldown to 18h. LAN Pilet doubles the reward. Botikoobas adds 5% interest on your balance (capped at 500 ⬡/day). | -| `/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. | +| `/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. | +| `/work` | 1h | 15–75 ⬡ | Random job flavour text. Mängurihiir +50%, Reguleeritav laud +25% (stacks). Red Bull: 30% chance of ×3. Ultralai monitor reduces cooldown to 40min. Prestige work_plus adds +20% per upgrade level. | | `/beg` | 5min | 10–40 ⬡ | XL hiirematt reduces cooldown to 3min. Mehhaaniline klaviatuur multiplies earnings ×2. | | `/crime` | 2h | 200–500 ⬡ | 60% success rate (75% with CAT6). +30% earnings with Mikrofon on win. Fail = fine + 30min jail. Mänguritool skips jail on fail. | +| `/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 @@ -254,10 +257,12 @@ Every successful economy action awards EXP: |---|---| | `/daily` claimed | +50 | | `/work` completed | +25 | +| `/heist` win | +25 | | `/crime` success | +15 | | `/rob` success | +15 | | Gambling win (`/roulette`, `/slots`, `/blackjack`) | Scaled by bet: <10⬡ = 0, 10–99⬡ = +5, 100–999⬡ = +10, 1 000–9 999⬡ = +15, 10 000–99 999⬡ = +20, 100 000+⬡ = +25 | | `/beg` completed | +5 | +| `/fish` catch | +3 to +15 (varies by rarity) | **Level formula:** `level = floor(√(total_exp ÷ 10))` @@ -314,10 +319,16 @@ The **ECONOMY** role is granted on your first EXP award (i.e. first successful e | `/rank [@user]` | EXP total, current level, progress bar to next level, leaderboard rank. | | `/stats [@user]` | Lifetime statistics: economy totals, work/beg counts, gambling records, crime/heist history, social totals, best streak. | | `/cooldowns` | All cooldowns at a glance with live Discord timestamps. Shows jail timer if jailed. | -| `/leaderboard` | Paginated coin leaderboard (10/page). House pinned at #0. ◀/▶ to browse; 📍 **Mina** jumps to your page. Has a separate EXP/level tab. | +| `/leaderboard` | Paginated leaderboard with 6 tabs: 🪙 Coins, 📊 EXP, 🏆 Season EXP, 🔥 Prestige, 🎲 Wagered, 🎣 Fish caught. House pinned at #0 on coins tab. | | `/shop` | Browse all items by tier. Shows owned status, Anticheat charges remaining, and level lock for T2/T3. | | `/buy ` | Purchase an item by name (partial match accepted). | -| `/reminders` | Toggle per-command DM notifications. **All reminders are on by default.** Bot DMs you the moment each cooldown expires. | +| `/reminders` | Toggle per-command DM notifications. Bot DMs you the moment each cooldown expires. | +| `/fish` | Interactive fishing minigame. Cast, wait for bite, pull, then keep or sell. 2min cooldown (90s with Ussipurk). | +| `/fishbook` | View your fish collection - all caught species, rarity, count, and current inventory amounts. | +| `/fishsell` | Sell all fish currently in your inventory at once. | +| `/prestige` | Reset your balance/EXP/items at level 30 in exchange for Prestige Points. Fishing collection preserved. | +| `/prestigeshop` | View available prestige upgrades and their current levels. | +| `/prestigebuy ` | Purchase a prestige upgrade using Prestige Points. | --- @@ -326,7 +337,7 @@ The **ECONOMY** role is granted on your first EXP award (i.e. first successful e `/crime` fail (without Mänguritool) jails you for **30 minutes**. While jailed, `/work`, `/beg`, `/crime`, `/rob`, and `/give` are blocked. #### `/jailbreak` -Roll two dice - matching values (doubles) free you instantly. **3 attempts** per jail sentence. 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: - **20–30% of your current balance** (scales with wealth) - **Minimum 350 ⬡** - if your balance is below this you stay jailed until the timer runs out @@ -335,6 +346,41 @@ Cooldowns and jail release times display as live Discord relative timestamps. --- +### Prestige + +Once you reach **level 30** (9 000 EXP), you can `/prestige`. This resets your balance, EXP, and items in exchange for **Prestige Points** (PP = floor(exp ÷ 1000), min 1). + +**What survives a prestige reset:** fish book, fish inventory, lifetime economy stats, prestige upgrades, season EXP. + +Spend PP in `/prestigeshop`: + +| Upgrade | Max Level | Cost/level | Effect | +|---|---|---|---| +| Coin multiplier | 5 | 5 PP | +8% to all coin earnings per level | +| EXP multiplier | 5 | 5 PP | +8% to all EXP per level | +| Daily bonus | 3 | 7 PP | +20% to daily base payout per level | +| Work bonus | 3 | 7 PP | +20% to work earnings per level | + +--- + +### Fishing + +`/fish` is an interactive minigame with a **2-minute cooldown** (90s with Ussipurk): + +1. Cast the line - a button appears +2. Wait 5–15 seconds for the bite +3. Press **TÕMBA!** within the 2s window (3s with Echolood) +4. Choose: **sell immediately** or **keep in inventory** + +Caught fish go to your `fish_inventory` and persist through prestige resets. + +| Command | Notes | +|---|---| +| `/fishbook` | View your fish collection: caught species, rarity, count, how many in inventory. | +| `/fishsell` | Sell all fish in your inventory at once. | + +--- + ### Shop items All items are **permanent** once purchased **except Anticheat**, which expires after 2 uses and can be repurchased. @@ -362,6 +408,12 @@ All items are **permanent** once purchased **except Anticheat**, which expires a | CAT6 netikaabel | 3 500 ⬡ | `/crime` success rate 60% → 75% | | Jellyfin server | 4 000 ⬡ | `/rob` success rate 45% → 60% | +#### Tier 2 - level 10 required (TipiHUSTLER+) - continued + +| Item | Cost | Effect | +|---|---|---| +| Ussipurk | 3 500 ⬡ | `/fish` cooldown 2min → 90s | + #### Tier 3 - level 20 required (TipiCHAD+) | Item | Cost | Effect | @@ -369,6 +421,8 @@ All items are **permanent** once purchased **except Anticheat**, which expires a | 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 | +| Echolood | 8 000 ⬡ | Fishing bite window 2s → 3s | --- diff --git a/bot.py b/bot.py index 3fccd1a..aa71e33 100644 --- a/bot.py +++ b/bot.py @@ -98,6 +98,9 @@ _active_heist: "HeistLobbyView | None" = None # server-wide singleton _spam_tracker: dict[int, collections.deque] = {} # user_id -> deque of recent income-cmd timestamps _SPAM_WINDOW = 5.0 # seconds _SPAM_THRESHOLD = 5 # income commands within window triggers jail +_gamble_cooldowns: dict[int, float] = {} # user_id -> monotonic timestamp of last gamble +_GAMBLE_CD = 30 # seconds (default gambling cooldown) +_GAMBLE_CD_360 = 25 # seconds (with monitor_360 item) _BDAY_LOG = _DATA_DIR / "birthday_sent.json" _RESTART_FILE = _DATA_DIR / "restart_channel.json" _BOT_CONFIG = _DATA_DIR / "bot_config.json" @@ -1128,24 +1131,102 @@ async def cmd_adminview(interaction: discord.Interaction, kasutaja: discord.Memb uses_str = ", ".join(f"{k}:{v}" for k, v in uses.items()) if uses else "-" jailed = d.get("jailed_until") or "-" banned = S.ADMINVIEW_UI["banned_yes"] if d.get("eco_banned") else S.ADMINVIEW_UI["banned_no"] + exp = d.get("exp", 0) + level = economy.get_level(exp) + prestige_lvl = d.get("prestige_level", 0) + prestige_pp = d.get("prestige_points", 0) + total_fish = d.get("total_fish_caught", 0) + inv_fish = len(d.get("fish_inventory") or []) embed = discord.Embed( title=S.ADMINVIEW_UI["title"].format(name=kasutaja.display_name), color=0x5865F2, ) embed.add_field(name=S.ADMINVIEW_UI["f_balance"], value=f"{d.get('balance', 0):,} {economy.COIN}", inline=True) + embed.add_field(name=S.ADMINVIEW_UI["f_exp"], value=S.ADMINVIEW_UI["exp_val"].format(exp=f"{exp:,}", level=level), inline=True) embed.add_field(name=S.ADMINVIEW_UI["f_streak"], value=str(d.get("daily_streak", 0)), inline=True) embed.add_field(name=S.ADMINVIEW_UI["f_banned"], value=banned, inline=True) embed.add_field(name=S.ADMINVIEW_UI["f_jailed"], value=jailed, inline=True) + embed.add_field(name=S.ADMINVIEW_UI["f_prestige"], value=S.ADMINVIEW_UI["prestige_val"].format(level=prestige_lvl, pp=prestige_pp), inline=True) + embed.add_field(name=S.ADMINVIEW_UI["f_fish"], value=S.ADMINVIEW_UI["fish_val"].format(caught=total_fish, inv=inv_fish), inline=True) embed.add_field(name=S.ADMINVIEW_UI["f_items"], value=items_str, inline=False) embed.add_field(name=S.ADMINVIEW_UI["f_uses"], value=uses_str, inline=False) embed.add_field(name=S.ADMINVIEW_UI["f_last_daily"], value=d.get("last_daily") or "-", inline=True) embed.add_field(name=S.ADMINVIEW_UI["f_last_work"], value=d.get("last_work") or "-", inline=True) embed.add_field(name=S.ADMINVIEW_UI["f_last_crime"], value=d.get("last_crime") or "-", inline=True) + embed.add_field(name=S.ADMINVIEW_UI["f_last_fish"], value=d.get("last_fish") or "-", inline=True) embed.set_footer(text=S.ADMINVIEW_UI["footer"].format(uid=kasutaja.id)) await interaction.response.send_message(embed=embed, ephemeral=True) log.info("ADMINVIEW %s by %s", kasutaja, interaction.user) +@tree.command(name="adminexp", description=S.CMD["adminexp"]) +@app_commands.guild_only() +@app_commands.describe( + kasutaja=S.OPT["admin_kasutaja"], + kogus=S.OPT["adminexp_kogus"], + põhjus=S.OPT["admin_põhjus"], +) +@app_commands.default_permissions(manage_guild=True) +async def cmd_adminexp(interaction: discord.Interaction, kasutaja: discord.Member, kogus: int, põhjus: str): + if kogus == 0: + await interaction.response.send_message(S.ERR["admincoins_zero"], ephemeral=True) + return + res = await economy.do_admin_exp(kasutaja.id, kogus, interaction.user.id, põhjus) + verb = f"+{kogus}" if kogus > 0 else str(kogus) + emoji = "📈" if kogus > 0 else "📉" + await interaction.response.send_message( + S.ADMIN["exp_done"].format( + emoji=emoji, name=kasutaja.display_name, verb=verb, + exp=f"{res['exp']:,}", level=res["new_level"], reason=põhjus, + ), + ephemeral=True, + ) + await _dm_user(kasutaja.id, + S.ADMIN["exp_dm"].format( + emoji=emoji, verb=verb, reason=põhjus, + exp=f"{res['exp']:,}", level=res["new_level"], + ) + ) + if res["level_changed"]: + member = interaction.guild.get_member(kasutaja.id) if interaction.guild else None + if member: + await _apply_level_role(member, res["new_level"], res["old_level"]) + log.info("ADMINEXP %s → %s (%s) by %s", verb, kasutaja, põhjus, interaction.user) + + +@tree.command(name="adminitem", description=S.CMD["adminitem"]) +@app_commands.guild_only() +@app_commands.describe( + kasutaja=S.OPT["admin_kasutaja"], + ese=S.OPT["adminitem_ese"], + tegevus=S.OPT["adminitem_tegevus"], +) +@app_commands.default_permissions(manage_guild=True) +async def cmd_adminitem(interaction: discord.Interaction, kasutaja: discord.Member, ese: str, tegevus: str): + action = tegevus.strip().lower() + if action not in ("anna", "eemalda"): + await interaction.response.send_message(S.ADMIN["item_invalid"].format(item_id=tegevus), ephemeral=True) + return + action_key = "give" if action == "anna" else "remove" + res = await economy.do_admin_item(kasutaja.id, ese, action_key, interaction.user.id) + if not res["ok"]: + if res["reason"] == "invalid_item": + await interaction.response.send_message(S.ADMIN["item_invalid"].format(item_id=ese), ephemeral=True) + elif res["reason"] == "not_owned": + await interaction.response.send_message(S.ADMIN["item_not_owned"].format(name=kasutaja.display_name, item_id=ese), ephemeral=True) + else: + await interaction.response.send_message(S.ERR["generic_error"].format(error=res["reason"]), ephemeral=True) + return + item_name = economy.SHOP[ese]["name"] + if action_key == "give": + await interaction.response.send_message(S.ADMIN["item_given"].format(item=item_name, name=kasutaja.display_name), ephemeral=True) + await _dm_user(kasutaja.id, S.ADMIN["item_dm_given"].format(item=item_name)) + else: + await interaction.response.send_message(S.ADMIN["item_removed"].format(item=item_name, name=kasutaja.display_name), ephemeral=True) + await _dm_user(kasutaja.id, S.ADMIN["item_dm_removed"].format(item=item_name)) + log.info("ADMINITEM %s %s %s by %s", action_key, ese, kasutaja, interaction.user) + + # --------------------------------------------------------------------------- # TipiBOT economy commands # --------------------------------------------------------------------------- @@ -1160,6 +1241,17 @@ def _cd_ts(remaining: datetime.timedelta) -> str: return f"" +def _gamble_cd(uid: int, has_360: bool = False) -> datetime.timedelta | None: + """Check and set gambling cooldown. Returns remaining time if on CD, else None.""" + cd = float(_GAMBLE_CD_360 if has_360 else _GAMBLE_CD) + now = time.monotonic() + remaining = cd - (now - _gamble_cooldowns.get(uid, 0.0)) + if remaining > 0: + return datetime.timedelta(seconds=remaining) + _gamble_cooldowns[uid] = now + return None + + async def _check_cmd_rate(interaction: discord.Interaction) -> bool: """Record an income command use. Jails the user and returns True if spam is detected.""" uid = interaction.user.id @@ -1231,6 +1323,7 @@ _REMINDER_COOLDOWN_KEYS: dict[str, str] = { "beg": "last_beg", "crime": "last_crime", "rob": "last_rob", + "fish": "last_fish", } @@ -1257,6 +1350,8 @@ async def _restore_reminders() -> None: cooldown = datetime.timedelta(minutes=3) elif cmd == "daily" and "korvaklapid" in items: cooldown = datetime.timedelta(hours=18) + elif cmd == "fish" and "ussipurk" in items: + cooldown = datetime.timedelta(seconds=90) else: cooldown = economy.COOLDOWNS.get(cmd) if not cooldown: @@ -1284,11 +1379,236 @@ async def _maybe_remind(user_id: int, cmd: str) -> None: delay = datetime.timedelta(minutes=3) elif cmd == "daily" and "korvaklapid" in items: delay = datetime.timedelta(hours=18) + elif cmd == "fish" and "ussipurk" in items: + delay = datetime.timedelta(seconds=90) else: delay = economy.COOLDOWNS.get(cmd, datetime.timedelta(hours=1)) _schedule_reminder(user_id, cmd, delay) +# --------------------------------------------------------------------------- +# /profile - tabbed profile view +# --------------------------------------------------------------------------- + +def _profile_main_embed(target: discord.User | discord.Member, data: dict) -> discord.Embed: + exp = data.get("exp", 0) + level = economy.get_level(exp) + role_name = economy.level_role_name(level) + next_level = level + 1 + exp_this = economy.exp_for_level(level) + exp_next = economy.exp_for_level(next_level) + progress = exp - exp_this + needed = exp_next - exp_this + pct = progress / needed if needed > 0 else 1.0 + filled = int(pct * 12) + bar = "█" * filled + "░" * (12 - filled) + embed = discord.Embed( + title=S.PROFILE_UI["main_title"].format(name=target.display_name), + color=0xF4C430, + ) + embed.add_field(name=S.PROFILE_UI["f_balance"], value=_coin(data.get("balance", 0)), inline=True) + embed.add_field(name=S.PROFILE_UI["f_level"], value=S.PROFILE_UI["level_val"].format(level=level, role=role_name), inline=True) + streak = data.get("daily_streak", 0) + if streak: + embed.add_field(name=S.PROFILE_UI["f_streak"], value=S.BALANCE_UI["streak_val"].format(streak=streak), inline=True) + p_level = data.get("prestige_level", 0) + if p_level > 0: + p_pp = data.get("prestige_points", 0) + embed.add_field(name=S.PROFILE_UI["f_prestige"], value=S.PROFILE_UI["prestige_val"].format(level=p_level, pp=p_pp), inline=True) + jail_remaining = economy._is_jailed(data) + if jail_remaining: + embed.add_field(name=S.PROFILE_UI["f_jail"], value=_cd_ts(jail_remaining), inline=True) + embed.add_field( + name=S.PROFILE_UI["f_progress"].format(next=next_level), + value=S.PROFILE_UI["progress_bar"].format(bar=bar, done=progress, needed=needed), + inline=False, + ) + if level < 10: + embed.set_footer(text=S.PROFILE_UI["footer_t1"]) + elif level < 20: + embed.set_footer(text=S.PROFILE_UI["footer_t2"]) + else: + embed.set_footer(text=S.PROFILE_UI["footer_t3"]) + return embed + + +def _profile_items_embed(target: discord.User | discord.Member, data: dict) -> discord.Embed: + embed = discord.Embed( + title=S.PROFILE_UI["items_title"].format(name=target.display_name), + color=0xF4C430, + ) + uses_map = data.get("item_uses", {}) + item_lines = [] + for i in data.get("items", []): + if i not in economy.SHOP: + continue + line = f"{economy.SHOP[i]['emoji']} **{economy.SHOP[i]['name']}**" + if i in uses_map: + u = uses_map[i] + line += S.BALANCE_UI["uses_one"].format(uses=u) if u == 1 else S.BALANCE_UI["uses_many"].format(uses=u) + item_lines.append(line) + embed.description = "\n".join(item_lines) if item_lines else S.PROFILE_UI["items_empty"] + return embed + + +def _profile_stats_embed(target: discord.User | discord.Member, data: dict) -> discord.Embed: + def _s(key: str) -> int: + return data.get(key, 0) + embed = discord.Embed( + title=S.PROFILE_UI["stats_title"].format(name=target.display_name), + color=0x5865F2, + ) + embed.add_field( + name=S.STATS_UI["economy_field"], + value=S.STATS_UI["economy_val"].format(peak=_coin(_s("peak_balance")), earned=_coin(_s("lifetime_earned")), lost=_coin(_s("lifetime_lost"))), + inline=True, + ) + embed.add_field( + name=S.STATS_UI["work_field"], + value=S.STATS_UI["work_val"].format(work=_s("work_count"), beg=_s("beg_count")), + inline=True, + ) + embed.add_field(name="\u200b", value="\u200b", inline=False) + embed.add_field( + name=S.STATS_UI["gamble_field"], + value=S.STATS_UI["gamble_val"].format(wagered=_coin(_s("total_wagered")), win=_coin(_s("biggest_win")), loss=_coin(_s("biggest_loss")), jackpots=_s("slots_jackpots")), + inline=True, + ) + embed.add_field( + name=S.STATS_UI["crime_field"], + value=S.STATS_UI["crime_val"].format(crimes=_s("crimes_attempted"), succeeded=_s("crimes_succeeded"), heists=_s("heists_joined"), heists_won=_s("heists_won"), jailed=_s("times_jailed"), bail=_coin(_s("total_bail_paid"))), + inline=True, + ) + embed.add_field(name="\u200b", value="\u200b", inline=False) + embed.add_field( + name=S.STATS_UI["social_field"], + value=S.STATS_UI["social_val"].format(given=_coin(_s("total_given")), received=_coin(_s("total_received"))), + inline=True, + ) + embed.add_field( + name=S.STATS_UI["records_field"], + value=S.STATS_UI["records_val"].format(streak=_s("best_daily_streak")), + inline=True, + ) + return embed + + +def _profile_fish_embed(target: discord.User | discord.Member, fish_res: dict) -> discord.Embed: + embed = discord.Embed( + title=S.PROFILE_UI["fish_title"].format(name=target.display_name), + color=0x5865F2, + ) + book: dict = fish_res["book"] + if not book: + embed.description = S.FISH_UI["book_empty"] + return embed + inv_counts: dict = fish_res.get("inv_counts", {}) + caught_count = fish_res["unique_caught"] + total = fish_res["total_species"] + lines = [S.FISH_UI["book_caught"].format(caught=caught_count, total=total)] + for fish_id, fish_data in economy.FISH_CATALOGUE.items(): + rarity = fish_data["rarity"] + emoji = S.FISH_RARITY_EMOJI[rarity] + rarity_name = S.FISH_RARITY_NAMES[rarity] + count = book.get(fish_id, 0) + if count > 0: + n_inv = inv_counts.get(fish_id, 0) + inv_str = S.FISH_UI["book_inv"].format(n=n_inv) if n_inv > 0 else "" + lines.append(S.FISH_UI["book_yes"].format(emoji=emoji, name=S.FISH_NAMES[fish_id], rarity=rarity_name, count=count, inv=inv_str)) + else: + lines.append(S.FISH_UI["book_no"].format(rarity=rarity_name)) + embed.description = "\n".join(lines) + embed.set_footer(text=S.FISH_UI["book_footer"].format(page=1, total_pages=1, caught=caught_count, total=total)) + return embed + + +class ProfileView(discord.ui.View): + def __init__(self, target: discord.User | discord.Member, invoker_id: int, tab: str = "main"): + super().__init__(timeout=120) + self.target = target + self.invoker_id = invoker_id + self.tab = tab + self._rebuild() + + def _rebuild(self): + self.clear_items() + tabs = [ + ("main", S.PROFILE_UI["btn_profile"]), + ("items", S.PROFILE_UI["btn_items"]), + ("stats", S.PROFILE_UI["btn_stats"]), + ("fish", S.PROFILE_UI["btn_fish"]), + ] + for tab_id, label in tabs: + btn = discord.ui.Button( + label=label, + style=discord.ButtonStyle.primary if tab_id == self.tab else discord.ButtonStyle.secondary, + disabled=(tab_id == self.tab), + ) + btn.callback = self._make_cb(tab_id) + self.add_item(btn) + + def _make_cb(self, tab_id: str): + async def _cb(interaction: discord.Interaction): + if interaction.user.id != self.invoker_id: + await interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True) + return + self.tab = tab_id + self._rebuild() + await interaction.response.defer() + data = await economy.get_user(self.target.id) + if tab_id == "fish": + fish_res = await economy.do_fishbook(self.target.id) + embed = _profile_fish_embed(self.target, fish_res) + inv: list = data.get("fish_inventory") or [] + if inv and self.target.id == self.invoker_id: + total_value = sum(e.get("value", 0) for e in inv) + sell_btn = discord.ui.Button( + label=f"{S.FISH_UI['btn_sell']} ({len(inv)} kala · {total_value:,} {economy.COIN})", + style=discord.ButtonStyle.success, + row=1, + ) + sell_btn.callback = self._sell_fish_cb() + self.add_item(sell_btn) + elif tab_id == "items": + embed = _profile_items_embed(self.target, data) + elif tab_id == "stats": + embed = _profile_stats_embed(self.target, data) + else: + embed = _profile_main_embed(self.target, data) + await interaction.edit_original_response(embed=embed, view=self) + return _cb + + def _sell_fish_cb(self): + async def _cb(interaction: discord.Interaction): + if interaction.user.id != self.invoker_id: + await interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True) + return + await interaction.response.defer() + res = await economy.do_fish_sell(self.invoker_id) + self.tab = "fish" + self._rebuild() + fish_res = await economy.do_fishbook(self.target.id) + embed = _profile_fish_embed(self.target, fish_res) + sold_line = S.FISH_UI["inv_sold_all"].format(count=res.get("count", 0), coins=_coin(res.get("coins", 0)), balance=_coin(res.get("balance", 0))) + embed.description = f"{sold_line}\n\n{embed.description or ''}" + await interaction.edit_original_response(embed=embed, view=self) + return _cb + + +@tree.command(name="profile", description=S.CMD["profile"]) +@app_commands.describe(kasutaja=S.OPT["profile_kasutaja"]) +async def cmd_profile(interaction: discord.Interaction, kasutaja: discord.Member | None = None): + target = kasutaja or interaction.user + data = await economy.get_user(target.id) + embed = _profile_main_embed(target, data) + invoker_id = interaction.user.id + await interaction.response.send_message(embed=embed, view=ProfileView(target, invoker_id)) + if not kasutaja and interaction.guild: + member = interaction.guild.get_member(target.id) + if member: + asyncio.create_task(_ensure_level_role(member, economy.get_level(data.get("exp", 0)))) + + def _balance_embed(user: discord.User | discord.Member, data: dict) -> discord.Embed: embed = discord.Embed( title=f"{economy.COIN} {user.display_name}", @@ -1347,6 +1667,7 @@ async def cmd_cooldowns(interaction: discord.Interaction): work_cd = datetime.timedelta(minutes=40) if "monitor" in items else economy.COOLDOWNS["work"] beg_cd = datetime.timedelta(minutes=3) if "hiirematt" in items else economy.COOLDOWNS["beg"] daily_cd = datetime.timedelta(hours=18) if "korvaklapid" in items else economy.COOLDOWNS["daily"] + fish_cd = datetime.timedelta(seconds=90) if "ussipurk" in items else economy.COOLDOWNS["fish"] lines = [ S.COOLDOWNS_UI["daily_line"].format(status=_status("last_daily", daily_cd), note=S.COOLDOWNS_UI["note_korvak"] if "korvaklapid" in items else ""), @@ -1354,6 +1675,7 @@ async def cmd_cooldowns(interaction: discord.Interaction): S.COOLDOWNS_UI["beg_line"].format(status=_status("last_beg", beg_cd), note=S.COOLDOWNS_UI["note_hiirematt"] if "hiirematt" in items else ""), S.COOLDOWNS_UI["crime_line"].format(status=_status("last_crime", economy.COOLDOWNS["crime"])), S.COOLDOWNS_UI["rob_line"].format(status=_status("last_rob", economy.COOLDOWNS["rob"])), + S.COOLDOWNS_UI["fish_line"].format(status=_status("last_fish", fish_cd), note=S.COOLDOWNS_UI["note_ussipurk"] if "ussipurk" in items else ""), ] jailed = data.get("jailed_until") @@ -1432,6 +1754,15 @@ async def cmd_rank(interaction: discord.Interaction, kasutaja: discord.Member | value=S.RANK_UI["progress_val"].format(bar=bar, progress=progress, needed=needed), inline=False, ) + p_level = data.get("prestige_level", 0) + p_pp = data.get("prestige_points", 0) + s_exp = data.get("season_total_exp", 0) + if p_level > 0 or s_exp > 0: + embed.add_field( + name="\u200b", + value=S.PRESTIGE_UI["rank_line"].format(level=p_level, pp=p_pp) + "\n" + S.PRESTIGE_UI["rank_season"].format(exp=s_exp), + inline=False, + ) if level < 10: embed.set_footer(text=S.RANK_UI["footer_t1"]) elif level < 20: @@ -1694,6 +2025,13 @@ async def cmd_rob(interaction: discord.Interaction, sihtmärk: discord.Member): asyncio.create_task(_maybe_remind(interaction.user.id, "rob")) if res["success"]: asyncio.create_task(_award_exp(interaction, economy.EXP_REWARDS["rob_win"])) + try: + await sihtmärk.send(S.ROB_UI["victim_dm"].format( + robber=interaction.user.display_name, + stolen=_coin(res["stolen"]), + )) + except discord.Forbidden: + pass # --------------------------------------------------------------------------- @@ -1748,16 +2086,19 @@ def _build_heist_story(participants: list[discord.Member], success: bool) -> lis class HeistLobbyView(discord.ui.View): - def __init__(self, organizer: discord.Member): + def __init__(self, organizer: discord.Member, organizer_has_jellyfin: bool = False): super().__init__(timeout=_HEIST_JOIN_WINDOW) self.organizer = organizer self.participants: list[discord.Member] = [organizer] self.message: discord.Message | None = None self.resolved = False + self.jellyfin_holders: int = 1 if organizer_has_jellyfin else 0 def _chance(self) -> float: n = len(self.participants) - return min(_HEIST_BASE_CHANCE + _HEIST_CHANCE_STEP * (n - 1), _HEIST_MAX_CHANCE) + base = min(_HEIST_BASE_CHANCE + _HEIST_CHANCE_STEP * (n - 1), _HEIST_MAX_CHANCE) + jelly_bonus = 0.05 if self.jellyfin_holders > 0 else 0.0 + return min(base + jelly_bonus, _HEIST_MAX_CHANCE) def _lobby_embed(self) -> discord.Embed: names = "\n".join(f"• {p.display_name}" for p in self.participants) @@ -1798,6 +2139,9 @@ class HeistLobbyView(discord.ui.View): return self.participants.append(interaction.user) _active_games.add(interaction.user.id) + joiner_data = await economy.get_user(interaction.user.id) + if "jellyfin" in joiner_data.get("items", []): + self.jellyfin_holders += 1 await interaction.response.edit_message(embed=self._lobby_embed()) @discord.ui.button(label=S.HEIST_UI["btn_start"], style=discord.ButtonStyle.success) @@ -1943,7 +2287,8 @@ async def cmd_heist(interaction: discord.Interaction): ) return - view = HeistLobbyView(interaction.user) + organizer_data = await economy.get_user(interaction.user.id) + view = HeistLobbyView(interaction.user, "jellyfin" in organizer_data.get("items", [])) _active_heist = view _active_games.add(interaction.user.id) await interaction.response.send_message(embed=view._lobby_embed(), view=view) @@ -1970,16 +2315,15 @@ class JailbreakView(discord.ui.View): super().__init__(timeout=120) self.user_id = user_id self.tries = 0 - self._die1: int | None = None - self._refresh() + self._rolling = False + self._add_roll_btn() - def _refresh(self): + def _add_roll_btn(self): self.clear_items() - if self._die1 is None: - label = S.JAILBREAK_UI["btn_die1"].format(try_=self.tries + 1, max=self.MAX_TRIES) - else: - label = S.JAILBREAK_UI["btn_die2"] - btn = discord.ui.Button(label=label, style=discord.ButtonStyle.primary) + btn = discord.ui.Button( + label=S.JAILBREAK_UI["btn_roll"].format(try_=self.tries + 1, max=self.MAX_TRIES), + style=discord.ButtonStyle.primary, + ) btn.callback = self._on_roll self.add_item(btn) @@ -1987,62 +2331,67 @@ class JailbreakView(discord.ui.View): if interaction.user.id != self.user_id: await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) return + if self._rolling: + await interaction.response.defer() + return + self._rolling = True - if self._die1 is None: - self._die1 = random.randint(1, 6) - e1 = _DICE_EMOJI[self._die1 - 1] - self._refresh() + # Show rolling animation immediately + self.clear_items() + rolling_embed = discord.Embed( + title=S.TITLE["jailbreak"], + description=S.JAILBREAK_UI["rolling_desc"], + color=0xF4C430, + ) + await interaction.response.edit_message(embed=rolling_embed, view=self) + + # Roll both dice, then reveal after delay + d1 = random.randint(1, 6) + d2 = random.randint(1, 6) + e1, e2 = _DICE_EMOJI[d1 - 1], _DICE_EMOJI[d2 - 1] + double = d1 == d2 + self.tries += 1 + tries_left = self.MAX_TRIES - self.tries + + await asyncio.sleep(1.5) + self._rolling = False + + if double: + await economy.do_jail_free(self.user_id) + self.stop() embed = discord.Embed( - title=S.TITLE["jailbreak"], - description=S.JAILBREAK_UI["die1_desc"].format(die=e1), + title=S.TITLE["jailbreak_free"], + description=S.JAILBREAK_UI["free_desc"].format(d1=e1, d2=e2), + color=0x57F287, + ) + await interaction.edit_original_response(embed=embed, view=self) + elif tries_left == 0: + self.stop() + user_data = await economy.get_user(self.user_id) + bal = user_data["balance"] + if bal >= economy.MIN_BAIL: + min_fine = max(economy.MIN_BAIL, int(bal * 0.20)) + max_fine = max(economy.MIN_BAIL, int(bal * 0.30)) + desc = S.JAILBREAK_UI["fail_bail_offer"].format( + d1=e1, d2=e2, min=_coin(min_fine), max=_coin(max_fine), bal=_coin(bal) + ) + embed = discord.Embed(title=S.TITLE["jailbreak_fail"], description=desc, color=0xED4245) + await interaction.edit_original_response(embed=embed, view=BailView(self.user_id)) + else: + embed = discord.Embed( + title=S.TITLE["jailbreak_fail"], + description=S.JAILBREAK_UI["fail_broke_desc"].format(d1=e1, d2=e2, balance=_coin(bal)), + color=0xED4245, + ) + await interaction.edit_original_response(embed=embed, view=None) + else: + self._add_roll_btn() + embed = discord.Embed( + title=S.TITLE["jailbreak_miss"].format(tries=self.tries, max=self.MAX_TRIES), + description=S.JAILBREAK_UI["miss_desc"].format(d1=e1, d2=e2, left=tries_left), color=0xF4C430, ) - await interaction.response.edit_message(embed=embed, view=self) - else: - d1, d2 = self._die1, random.randint(1, 6) - e1, e2 = _DICE_EMOJI[d1 - 1], _DICE_EMOJI[d2 - 1] - double = d1 == d2 - self._die1 = None - self.tries += 1 - tries_left = self.MAX_TRIES - self.tries - - if double: - await economy.do_jail_free(self.user_id) - self.clear_items() - embed = discord.Embed( - title=S.TITLE["jailbreak_free"], - description=S.JAILBREAK_UI["free_desc"].format(d1=e1, d2=e2), - color=0x57F287, - ) - await interaction.response.edit_message(embed=embed, view=self) - self.stop() - elif tries_left == 0: - self.stop() - user_data = await economy.get_user(self.user_id) - bal = user_data["balance"] - if bal >= economy.MIN_BAIL: - min_fine = max(economy.MIN_BAIL, int(bal * 0.20)) - max_fine = max(economy.MIN_BAIL, int(bal * 0.30)) - desc = S.JAILBREAK_UI["fail_bail_offer"].format( - d1=e1, d2=e2, min=_coin(min_fine), max=_coin(max_fine), bal=_coin(bal) - ) - embed = discord.Embed(title=S.TITLE["jailbreak_fail"], description=desc, color=0xED4245) - await interaction.response.edit_message(embed=embed, view=BailView(self.user_id)) - else: - embed = discord.Embed( - title=S.TITLE["jailbreak_fail"], - description=S.JAILBREAK_UI["fail_broke_desc"].format(d1=e1, d2=e2, balance=_coin(bal)), - color=0xED4245, - ) - await interaction.response.edit_message(embed=embed, view=None) - else: - self._refresh() - embed = discord.Embed( - title=S.TITLE["jailbreak_miss"].format(tries=self.tries, max=self.MAX_TRIES), - description=S.JAILBREAK_UI["miss_desc"].format(d1=e1, d2=e2, left=tries_left), - color=0xF4C430, - ) - await interaction.response.edit_message(embed=embed, view=self) + await interaction.edit_original_response(embed=embed, view=self) class BailView(discord.ui.View): @@ -2191,6 +2540,12 @@ async def cmd_roulette(interaction: discord.Interaction, panus: str, värv: app_ if panus_int <= 0: await interaction.response.send_message(S.ERR["positive_bet"], ephemeral=True) return + has_360 = "monitor_360" in _data.get("items", []) + if rem := _gamble_cd(interaction.user.id, has_360): + await interaction.response.send_message( + S.ERR["gamble_cooldown"].format(ts=_cd_ts(rem)), ephemeral=True + ) + return if interaction.user.id in _active_games: await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) @@ -2295,45 +2650,59 @@ async def cmd_give(interaction: discord.Interaction, kasutaja: discord.Member, s class LeaderboardView(discord.ui.View): PER_PAGE = 10 - def __init__( - self, - regular: list[tuple[str, int]], - house_entry: tuple[str, int] | None, - guild: discord.Guild | None, - bot_user: discord.ClientUser | None, - exp_entries: list[tuple[str, int, int]] | None = None, - ): + def __init__(self, data: dict, guild: discord.Guild | None, bot_user: discord.ClientUser | None): super().__init__(timeout=120) - self.regular = regular - self.house_entry = house_entry + self.data = data self.guild = guild self.bot_user = bot_user - self.exp_entries = exp_entries or [] self.page = 0 - self.mode = "coins" # "coins" or "exp" - self.max_page = max(0, (len(regular) - 1) // self.PER_PAGE) if regular else 0 + self.mode = "coins" + self.max_page = 0 self._update_buttons() - def _current_list(self): - return self.regular if self.mode == "coins" else self.exp_entries + def _current_list(self) -> list: + return self.data.get(self.mode, []) def _update_buttons(self): current = self._current_list() self.max_page = max(0, (len(current) - 1) // self.PER_PAGE) if current else 0 self.prev_btn.disabled = self.page == 0 self.next_btn.disabled = self.page >= self.max_page - self.coins_btn.style = discord.ButtonStyle.primary if self.mode == "coins" else discord.ButtonStyle.secondary - self.exp_btn.style = discord.ButtonStyle.primary if self.mode == "exp" else discord.ButtonStyle.secondary + for m, btn in [ + ("coins", self.coins_btn), + ("exp", self.exp_btn), + ("season", self.season_btn), + ("prestige",self.prestige_btn), + ("wagered", self.wagered_btn), + ("fish", self.fish_btn), + ]: + btn.style = discord.ButtonStyle.primary if m == self.mode else discord.ButtonStyle.secondary + + def _name(self, uid: str, highlight_uid: int | None = None) -> str: + if self.guild: + member = self.guild.get_member(int(uid)) + name = member.display_name if member else S.LEADERBOARD_UI["unknown_user"].format(uid=uid) + else: + name = S.LEADERBOARD_UI["unknown_user"].format(uid=uid) + if highlight_uid and int(uid) == highlight_uid: + name = f"**› {name} ‹**" + return name def _make_embed(self, highlight_uid: int | None = None) -> discord.Embed: - if self.mode == "coins": - embed = discord.Embed(title=f"{economy.COIN} {S.TITLE['leaderboard_coins']}", color=0xF4C430) - else: - embed = discord.Embed(title=S.TITLE["leaderboard_exp"], color=0x5865F2) + title_map = { + "coins": f"{economy.COIN} {S.TITLE['leaderboard_coins']}", + "exp": S.TITLE["leaderboard_exp"], + "season": S.TITLE["leaderboard_season"], + "prestige": S.TITLE["leaderboard_prestige"], + "wagered": S.TITLE["leaderboard_wagered"], + "fish": S.TITLE["leaderboard_fish"], + } + color_map = {"coins": 0xF4C430, "wagered": 0xED4245, "fish": 0x57F287} + embed = discord.Embed(title=title_map.get(self.mode, "Edetabel"), color=color_map.get(self.mode, 0x5865F2)) lines = [] - if self.mode == "coins" and self.page == 0 and self.house_entry: - _, bal = self.house_entry + if self.mode == "coins" and self.page == 0 and self.data.get("house_entry"): + _, bal = self.data["house_entry"] house_name = self.bot_user.display_name if self.bot_user else "TipiBOT" lines.append(S.LEADERBOARD_UI["house_entry"].format(name=house_name, balance=_coin(bal))) lines.append("") @@ -2342,6 +2711,7 @@ class LeaderboardView(discord.ui.View): medals = ["🥇", "🥈", "🥉"] current = self._current_list() slice_ = current[start:start + self.PER_PAGE] + if not slice_: lines.append(S.LEADERBOARD_UI["no_entries"]) else: @@ -2349,63 +2719,77 @@ class LeaderboardView(discord.ui.View): rank = start + i uid = entry[0] prefix = medals[rank] if rank < 3 else f"**{rank + 1}.**" - if self.guild: - member = self.guild.get_member(int(uid)) - name = member.display_name if member else S.LEADERBOARD_UI["unknown_user"].format(uid=uid) - else: - name = S.LEADERBOARD_UI["unknown_user"].format(uid=uid) - if highlight_uid and int(uid) == highlight_uid: - name = f"**› {name} ‹**" + name = self._name(uid, highlight_uid) if self.mode == "coins": lines.append(f"{prefix} {name} - {_coin(entry[1])}") - else: + elif self.mode == "exp": lines.append(S.LEADERBOARD_UI["exp_entry"].format(prefix=prefix, name=name, exp=entry[1], level=entry[2])) + elif self.mode == "season": + lines.append(S.LEADERBOARD_UI["season_entry"].format(prefix=prefix, name=name, exp=entry[1], prestige=entry[2])) + elif self.mode == "prestige": + lines.append(S.LEADERBOARD_UI["prestige_entry"].format(prefix=prefix, name=name, prestige=entry[1], pp=entry[2])) + elif self.mode == "wagered": + lines.append(S.LEADERBOARD_UI["wagered_entry"].format(prefix=prefix, name=name, wagered=_coin(entry[1]))) + elif self.mode == "fish": + lines.append(S.LEADERBOARD_UI["fish_entry"].format(prefix=prefix, name=name, caught=entry[1])) total = self.max_page + 1 embed.description = "\n".join(lines) embed.set_footer(text=S.LEADERBOARD_UI["footer"].format(page=self.page + 1, total=total, count=len(current))) return embed - @discord.ui.button(label="◄", style=discord.ButtonStyle.secondary) + @discord.ui.button(label="◄", style=discord.ButtonStyle.secondary, row=0) async def prev_btn(self, interaction: discord.Interaction, button: discord.ui.Button): self.page -= 1 self._update_buttons() await interaction.response.edit_message(embed=self._make_embed(), view=self) - @discord.ui.button(label="►", style=discord.ButtonStyle.secondary) + @discord.ui.button(label="►", style=discord.ButtonStyle.secondary, row=0) async def next_btn(self, interaction: discord.Interaction, button: discord.ui.Button): self.page += 1 self._update_buttons() await interaction.response.edit_message(embed=self._make_embed(), view=self) - @discord.ui.button(label=S.LEADERBOARD_UI["btn_coins"], style=discord.ButtonStyle.primary) - async def coins_btn(self, interaction: discord.Interaction, button: discord.ui.Button): - self.mode = "coins" - self.page = 0 - self._update_buttons() - await interaction.response.edit_message(embed=self._make_embed(), view=self) - - @discord.ui.button(label=S.LEADERBOARD_UI["btn_exp"], style=discord.ButtonStyle.secondary) - async def exp_btn(self, interaction: discord.Interaction, button: discord.ui.Button): - self.mode = "exp" - self.page = 0 - self._update_buttons() - await interaction.response.edit_message(embed=self._make_embed(), view=self) - - @discord.ui.button(label=S.LEADERBOARD_UI["btn_find_me"], style=discord.ButtonStyle.secondary) + @discord.ui.button(label=S.LEADERBOARD_UI["btn_find_me"], style=discord.ButtonStyle.secondary, row=0) async def find_me_btn(self, interaction: discord.Interaction, button: discord.ui.Button): uid = interaction.user.id for i, entry in enumerate(self._current_list()): if int(entry[0]) == uid: self.page = i // self.PER_PAGE self._update_buttons() - await interaction.response.edit_message( - embed=self._make_embed(highlight_uid=uid), view=self - ) + await interaction.response.edit_message(embed=self._make_embed(highlight_uid=uid), view=self) return - await interaction.response.send_message( - S.ERR["not_in_leaderboard"], ephemeral=True - ) + await interaction.response.send_message(S.ERR["not_in_leaderboard"], ephemeral=True) + + @discord.ui.button(label=S.LEADERBOARD_UI["btn_coins"], style=discord.ButtonStyle.primary, row=1) + async def coins_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + self.mode = "coins"; self.page = 0; self._update_buttons() + await interaction.response.edit_message(embed=self._make_embed(), view=self) + + @discord.ui.button(label=S.LEADERBOARD_UI["btn_exp"], style=discord.ButtonStyle.secondary, row=1) + async def exp_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + self.mode = "exp"; self.page = 0; self._update_buttons() + await interaction.response.edit_message(embed=self._make_embed(), view=self) + + @discord.ui.button(label=S.LEADERBOARD_UI["btn_season"], style=discord.ButtonStyle.secondary, row=1) + async def season_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + self.mode = "season"; self.page = 0; self._update_buttons() + await interaction.response.edit_message(embed=self._make_embed(), view=self) + + @discord.ui.button(label=S.LEADERBOARD_UI["btn_prestige"], style=discord.ButtonStyle.secondary, row=1) + async def prestige_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + self.mode = "prestige"; self.page = 0; self._update_buttons() + await interaction.response.edit_message(embed=self._make_embed(), view=self) + + @discord.ui.button(label=S.LEADERBOARD_UI["btn_wagered"], style=discord.ButtonStyle.secondary, row=1) + async def wagered_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + self.mode = "wagered"; self.page = 0; self._update_buttons() + await interaction.response.edit_message(embed=self._make_embed(), view=self) + + @discord.ui.button(label=S.LEADERBOARD_UI["btn_fish"], style=discord.ButtonStyle.secondary, row=2) + async def fish_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + self.mode = "fish"; self.page = 0; self._update_buttons() + await interaction.response.edit_message(embed=self._make_embed(), view=self) async def on_timeout(self): for child in self.children: @@ -2415,19 +2799,37 @@ class LeaderboardView(discord.ui.View): @tree.command(name="leaderboard", description=S.CMD["leaderboard"]) async def cmd_leaderboard(interaction: discord.Interaction): await interaction.response.defer() - all_entries = await economy.get_leaderboard(top_n=None) - exp_entries_raw = await economy.get_leaderboard_exp(top_n=None) + coins_raw, exp_raw, season_raw, prestige_raw, wagered_raw, fish_raw = await asyncio.gather( + economy.get_leaderboard(top_n=None), + economy.get_leaderboard_exp(top_n=None), + economy.get_leaderboard_season_exp(top_n=None), + economy.get_leaderboard_prestige(top_n=None), + economy.get_leaderboard_wagered(top_n=None), + economy.get_leaderboard_fish(top_n=None), + ) house_entry = None regular = [] - for uid, bal in all_entries: - if bot.user and int(uid) == bot.user.id: + bot_id = bot.user.id if bot.user else None + for uid, bal in coins_raw: + if bot_id and int(uid) == bot_id: house_entry = (uid, bal) else: regular.append((uid, bal)) - exp_entries = [e for e in exp_entries_raw if not (bot.user and int(e[0]) == bot.user.id)] - view = LeaderboardView(regular, house_entry, interaction.guild, bot.user, exp_entries) + def _no_bot(entries: list) -> list: + return [e for e in entries if not (bot_id and int(e[0]) == bot_id)] + + data = { + "coins": regular, + "exp": _no_bot(exp_raw), + "season": _no_bot(season_raw), + "prestige": _no_bot(prestige_raw), + "wagered": _no_bot(wagered_raw), + "fish": _no_bot(fish_raw), + "house_entry": house_entry, + } + view = LeaderboardView(data, interaction.guild, bot.user) await interaction.followup.send(embed=view._make_embed(), view=view) @@ -2912,6 +3314,13 @@ async def cmd_rps(interaction: discord.Interaction, panus: str = "0", vastane: d if interaction.user.id in _active_games: await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) return + if panus_int > 0: + has_360 = "monitor_360" in _data.get("items", []) + if rem := _gamble_cd(interaction.user.id, has_360): + await interaction.response.send_message( + S.ERR["gamble_cooldown"].format(ts=_cd_ts(rem)), ephemeral=True + ) + return _active_games.add(interaction.user.id) bet_str = S.RPS_UI["vs_bot_bet"].format(bet=_coin(panus_int)) if panus_int > 0 else "" embed = discord.Embed( @@ -2950,6 +3359,12 @@ async def cmd_slots(interaction: discord.Interaction, panus: str): if panus_int <= 0: await interaction.response.send_message(S.ERR["positive_bet"], ephemeral=True) return + has_360 = "monitor_360" in _data.get("items", []) + if rem := _gamble_cd(interaction.user.id, has_360): + await interaction.response.send_message( + S.ERR["gamble_cooldown"].format(ts=_cd_ts(rem)), ephemeral=True + ) + return if interaction.user.id in _active_games: await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) return @@ -3357,6 +3772,12 @@ async def cmd_blackjack(interaction: discord.Interaction, panus: str): if bet <= 0: await interaction.response.send_message(S.ERR["positive_bet"], ephemeral=True) return + has_360 = "monitor_360" in _data.get("items", []) + if rem := _gamble_cd(interaction.user.id, has_360): + await interaction.response.send_message( + S.ERR["gamble_cooldown"].format(ts=_cd_ts(rem)), ephemeral=True + ) + return if interaction.user.id in _active_games: await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) return @@ -3741,6 +4162,550 @@ async def cmd_channels(interaction: discord.Interaction): await interaction.response.send_message(embed=embed, ephemeral=True) +# --------------------------------------------------------------------------- +# /fish - fishing minigame +# --------------------------------------------------------------------------- +class FishCatchView(discord.ui.View): + """Shown after a successful pull - lets user sell or keep the fish.""" + + def __init__(self, user_id: int, res: dict, fish_id: str, weight: int): + super().__init__(timeout=60) + self.user_id = user_id + self._res = res + self._fish_id = fish_id + self._weight = weight + self._done = False + + def _catch_embed(self, color: int = 0x57F287) -> discord.Embed: + rarity = economy.FISH_CATALOGUE[self._fish_id]["rarity"] + emoji = S.FISH_RARITY_EMOJI[rarity] + fish_name = S.FISH_NAMES[self._fish_id] + desc = S.FISH_UI["catch_desc"].format( + name=fish_name, weight=self._weight, + exp=self._res["exp"], value=_coin(self._res["value"]), + ) + if self._res.get("is_new"): + desc += S.FISH_UI["new_fish"] + return discord.Embed(title=f"{emoji} {fish_name}!", description=desc, color=color) + + @discord.ui.button(label="", style=discord.ButtonStyle.success) + async def sell_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + if interaction.user.id != self.user_id: + await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) + return + if self._done: + await interaction.response.defer() + return + self._done = True + self.stop() + for child in self.children: + child.disabled = True + sell_res = await economy.do_fish_sell(self.user_id, [-1]) + rarity = economy.FISH_CATALOGUE[self._fish_id]["rarity"] + emoji = S.FISH_RARITY_EMOJI[rarity] + fish_name = S.FISH_NAMES[self._fish_id] + desc = S.FISH_UI["catch_sold"].format( + name=fish_name, weight=self._weight, + coins=_coin(sell_res["coins"]), exp=self._res["exp"], + balance=_coin(sell_res["balance"]), + ) + if self._res.get("is_new"): + desc += S.FISH_UI["new_fish"] + embed = discord.Embed(title=f"{emoji} {fish_name}!", description=desc, color=0x57F287) + await interaction.response.edit_message(embed=embed, view=self) + + @discord.ui.button(label="", style=discord.ButtonStyle.secondary) + async def keep_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + if interaction.user.id != self.user_id: + await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) + return + if self._done: + await interaction.response.defer() + return + self._done = True + self.stop() + for child in self.children: + child.disabled = True + rarity = economy.FISH_CATALOGUE[self._fish_id]["rarity"] + emoji = S.FISH_RARITY_EMOJI[rarity] + fish_name = S.FISH_NAMES[self._fish_id] + desc = S.FISH_UI["catch_kept"].format(name=fish_name, weight=self._weight, exp=self._res["exp"]) + if self._res.get("is_new"): + desc += S.FISH_UI["new_fish"] + embed = discord.Embed(title=f"{emoji} {fish_name}!", description=desc, color=0x5865F2) + await interaction.response.edit_message(embed=embed, view=self) + + async def on_timeout(self): + for child in self.children: + child.disabled = True + + +class FishingView(discord.ui.View): + BITE_WINDOW = 2.0 + + def __init__(self, user_id: int, fish_id: str, weight: int): + super().__init__(timeout=40) + self.user_id = user_id + self._fish_id = fish_id + self._weight = weight + self._clicked = False + self._bite_active = False + self._msg: discord.Message | None = None + + self.pull_btn = discord.ui.Button( + label=S.FISH_UI["btn_wait"], + style=discord.ButtonStyle.secondary, + disabled=True, + ) + self.pull_btn.callback = self._pull + self.add_item(self.pull_btn) + + async def start(self, msg: discord.Message) -> None: + self._msg = msg + wait = random.uniform(5, 15) + await asyncio.sleep(wait) + if self._clicked or self.is_finished(): + return + self._bite_active = True + self.pull_btn.disabled = False + self.pull_btn.label = S.FISH_UI["btn_bite"] + self.pull_btn.style = discord.ButtonStyle.success + try: + await msg.edit( + embed=discord.Embed(title=S.TITLE["fish_bite"], description=S.FISH_UI["bite_desc"], color=0xED4245), + view=self, + ) + except Exception: + pass + await asyncio.sleep(self.BITE_WINDOW) + if not self._clicked: + self.stop() + _active_games.discard(self.user_id) + self.pull_btn.disabled = True + try: + await msg.edit( + embed=discord.Embed(title=S.TITLE["fish_escape"], description=S.FISH_UI["escape_desc"], color=0x99AAB5), + view=self, + ) + except Exception: + pass + + async def _pull(self, interaction: discord.Interaction) -> None: + if interaction.user.id != self.user_id: + await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) + return + if not self._bite_active: + await interaction.response.send_message(S.FISH_UI["too_early"], ephemeral=True) + return + self._clicked = True + self.stop() + _active_games.discard(self.user_id) + self.pull_btn.disabled = True + await interaction.response.defer() + + if self._fish_id == "junk": + junk_text = random.choice(S.FISH_JUNK_LINES) + user_data = await economy.get_user(interaction.user.id) + embed = discord.Embed( + title=S.TITLE["fish_junk"], + description=S.FISH_UI["junk_desc"].format(text=junk_text, balance=_coin(user_data.get("balance", 0))), + color=0x99AAB5, + ) + await self._msg.edit(embed=embed, view=self) + return + + res = await economy.do_fish_resolve(self.user_id, self._fish_id, self._weight) + if not res["ok"]: + await self._msg.edit(embed=discord.Embed(title=S.ERR["generic_error"], color=0xED4245), view=self) + return + + catch_view = FishCatchView(self.user_id, res, self._fish_id, self._weight) + catch_view.sell_btn.label = S.FISH_UI["btn_sell"] + catch_view.keep_btn.label = S.FISH_UI["btn_keep"] + await self._msg.edit(embed=catch_view._catch_embed(), view=catch_view) + if res.get("exp", 0) > 0: + asyncio.create_task(_award_exp(interaction, res["exp"])) + + async def on_timeout(self): + for child in self.children: + child.disabled = True + _active_games.discard(self.user_id) + + +@tree.command(name="fish", description=S.CMD["fish"]) +@app_commands.guild_only() +async def cmd_fish(interaction: discord.Interaction): + if await _check_cmd_rate(interaction): + return + if interaction.user.id in _active_games: + await interaction.response.send_message(S.ERR["game_in_progress"], ephemeral=True) + return + + res = await economy.do_fish_start(interaction.user.id) + if not res["ok"]: + if res["reason"] == "banned": + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + elif res["reason"] == "cooldown": + await interaction.response.send_message( + S.CD_MSG["fish"].format(ts=_cd_ts(res["remaining"])), ephemeral=True + ) + elif res["reason"] == "jailed": + await interaction.response.send_message( + S.CD_MSG["jailed"].format(ts=_cd_ts(res["remaining"])), ephemeral=True + ) + return + + user_data = await economy.get_user(interaction.user.id) + rarity_bump = "kalavork" in user_data.get("items", []) + has_echolood = "echolood" in user_data.get("items", []) + fish_id, weight = economy.roll_fish(rarity_bump=rarity_bump) + + _active_games.add(interaction.user.id) + view = FishingView(interaction.user.id, fish_id, weight) + if has_echolood: + view.BITE_WINDOW = 3.0 + embed = discord.Embed(title=S.TITLE["fish_cast"], description=S.FISH_UI["cast_desc"], color=0x5865F2) + await interaction.response.send_message(embed=embed, view=view) + msg = await interaction.original_response() + asyncio.create_task(view.start(msg)) + asyncio.create_task(_maybe_remind(interaction.user.id, "fish")) + + +@tree.command(name="fishbook", description=S.CMD["fishbook"]) +@app_commands.describe(kasutaja=S.OPT["fishbook_kasutaja"]) +async def cmd_fishbook(interaction: discord.Interaction, kasutaja: discord.Member | None = None): + target = kasutaja or interaction.user + res = await economy.do_fishbook(target.id) + book: dict = res["book"] + total = res["total_species"] + caught_count = res["unique_caught"] + + if not book: + embed = discord.Embed( + title=S.TITLE["fishbook"], + description=S.FISH_UI["book_empty"], + color=0x5865F2, + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + inv_counts: dict = res.get("inv_counts", {}) + all_fish = list(economy.FISH_CATALOGUE.items()) + lines = [S.FISH_UI["book_caught"].format(caught=caught_count, total=total)] + for fish_id, fish_data in all_fish: + rarity = fish_data["rarity"] + emoji = S.FISH_RARITY_EMOJI[rarity] + rarity_name = S.FISH_RARITY_NAMES[rarity] + count = book.get(fish_id, 0) + if count > 0: + n_inv = inv_counts.get(fish_id, 0) + inv_str = S.FISH_UI["book_inv"].format(n=n_inv) if n_inv > 0 else "" + lines.append(S.FISH_UI["book_yes"].format(emoji=emoji, name=S.FISH_NAMES[fish_id], rarity=rarity_name, count=count, inv=inv_str)) + else: + lines.append(S.FISH_UI["book_no"].format(rarity=rarity_name)) + + embed = discord.Embed( + title=S.TITLE["fishbook"].replace("Kalakogu", f"{target.display_name} kalakogu"), + description="\n".join(lines), + color=0x5865F2, + ) + embed.set_footer(text=S.FISH_UI["book_footer"].format(page=1, total_pages=1, caught=caught_count, total=total)) + await interaction.response.send_message(embed=embed, ephemeral=True) + + +# --------------------------------------------------------------------------- +# /fishsell +# --------------------------------------------------------------------------- +@tree.command(name="fishsell", description=S.CMD["fishsell"]) +@app_commands.guild_only() +async def cmd_fishsell(interaction: discord.Interaction): + await interaction.response.defer(ephemeral=True) + user_data = await economy.get_user(interaction.user.id) + inv: list = user_data.get("fish_inventory") or [] + if not inv: + embed = discord.Embed( + title=S.TITLE["fishbook"], + description=S.FISH_UI["inv_empty"], + color=0x5865F2, + ) + await interaction.followup.send(embed=embed, ephemeral=True) + return + + total_value = sum(e["value"] for e in inv) + lines = [S.FISH_UI["inv_header"].format(count=len(inv), total_value=_coin(total_value))] + for entry in inv: + fid = entry.get("fish_id", "") + rarity = economy.FISH_CATALOGUE.get(fid, {}).get("rarity", "common") + emoji = S.FISH_RARITY_EMOJI.get(rarity, "🐟") + name = S.FISH_NAMES.get(fid, fid) + lines.append(S.FISH_UI["inv_entry"].format(emoji=emoji, name=name, weight=entry["weight"], value=_coin(entry["value"]))) + + embed = discord.Embed(title=S.TITLE["fishbook"], description="\n".join(lines), color=0x5865F2) + + sell_all_btn = discord.ui.Button(label=S.FISH_UI["btn_sell"] + f" ({_coin(total_value)})", style=discord.ButtonStyle.success) + + async def _sell_all(btn_interaction: discord.Interaction): + if btn_interaction.user.id != interaction.user.id: + await btn_interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True) + return + sell_view.stop() + for child in sell_view.children: + child.disabled = True + res = await economy.do_fish_sell(interaction.user.id) + if not res["ok"]: + await btn_interaction.response.edit_message( + embed=discord.Embed(description=S.FISH_UI["inv_none"], color=0x99AAB5), view=sell_view + ) + return + sold_embed = discord.Embed( + title=S.TITLE["fishbook"], + description=S.FISH_UI["inv_sold_all"].format( + count=res["count"], coins=_coin(res["coins"]), balance=_coin(res["balance"]) + ), + color=0x57F287, + ) + await btn_interaction.response.edit_message(embed=sold_embed, view=sell_view) + + sell_all_btn.callback = _sell_all + sell_view = discord.ui.View(timeout=60) + sell_view.add_item(sell_all_btn) + await interaction.followup.send(embed=embed, view=sell_view, ephemeral=True) + + +# --------------------------------------------------------------------------- +# /prestige /prestigeshop /prestigebuy +# --------------------------------------------------------------------------- +class PrestigeView(discord.ui.View): + def __init__(self, user_id: int, tab: str = "status"): + super().__init__(timeout=60) + self.user_id = user_id + self.tab = tab + + async def _rebuild(self, data: dict): + self.clear_items() + pp = data.get("prestige_points", 0) + upgrades: dict = data.get("prestige_upgrades") or {} + exp = data.get("exp", 0) + level = economy.get_level(exp) + + # Tab switcher buttons (row 0) + for tab_id, label in (("status", S.PRESTIGE_UI["btn_tab_status"]), ("shop", S.PRESTIGE_UI["btn_tab_shop"])): + btn = discord.ui.Button( + label=label, + style=discord.ButtonStyle.primary if tab_id == self.tab else discord.ButtonStyle.secondary, + disabled=(tab_id == self.tab), + row=0, + ) + btn.callback = self._switch_tab(tab_id) + self.add_item(btn) + + if self.tab == "status" and level >= economy.PRESTIGE_MIN_LEVEL: + confirm_btn = discord.ui.Button( + label=S.PRESTIGE_UI["btn_confirm"], + style=discord.ButtonStyle.danger, + row=1, + ) + confirm_btn.callback = self._do_prestige() + cancel_btn = discord.ui.Button( + label=S.PRESTIGE_UI["btn_cancel"], + style=discord.ButtonStyle.secondary, + row=1, + ) + cancel_btn.callback = self._do_cancel() + self.add_item(confirm_btn) + self.add_item(cancel_btn) + + elif self.tab == "shop": + for uid, item in economy.PRESTIGE_SHOP.items(): + cur_level = upgrades.get(uid, 0) + if cur_level >= item["max_level"]: + continue + cost = item["pp_cost"] + btn = discord.ui.Button( + label=S.PRESTIGE_UI["btn_buy_upgrade"].format(emoji=item["emoji"], name=S.PRESTIGE_SHOP_NAMES[uid], cost=cost), + style=discord.ButtonStyle.success if pp >= cost else discord.ButtonStyle.secondary, + disabled=(pp < cost), + row=1, + ) + btn.callback = self._buy_upgrade(uid) + self.add_item(btn) + + def _build_status_embed(self, data: dict) -> discord.Embed: + exp = data.get("exp", 0) + level = economy.get_level(exp) + pp = data.get("prestige_points", 0) + p_level = data.get("prestige_level", 0) + if level >= economy.PRESTIGE_MIN_LEVEL: + pp_preview = max(1, exp // 1000) + embed = discord.Embed( + title=S.TITLE["prestige_confirm"], + description=S.PRESTIGE_UI["confirm_desc"].format(level=level, exp=exp, pp=pp_preview), + color=0xF4C430, + ) + else: + embed = discord.Embed( + title=S.TITLE["prestige_too_low"], + description=S.PRESTIGE_UI["too_low_desc"].format(level=level, required=economy.PRESTIGE_MIN_LEVEL), + color=0xED4245, + ) + if p_level > 0: + embed.set_footer(text=S.PRESTIGE_UI["status_footer"].format(level=p_level, pp=pp)) + return embed + + def _build_shop_embed(self, data: dict) -> discord.Embed: + pp = data.get("prestige_points", 0) + upgrades: dict = data.get("prestige_upgrades") or {} + embed = discord.Embed( + title=S.TITLE["prestige_shop"], + description=S.PRESTIGE_UI["shop_desc"].format(pp=pp), + color=0xF4C430, + ) + for uid, item in economy.PRESTIGE_SHOP.items(): + cur_level = upgrades.get(uid, 0) + name = f"{item['emoji']} {S.PRESTIGE_SHOP_NAMES[uid]}" + cost_str = S.PRESTIGE_UI["shop_maxed"] if cur_level >= item["max_level"] else S.PRESTIGE_UI["shop_cost_fmt"].format(cost=item["pp_cost"]) + level_str = S.PRESTIGE_UI["shop_level_fmt"].format(cur=cur_level, max=item["max_level"]) + embed.add_field(name=f"{name} · {level_str} · {cost_str}", value=S.PRESTIGE_SHOP_DESCRIPTIONS[uid], inline=False) + return embed + + def _switch_tab(self, tab_id: str): + async def _cb(interaction: discord.Interaction): + if interaction.user.id != self.user_id: + await interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True) + return + self.tab = tab_id + await interaction.response.defer() + data = await economy.get_user(self.user_id) + await self._rebuild(data) + embed = self._build_status_embed(data) if tab_id == "status" else self._build_shop_embed(data) + await interaction.edit_original_response(embed=embed, view=self) + return _cb + + def _do_prestige(self): + async def _cb(interaction: discord.Interaction): + if interaction.user.id != self.user_id: + await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) + return + await interaction.response.defer() + res = await economy.do_prestige(self.user_id) + self.clear_items() + if not res["ok"]: + embed = discord.Embed( + title=S.TITLE["prestige_too_low"], + description=S.PRESTIGE_UI["too_low_desc"].format(level=res.get("level", 0), required=res.get("required", 30)), + color=0xED4245, + ) + else: + embed = discord.Embed( + title=S.TITLE["prestige_success"].format(level=res["prestige_level"]), + description=S.PRESTIGE_UI["success_desc"].format(pp=res["pp_earned"], level=res["prestige_level"], total_pp=res["prestige_points"]), + color=0xF4C430, + ) + await interaction.edit_original_response(embed=embed, view=self) + if res.get("ok") and interaction.guild: + member = interaction.guild.get_member(self.user_id) + if member: + asyncio.create_task(_ensure_level_role(member, 1)) + return _cb + + def _do_cancel(self): + async def _cb(interaction: discord.Interaction): + if interaction.user.id != self.user_id: + await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) + return + self.clear_items() + await interaction.response.edit_message(view=self) + return _cb + + def _buy_upgrade(self, upgrade_id: str): + async def _cb(interaction: discord.Interaction): + if interaction.user.id != self.user_id: + await interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True) + return + await interaction.response.defer() + res = await economy.do_prestige_buy(self.user_id, upgrade_id) + if not res["ok"]: + if res["reason"] == "insufficient_pp": + err = S.PRESTIGE_UI["buy_no_pp"].format(have=res["have"], need=res["need"]) + elif res["reason"] == "maxed": + err = S.PRESTIGE_UI["buy_maxed"] + else: + err = S.ERR["generic_error"].format(error=res["reason"]) + await interaction.followup.send(err, ephemeral=True) + return + data = await economy.get_user(self.user_id) + await self._rebuild(data) + embed = self._build_shop_embed(data) + item = economy.PRESTIGE_SHOP[res["upgrade_id"]] + name = f"{item['emoji']} {S.PRESTIGE_SHOP_NAMES[res['upgrade_id']]}" + embed.description = S.PRESTIGE_UI["buy_success_desc"].format( + name=name, new_level=res["new_level"], max_level=item["max_level"], pp=res["pp_remaining"] + ) + "\n\n" + (embed.description or "") + await interaction.edit_original_response(embed=embed, view=self) + return _cb + + async def on_timeout(self): + self.clear_items() + + +@tree.command(name="prestige", description=S.CMD["prestige"]) +@app_commands.guild_only() +async def cmd_prestige(interaction: discord.Interaction): + if await _check_cmd_rate(interaction): + return + data = await economy.get_user(interaction.user.id) + if data.get("eco_banned"): + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + return + view = PrestigeView(interaction.user.id) + await view._rebuild(data) + embed = view._build_status_embed(data) + await interaction.response.send_message(embed=embed, view=view, ephemeral=True) + + +@tree.command(name="prestigeshop", description=S.CMD["prestigeshop"]) +async def cmd_prestigeshop(interaction: discord.Interaction): + data = await economy.get_user(interaction.user.id) + view = PrestigeView(interaction.user.id, tab="shop") + await view._rebuild(data) + embed = view._build_shop_embed(data) + await interaction.response.send_message(embed=embed, view=view, ephemeral=True) + + +@tree.command(name="prestigebuy", description=S.CMD["prestigebuy"]) +@app_commands.describe(upgrade=S.OPT["prestigebuy_upgrade"]) +async def cmd_prestigebuy(interaction: discord.Interaction, upgrade: str): + if await _check_cmd_rate(interaction): + return + res = await economy.do_prestige_buy(interaction.user.id, upgrade.strip().lower()) + if not res["ok"]: + if res["reason"] == "banned": + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + elif res["reason"] == "not_found": + await interaction.response.send_message(S.PRESTIGE_UI["buy_not_found"], ephemeral=True) + elif res["reason"] == "maxed": + await interaction.response.send_message(S.PRESTIGE_UI["buy_maxed"], ephemeral=True) + elif res["reason"] == "insufficient_pp": + await interaction.response.send_message( + S.PRESTIGE_UI["buy_no_pp"].format(have=res["have"], need=res["need"]), ephemeral=True + ) + return + + item = economy.PRESTIGE_SHOP[res["upgrade_id"]] + name = f"{item['emoji']} {S.PRESTIGE_SHOP_NAMES[res['upgrade_id']]}" + embed = discord.Embed( + title=S.TITLE["prestige_buy_ok"], + description=S.PRESTIGE_UI["buy_success_desc"].format( + name=name, + new_level=res["new_level"], + max_level=res["max_level"], + pp=res["pp_remaining"], + ), + color=0x57F287, + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + + # --------------------------------------------------------------------------- # Error handling for slash commands # --------------------------------------------------------------------------- diff --git a/economy.py b/economy.py index 48d1b33..09ebc56 100644 --- a/economy.py +++ b/economy.py @@ -30,7 +30,10 @@ def _txn(event: str, **fields) -> None: # To use your custom Discord emoji replace COIN with the full tag, e.g.: # COIN = "<:tipicoin:1234567890123456789>" # --------------------------------------------------------------------------- -COIN = "<:TipiCOIN:1483000209188589628>" +COIN = "<:TipiCOIN:1483000209188589628>" +PP_EMOJI = "<:TipiFIRE:1483431381668335687>" +PRESTIGE_ROLE = "TipiPRESTIGE" +PRESTIGE_MIN_LEVEL = 30 # minimum level required to prestige # --------------------------------------------------------------------------- # Shop catalogue @@ -141,13 +144,32 @@ SHOP: dict[str, ShopItem] = { "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"], - 3: ["monitor_360", "karikas", "gaming_tool"], + 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 @@ -158,11 +180,104 @@ SHOP_LEVEL_REQ: dict[str, int] = { "klaviatuur": 10, "monitor": 10, "cat6": 10, + "ussipurk": 10, "monitor_360": 20, "karikas": 20, "gaming_tool": 20, + "kalavork": 20, + "echolood": 20, } +# --------------------------------------------------------------------------- +# Prestige shop catalogue +# --------------------------------------------------------------------------- +class PrestigeItem(TypedDict): + emoji: str + max_level: int + pp_cost: int + effect: float + + +PRESTIGE_SHOP: dict[str, PrestigeItem] = { + "coin_mult": { + "emoji": "<:TipiCOIN:1483000209188589628>", + "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, + }, +} + +# --------------------------------------------------------------------------- +# 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) + # --------------------------------------------------------------------------- # EXP / Level system # --------------------------------------------------------------------------- @@ -227,6 +342,7 @@ COOLDOWNS: dict[str, timedelta] = { "beg": timedelta(minutes=5), "crime": timedelta(hours=2), "rob": timedelta(hours=2), + "fish": timedelta(minutes=2), } JAIL_DURATION = timedelta(minutes=30) @@ -272,6 +388,16 @@ class UserData(TypedDict, total=False): 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 def _default_user() -> UserData: @@ -312,6 +438,16 @@ def _default_user() -> UserData: "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": [], } @@ -388,33 +524,6 @@ async def get_all_users_raw() -> dict[str, "UserData"]: return result -async def migrate_anticheat_uses() -> int: - """One-time migration: users who own anticheat but have no item_uses entry get 2 uses.""" - records = await pb_client.list_all_records() - changed = 0 - for record in records: - items = record.get("items") or [] - item_uses = record.get("item_uses") or {} - if "anticheat" in items and "anticheat" not in item_uses: - item_uses["anticheat"] = 2 - await pb_client.update_record(record["id"], {"item_uses": item_uses}) - changed += 1 - return changed - - -async def migrate_reminders_default() -> int: - """One-time migration: enable all reminders for users who have an empty list.""" - _ALL_REMINDERS = ["daily", "work", "beg", "crime", "rob"] - records = await pb_client.list_all_records() - changed = 0 - for record in records: - reminders = record.get("reminders") - if not reminders: - await pb_client.update_record(record["id"], {"reminders": _ALL_REMINDERS}) - changed += 1 - return changed - - # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- @@ -498,6 +607,17 @@ async def get_leaderboard(top_n: int | None = 10) -> list[tuple[str, int]]: return result if top_n is None else result[:top_n] +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"], + ) + + 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() @@ -511,15 +631,18 @@ async def get_leaderboard_exp(top_n: int | None = 10) -> list[tuple[str, int, in async def award_exp(user_id: int, amount: int) -> dict: - """Add EXP to a user. Returns old_level, new_level, total exp.""" + """Add EXP to a user. Applies prestige exp_mult. Returns old_level, new_level, total exp.""" user = await get_user(user_id) + _, exp_mult = _prestige_mult(user) + gained = max(1, int(amount * exp_mult)) old_exp = user.get("exp", 0) - new_exp = old_exp + amount + 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} + return {"old_level": old_level, "new_level": new_level, "exp": new_exp, "gained": gained} async def do_season_reset(top_n: int = 10) -> list[tuple[str, int, int]]: @@ -541,8 +664,10 @@ async def do_season_reset(top_n: int = 10) -> list[tuple[str, int, int]]: "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) @@ -605,8 +730,13 @@ async def do_daily(user_id: int) -> dict: vip = "lan_pass" in user["items"] vip_mult = 2.0 if vip else 1.0 - base = 150 + 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 @@ -661,7 +791,10 @@ async def do_work(user_id: int) -> dict: if "energiajook" in user["items"] and random.random() < 0.30: lucky = True - earned = int(base * job_mult * worker_mult * desk_mult * (3.0 if lucky else 1.0)) + 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 @@ -699,7 +832,8 @@ async def do_beg(user_id: int) -> dict: jailed = bool(_is_jailed(user)) beg_mult = 2 if "klaviatuur" in user["items"] else 1 - earned = random.randint(10, 40) * beg_mult + 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 @@ -718,6 +852,262 @@ async def do_beg(user_id: int) -> dict: } +# --------------------------------------------------------------------------- +# /fish +# --------------------------------------------------------------------------- +async def do_fish_start(user_id: int) -> dict: + """Check cooldown + jail, set cooldown. Call before starting the fishing minigame.""" + user = await get_user(user_id) + 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} + + +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], + } + + +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: + to_sell = [inv[i] for i in sorted(set(indices)) if 0 <= i < len(inv)] + keep_idx = set(range(len(inv))) - set(indices) + 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), + } + + +# --------------------------------------------------------------------------- +# /prestige +# --------------------------------------------------------------------------- +async def do_prestige(user_id: int) -> dict: + """Prestige: requires level 30, earns PP, resets balance/exp/items/cooldowns.""" + user = await get_user(user_id) + 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, + } + + +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"} + + user = await get_user(user_id) + 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"], + } + + +# --------------------------------------------------------------------------- +# 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] + + # --------------------------------------------------------------------------- # /crime # --------------------------------------------------------------------------- @@ -1171,6 +1561,57 @@ async def do_admin_inspect(target_id: int) -> dict: return {"ok": True, "data": dict(user)} +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, + } + + +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"} + + # --------------------------------------------------------------------------- # /reminders # --------------------------------------------------------------------------- diff --git a/logs/bot.log b/logs/bot.log index c1fd299..c01fdc6 100644 --- a/logs/bot.log +++ b/logs/bot.log @@ -1517,3 +1517,9870 @@ discord.app_commands.errors.CommandInvokeError: Command 'leaderboard' raised an 2026-03-20 17:19:15,335 [INFO] tipilan: /adminseason triggered by alexander.rr37 (top_n=20) 2026-03-20 17:22:06,854 [ERROR] tipilan: Unhandled asyncio error: Unclosed client session 2026-03-20 17:22:06,855 [ERROR] tipilan: Unhandled asyncio error: Unclosed connector +2026-03-23 14:52:40,600 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-23 14:52:40,601 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-23 14:52:40,628 [INFO] discord.client: logging in using static token +2026-03-23 14:52:41,638 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: 2ece60c36fce2aed806297e4a345be35). +2026-03-23 14:52:43,653 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-23 14:52:45,694 [ERROR] tipilan: migrate_item_ids failed: Cannot connect to host 127.0.0.1:8090 ssl:default [The remote computer refused the network connection] +2026-03-23 14:52:45,695 [ERROR] tipilan: Failed to load sheet on startup: [Errno 2] No such file or directory: 'credentials.json' +2026-03-23 14:52:46,330 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 (global commands cleared) +2026-03-23 14:52:46,330 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-23 14:52:46,331 [INFO] tipilan: Rich presence rotation started +2026-03-23 14:52:48,369 [ERROR] discord.client: Ignoring exception in on_ready +Traceback (most recent call last): + File "C:\Users\Sass\Documents\GitHub\TipiLAN\tipibot\.venv\Lib\site-packages\aiohttp\connector.py", line 1298, in _wrap_create_connection + sock = await aiohappyeyeballs.start_connection( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\Users\Sass\Documents\GitHub\TipiLAN\tipibot\.venv\Lib\site-packages\aiohappyeyeballs\impl.py", line 122, in start_connection + raise first_exception + File "C:\Users\Sass\Documents\GitHub\TipiLAN\tipibot\.venv\Lib\site-packages\aiohappyeyeballs\impl.py", line 73, in start_connection + sock = await _connect_sock( + ^^^^^^^^^^^^^^^^^^^^ + File "C:\Users\Sass\Documents\GitHub\TipiLAN\tipibot\.venv\Lib\site-packages\aiohappyeyeballs\impl.py", line 208, in _connect_sock + await loop.sock_connect(sock, address) + File "C:\Program Files\WindowsApps\PythonSoftwareFoundation.Python.3.11_3.11.2544.0_x64__qbz5n2kfra8p0\Lib\asyncio\proactor_events.py", line 726, in sock_connect + return await self._proactor.connect(sock, address) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\Program Files\WindowsApps\PythonSoftwareFoundation.Python.3.11_3.11.2544.0_x64__qbz5n2kfra8p0\Lib\asyncio\windows_events.py", line 854, in _poll + value = callback(transferred, key, ov) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\Program Files\WindowsApps\PythonSoftwareFoundation.Python.3.11_3.11.2544.0_x64__qbz5n2kfra8p0\Lib\asyncio\windows_events.py", line 641, in finish_connect + ov.getresult() +ConnectionRefusedError: [WinError 1225] The remote computer refused the network connection + +The above exception was the direct cause of the following exception: + +Traceback (most recent call last): + File "C:\Users\Sass\Documents\GitHub\TipiLAN\tipibot\.venv\Lib\site-packages\discord\client.py", line 508, in _run_event + await coro(*args, **kwargs) + File "C:\Users\Sass\Documents\GitHub\TipiLAN\tipibot\bot.py", line 538, in on_ready + await _restore_reminders() + File "C:\Users\Sass\Documents\GitHub\TipiLAN\tipibot\bot.py", line 1279, in _restore_reminders + for uid_str, user in (await economy.get_all_users_raw()).items(): + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\Users\Sass\Documents\GitHub\TipiLAN\tipibot\economy.py", line 563, in get_all_users_raw + records = await pb_client.list_all_records() + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\Users\Sass\Documents\GitHub\TipiLAN\tipibot\pb_client.py", line 138, in list_all_records + hdrs = await _hdrs() + ^^^^^^^^^^^^^ + File "C:\Users\Sass\Documents\GitHub\TipiLAN\tipibot\pb_client.py", line 73, in _hdrs + return {"Authorization": await _ensure_auth()} + ^^^^^^^^^^^^^^^^^^^^ + File "C:\Users\Sass\Documents\GitHub\TipiLAN\tipibot\pb_client.py", line 58, in _ensure_auth + async with session.post( + File "C:\Users\Sass\Documents\GitHub\TipiLAN\tipibot\.venv\Lib\site-packages\aiohttp\client.py", line 1510, in __aenter__ + self._resp: _RetType = await self._coro + ^^^^^^^^^^^^^^^^ + File "C:\Users\Sass\Documents\GitHub\TipiLAN\tipibot\.venv\Lib\site-packages\aiohttp\client.py", line 779, in _request + resp = await handler(req) + ^^^^^^^^^^^^^^^^^^ + File "C:\Users\Sass\Documents\GitHub\TipiLAN\tipibot\.venv\Lib\site-packages\aiohttp\client.py", line 734, in _connect_and_send_request + conn = await self._connector.connect( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\Users\Sass\Documents\GitHub\TipiLAN\tipibot\.venv\Lib\site-packages\aiohttp\connector.py", line 672, in connect + proto = await self._create_connection(req, traces, timeout) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\Users\Sass\Documents\GitHub\TipiLAN\tipibot\.venv\Lib\site-packages\aiohttp\connector.py", line 1239, in _create_connection + _, proto = await self._create_direct_connection(req, traces, timeout) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\Users\Sass\Documents\GitHub\TipiLAN\tipibot\.venv\Lib\site-packages\aiohttp\connector.py", line 1611, in _create_direct_connection + raise last_exc + File "C:\Users\Sass\Documents\GitHub\TipiLAN\tipibot\.venv\Lib\site-packages\aiohttp\connector.py", line 1580, in _create_direct_connection + transp, proto = await self._wrap_create_connection( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\Users\Sass\Documents\GitHub\TipiLAN\tipibot\.venv\Lib\site-packages\aiohttp\connector.py", line 1321, in _wrap_create_connection + raise client_error(req.connection_key, exc) from exc +aiohttp.client_exceptions.ClientConnectorError: Cannot connect to host 127.0.0.1:8090 ssl:default [The remote computer refused the network connection] +2026-03-23 14:52:50,408 [WARNING] tipilan: Presence: failed to fetch economy count: Cannot connect to host 127.0.0.1:8090 ssl:default [The remote computer refused the network connection] +2026-03-23 14:53:08,372 [WARNING] tipilan: Presence: failed to fetch economy count: Cannot connect to host 127.0.0.1:8090 ssl:default [The remote computer refused the network connection] +2026-03-23 14:53:28,378 [WARNING] tipilan: Presence: failed to fetch economy count: Cannot connect to host 127.0.0.1:8090 ssl:default [The remote computer refused the network connection] +2026-03-23 14:53:46,344 [WARNING] tipilan: Presence: failed to fetch economy count: PocketBase auth failed (400): {"data":{},"message":"Failed to authenticate.","status":400} + +2026-03-23 14:54:06,342 [WARNING] tipilan: Presence: failed to fetch economy count: PocketBase auth failed (400): {"data":{},"message":"Failed to authenticate.","status":400} + +2026-03-23 14:54:26,401 [WARNING] tipilan: Presence: failed to fetch economy count: 404, message='Not Found', url='http://127.0.0.1:8090/api/collections/economy_users/records?perPage=1&page=1' +2026-03-23 14:54:29,430 [ERROR] tipilan: Unhandled asyncio error: Unclosed client session +2026-03-23 14:54:29,432 [ERROR] tipilan: Unhandled asyncio error: Unclosed connector +2026-03-23 14:58:01,345 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-23 14:58:01,346 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-23 14:58:01,352 [INFO] discord.client: logging in using static token +2026-03-23 14:58:02,572 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: e84cb9aded62a4e930b1c4ff1478df24). +2026-03-23 14:58:04,612 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-23 14:58:09,022 [INFO] tipilan: Loaded 64 member rows from Google Sheets +2026-03-23 14:58:09,545 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 (global commands cleared) +2026-03-23 14:58:09,547 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-23 14:58:09,547 [INFO] tipilan: Rich presence rotation started +2026-03-23 14:58:09,549 [ERROR] tipilan: migrate_lifetime_exp failed: module 'economy' has no attribute 'migrate_lifetime_exp' +2026-03-23 14:58:20,766 [ERROR] tipilan: Unhandled asyncio error: Unclosed client session +2026-03-23 14:58:20,766 [ERROR] tipilan: Unhandled asyncio error: Unclosed connector +2026-03-23 14:59:27,722 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-23 14:59:27,723 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-23 14:59:27,729 [INFO] discord.client: logging in using static token +2026-03-23 14:59:28,760 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: b7bf4f34d3081820853970c5dadfa20f). +2026-03-23 14:59:30,783 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-23 14:59:35,099 [INFO] tipilan: Loaded 64 member rows from Google Sheets +2026-03-23 14:59:35,707 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 (global commands cleared) +2026-03-23 14:59:35,708 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-23 14:59:35,708 [INFO] tipilan: Rich presence rotation started +2026-03-23 15:00:58,788 [ERROR] tipilan: Unhandled asyncio error: Unclosed client session +2026-03-23 15:00:58,790 [ERROR] tipilan: Unhandled asyncio error: Unclosed connector +2026-03-23 15:06:05,707 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-23 15:06:05,707 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-23 15:06:05,716 [INFO] discord.client: logging in using static token +2026-03-23 15:06:07,063 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: 9d3abf09e93c71cea6151040d68370e3). +2026-03-23 15:06:09,061 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-23 15:06:13,676 [INFO] tipilan: Loaded 64 member rows from Google Sheets +2026-03-23 15:06:14,192 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 (global commands cleared) +2026-03-23 15:06:14,194 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-23 15:06:14,195 [INFO] tipilan: Rich presence rotation started +2026-03-23 15:24:51,289 [ERROR] tipilan: Unhandled asyncio error: Unclosed client session +2026-03-23 15:24:52,674 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-23 15:24:52,675 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-23 15:24:52,682 [INFO] discord.client: logging in using static token +2026-03-23 15:24:53,648 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: 9efa44eb43d34dcdb085cc43fae8a685). +2026-03-23 15:24:55,658 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-23 15:24:59,519 [INFO] tipilan: Loaded 64 member rows from Google Sheets +2026-03-23 15:24:59,987 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 (global commands cleared) +2026-03-23 15:24:59,988 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-23 15:24:59,989 [INFO] tipilan: Rich presence rotation started +2026-03-23 15:25:13,054 [INFO] tipilan: /economysetup triggered by alexander.rr37 +2026-03-23 16:04:30,831 [ERROR] discord.ui.view: Ignoring exception in view for item