diff --git a/.env.example b/.env.example index ae4e597..f1b5c94 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,12 @@ -# Discord bot token (from https://discord.com/developers/applications) -DISCORD_TOKEN=your-bot-token-here +# Bot runtime profile: dev (economy + member tools) or economy (economy-only) +BOT_PROFILE=dev + +# Profile-specific Discord bot tokens (from https://discord.com/developers/applications) +DISCORD_TOKEN_DEV=your-dev-bot-token-here +DISCORD_TOKEN_ECONOMY=your-economy-bot-token-here + +# Legacy fallback token (optional, backward compatibility) +DISCORD_TOKEN= # Google Sheets spreadsheet ID (the long string in the sheet URL) SHEET_ID=your-google-sheet-id-here @@ -7,11 +14,21 @@ SHEET_ID=your-google-sheet-id-here # Path to Google service account credentials JSON GOOGLE_CREDS_PATH=credentials.json -# Guild (server) ID - right-click your server with dev mode on -GUILD_ID=your-guild-id-here +# Profile-specific guild (server) IDs - right-click your server with dev mode on +GUILD_ID_DEV=your-dev-guild-id-here +GUILD_ID_ECONOMY=your-economy-guild-id-here -# Channel ID where birthday announcements are posted (bot sends @here on the birthday at 09:00 Tallinn time) -BIRTHDAY_CHANNEL_ID=your-channel-id-here +# Legacy fallback guild ID (optional, backward compatibility) +GUILD_ID= + +# Channel ID where birthday announcements are posted (dev profile) +BIRTHDAY_CHANNEL_ID_DEV=your-dev-birthday-channel-id-here + +# Optional birthday channel for economy profile (normally unset for economy-only bot) +BIRTHDAY_CHANNEL_ID_ECONOMY= + +# Legacy fallback birthday channel ID (optional, backward compatibility) +BIRTHDAY_CHANNEL_ID= # How many days before a birthday the on-join check counts as "coming up" BIRTHDAY_WINDOW_DAYS=7 @@ -20,3 +37,10 @@ BIRTHDAY_WINDOW_DAYS=7 PB_URL=http://127.0.0.1:8090 PB_ADMIN_EMAIL=admin@example.com PB_ADMIN_PASSWORD=your-pb-admin-password + +# Profile-specific PocketBase collections +PB_ECONOMY_COLLECTION_DEV=economy_users_dev +PB_ECONOMY_COLLECTION_ECONOMY=economy_users_prod + +# Legacy fallback collection name (optional, backward compatibility) +PB_ECONOMY_COLLECTION= 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..492522a 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ The economy system stores all player data in [PocketBase](https://pocketbase.io/ 1. Download `pocketbase.exe` (Windows) from https://pocketbase.io/docs/ and place it in the project root. 2. Start PocketBase: `.\pocketbase.exe serve` 3. Open the admin UI at http://127.0.0.1:8090/_/ and create a superuser account. -4. Create a collection named `economy_users` - see `docs/POCKETBASE_SETUP.md` for the full schema. +4. Create two collections for profile separation: `economy_users_dev` and `economy_users_prod` - see `docs/POCKETBASE_SETUP.md` for schema notes. 5. Set `PB_URL`, `PB_ADMIN_EMAIL`, `PB_ADMIN_PASSWORD` in `.env`. 6. **One-time data migration** (only if you have an existing `data/economy.json`): `python scripts/migrate_to_pb.py` @@ -88,15 +88,25 @@ cp .env.example .env | Variable | Description | |---|---| -| `DISCORD_TOKEN` | Bot token from Discord Developer Portal | +| `BOT_PROFILE` | Runtime profile: `dev` or `economy` | +| `DISCORD_TOKEN_DEV` | Dev bot token from Discord Developer Portal | +| `DISCORD_TOKEN_ECONOMY` | Economy bot token from Discord Developer Portal | +| `DISCORD_TOKEN` | Legacy fallback token (optional) | | `SHEET_ID` | ID from the Google Sheet URL | | `GOOGLE_CREDS_PATH` | Path to `credentials.json` (default: `credentials.json`) | -| `GUILD_ID` | Right-click server → Copy Server ID (Developer Mode on) | -| `BIRTHDAY_CHANNEL_ID` | Channel for birthday `@here` pings | +| `GUILD_ID_DEV` | Dev bot guild ID | +| `GUILD_ID_ECONOMY` | Economy bot guild ID | +| `GUILD_ID` | Legacy fallback guild ID (optional) | +| `BIRTHDAY_CHANNEL_ID_DEV` | Channel for birthday `@here` pings in dev profile | +| `BIRTHDAY_CHANNEL_ID_ECONOMY` | Optional birthday channel in economy profile | +| `BIRTHDAY_CHANNEL_ID` | Legacy fallback birthday channel ID (optional) | | `BIRTHDAY_WINDOW_DAYS` | Days before birthday that on-join check flags it (default: 7) | | `PB_URL` | PocketBase base URL (default: `http://127.0.0.1:8090`) | | `PB_ADMIN_EMAIL` | PocketBase superuser e-mail | | `PB_ADMIN_PASSWORD` | PocketBase superuser password | +| `PB_ECONOMY_COLLECTION_DEV` | PocketBase collection used by `BOT_PROFILE=dev` | +| `PB_ECONOMY_COLLECTION_ECONOMY` | PocketBase collection used by `BOT_PROFILE=economy` | +| `PB_ECONOMY_COLLECTION` | Legacy fallback collection (optional) | ### 6. Install & Run @@ -110,7 +120,12 @@ pip install -r requirements.txt # Terminal 1 - keep running .\pocketbase.exe serve -# Terminal 2 +# Terminal 2 (dev bot) +set BOT_PROFILE=dev +python bot.py + +# Terminal 3 (economy bot) +set BOT_PROFILE=economy python bot.py ``` @@ -146,6 +161,8 @@ Admins (bot lacks permission to modify them) are silently skipped and still mark ## Admin Commands > These commands are hidden from users who don't have the required permission. Override per-role in **Server Settings → Integrations → Bot**. +> +> Profile note: `/check`, `/member`, and `/birthdays` are available only when `BOT_PROFILE=dev`. | Command | Permission | What it does | |---|---|---| @@ -154,16 +171,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 +245,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 +274,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 +336,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 +354,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 +363,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 +425,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 +438,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..f51fd6f 100644 --- a/bot.py +++ b/bot.py @@ -7,11 +7,7 @@ import json import logging import logging.handlers import math -import os -import random import re -import subprocess -import sys import time from pathlib import Path from zoneinfo import ZoneInfo @@ -27,15 +23,26 @@ import config import strings as S import economy import pb_client -import member_sync import sheets -from member_sync import SyncResult, sync_member, announce_birthday, is_birthday_today +from dev_member_commands import register_dev_member_commands +from dev_member_runtime import handle_member_join, run_birthday_daily +from economy_admin_commands import register_economy_admin_commands +from economy_extra_commands import register_economy_extra_commands +from economy_fish_commands import register_economy_fish_commands +from economy_games_commands import register_economy_games_commands +from economy_income_commands import register_economy_income_commands +from economy_prestige_commands import register_prestige_commands +from economy_profile_commands import register_economy_profile_commands +from economy_support_commands import register_economy_support_commands +from ops_channel_commands import register_ops_channel_commands +from ops_admin_commands import register_ops_admin_commands +from member_sync import SyncResult # --------------------------------------------------------------------------- # Logging # --------------------------------------------------------------------------- -_LOG_DIR = Path("logs") -_LOG_DIR.mkdir(exist_ok=True) +_LOG_DIR = Path("logs") / config.BOT_PROFILE +_LOG_DIR.mkdir(parents=True, exist_ok=True) _fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s") _txn_fmt = logging.Formatter("%(asctime)s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S") @@ -88,20 +95,35 @@ bot = discord.Client(intents=intents) tree = app_commands.CommandTree(bot) GUILD_OBJ = discord.Object(id=config.GUILD_ID) +IS_DEV_PROFILE = config.BOT_PROFILE == "dev" TALLINN_TZ = ZoneInfo("Europe/Tallinn") _start_time = datetime.datetime.now() _process = psutil.Process() -_DATA_DIR = Path("data") +_DATA_DIR = Path("data") / config.BOT_PROFILE +_DATA_DIR.mkdir(parents=True, exist_ok=True) _active_games: set[int] = set() # users with an in-progress interactive game -_active_heist: "HeistLobbyView | None" = None # server-wide singleton # heist global CD is persisted on the house record in PocketBase (see economy.get/set_heist_global_cd) _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" _PAUSED = False # maintenance mode: blocks non-admin commands when True +_DEV_ONLY_COMMANDS: tuple[str, ...] = ("birthdays", "check", "member") + + +def _apply_profile_command_filters() -> None: + """Remove commands that should not exist for the active bot profile.""" + if IS_DEV_PROFILE: + return + for name in _DEV_ONLY_COMMANDS: + removed = tree.remove_command(name) + if removed: + log.info("Profile '%s': removed dev-only command /%s", config.BOT_PROFILE, name) def _load_bot_config() -> dict: @@ -128,6 +150,19 @@ def _set_allowed_channels(channel_ids: list[int]) -> None: _save_bot_config(cfg) +def _get_paused() -> bool: + return _PAUSED + + +def _set_paused(value: bool) -> None: + global _PAUSED + _PAUSED = value + + +def _member_cache_size() -> int: + return len(sheets.get_cache()) + + # --------------------------------------------------------------------------- # EXP / Level role helpers # --------------------------------------------------------------------------- @@ -275,153 +310,20 @@ def _mark_announced_today(discord_id: int) -> None: _BDAY_LOG.write_text(json.dumps(log_data), encoding="utf-8") -# --------------------------------------------------------------------------- -# Birthday pages view -# --------------------------------------------------------------------------- -_MONTHS_ET = [ - "Jaanuar", "Veebruar", "Märts", "Aprill", "Mai", "Juuni", - "Juuli", "August", "September", "Oktoober", "November", "Detsember", -] - - -class BirthdayPages(discord.ui.View): - def __init__(self, pages: list[discord.Embed], start: int = 0): - super().__init__(timeout=120) - self.pages = pages - self.current = start - self._update_buttons() - - def _update_buttons(self): - self.prev_button.disabled = self.current == 0 - self.next_button.disabled = self.current >= len(self.pages) - 1 - - @discord.ui.button(label="◀", style=discord.ButtonStyle.secondary) - async def prev_button(self, interaction: discord.Interaction, button: discord.ui.Button): - self.current -= 1 - self._update_buttons() - await interaction.response.edit_message(embed=self.pages[self.current], view=self) - - @discord.ui.button(label="▶", style=discord.ButtonStyle.secondary) - async def next_button(self, interaction: discord.Interaction, button: discord.ui.Button): - self.current += 1 - self._update_buttons() - await interaction.response.edit_message(embed=self.pages[self.current], view=self) - - -def _build_birthday_pages( - guild: discord.Guild | None = None, -) -> tuple[list[discord.Embed], int]: - """Build 12 monthly embeds (one per calendar month). - - Returns (pages, start_index) where start_index is the current month. - """ - rows = sheets.get_cache() - today = datetime.date.today() - - # Group entries by month (1-12) - by_month: dict[int, list[tuple[int, str, int | None]]] = {m: [] for m in range(1, 13)} - - for row in rows: - name = str(row.get("Nimi", "")).strip() - bday_str = str(row.get("Sünnipäev", "")).strip() - if not name or not bday_str or bday_str.strip().lower() in ("-", "x", "n/a", "none", "ei"): - continue - bday = None - for fmt in ["%d/%m/%Y", "%Y-%m-%d", "%m-%d"]: - try: - bday = datetime.datetime.strptime(bday_str, fmt).date() - break - except ValueError: - continue - if bday is None: - continue - raw_uid = str(row.get("User ID", "")).strip() - try: - uid = int(raw_uid) if raw_uid and raw_uid not in ("0", "-") else None - except ValueError: - uid = None - by_month[bday.month].append((bday.day, name, uid)) - - pages: list[discord.Embed] = [] - for month in range(1, 13): - entries = sorted(by_month[month], key=lambda x: x[0]) - embed = discord.Embed( - title=f"🎂 {_MONTHS_ET[month - 1]}", - color=0xf4a261, - ) - if not entries: - embed.description = S.BIRTHDAY_UI["no_entries"] - else: - lines = [] - for day, name, uid in entries: - try: - this_year = datetime.date(today.year, month, day) - except ValueError: - this_year = datetime.date(today.year, month, day - 1) - next_bday = this_year if this_year >= today else this_year.replace(year=today.year + 1) - days_until = (next_bday - today).days - if days_until == 0: - when = S.BIRTHDAY_UI["today"] - elif days_until == 1: - when = S.BIRTHDAY_UI["tomorrow"] - else: - when = S.BIRTHDAY_UI["in_days"].format(days=days_until) - display = name - if guild and uid: - m = guild.get_member(uid) - if m: - display = m.mention - lines.append(f"{display} - {day:02d}/{month:02d} · {when}") - embed.description = "\n".join(lines) - embed.set_footer(text=S.BIRTHDAY_UI["footer"].format(month=month, month_name=_MONTHS_ET[month - 1])) - pages.append(embed) - - return pages, today.month - 1 # 0-indexed start on current month - - # --------------------------------------------------------------------------- # Daily 09:00 Tallinn-time birthday task # --------------------------------------------------------------------------- @tasks.loop(time=datetime.time(hour=9, minute=0, tzinfo=ZoneInfo("Europe/Tallinn"))) async def birthday_daily(): """Announce birthdays every day at 09:00 Tallinn time.""" - guild = bot.get_guild(config.GUILD_ID) - if guild is None: - log.warning("Birthday task: guild %s not found", config.GUILD_ID) + if not IS_DEV_PROFILE: return - - try: - data = sheets.refresh() - except Exception as e: - log.error("Birthday task: sheet refresh failed: %s", e) - data = sheets.get_cache() - - announced = 0 - for row in data: - bday_str = str(row.get("Sünnipäev", "")).strip() - if not is_birthday_today(bday_str): - continue - - member = None - raw_id = str(row.get("User ID", "")).strip() - if raw_id: - try: - member = guild.get_member(int(raw_id)) - except ValueError: - pass - if member is None: - discord_name = str(row.get("Discord", "")).strip() - if discord_name: - member = discord.utils.find( - lambda m, n=discord_name: m.name.lower() == n.lower(), - guild.members, - ) - if member and not _has_announced_today(member.id): - await announce_birthday(member, bot) - _mark_announced_today(member.id) - announced += 1 - - log.info("Sünnipäeva ülesanne: teavitati %d liiget", announced) + await run_birthday_daily( + bot, + log, + has_announced_today=_has_announced_today, + mark_announced_today=_mark_announced_today, + ) @birthday_daily.before_loop @@ -480,12 +382,15 @@ async def on_ready(): log.info("Logged in as %s (ID: %s)", bot.user, bot.user.id) economy.set_house(bot.user.id) + _apply_profile_command_filters() + # Pull sheet data into cache - try: - data = sheets.refresh() - log.info("Loaded %d member rows from Google Sheets", len(data)) - except Exception as e: - log.error("Failed to load sheet on startup: %s", e) + if IS_DEV_PROFILE: + try: + data = sheets.refresh() + log.info("Loaded %d member rows from Google Sheets", len(data)) + except Exception as e: + log.error("Failed to load sheet on startup: %s", e) # Sync slash commands to the guild only; wipe any leftover global registrations tree.copy_global_to(guild=GUILD_OBJ) @@ -495,7 +400,7 @@ async def on_ready(): log.info("Slash commands synced to guild %s (global commands cleared)", config.GUILD_ID) # Start daily birthday task - if not birthday_daily.is_running(): + if IS_DEV_PROFILE and not birthday_daily.is_running(): birthday_daily.start() log.info("Birthday daily task started (fires 09:00 Tallinn time)") @@ -533,71 +438,52 @@ async def on_resumed(): @bot.event async def on_member_join(member: discord.Member): """When someone joins, look them up in the sheet and sync.""" - log.info("Member joined: %s (ID: %s)", member, member.id) - - # Make sure cache is populated - if not sheets.get_cache(): - sheets.refresh() - - result = await sync_member(member, member.guild) - - if result.not_found: - try: - sheets.add_new_member_row(member.name, member.id) - log.info(" → %s not in sheet, added new row (Discord=%s, ID=%s)", - member, member.name, member.id) - except Exception as e: - log.error(" → Failed to add sheet row for %s: %s", member, e) + if not IS_DEV_PROFILE: return - - _log_sync_result(member, result) - sheets.set_synced(member.id, result.synced) - - # Sünnipäeva teavitus - if result.birthday_soon and not _has_announced_today(member.id): - await announce_birthday(member, bot) - _mark_announced_today(member.id) + await handle_member_join( + member, + bot, + log, + has_announced_today=_has_announced_today, + mark_announced_today=_mark_announced_today, + log_sync_result=_log_sync_result, + ) # --------------------------------------------------------------------------- # Slash commands # --------------------------------------------------------------------------- -def _sheet_stats(rows: list[dict]) -> str: - """Return a formatted string with sheet completeness statistics.""" - total = len(rows) - missing_uid = [] - missing_discord = [] - missing_birthday = [] +if IS_DEV_PROFILE: + register_dev_member_commands( + tree, + bot, + log, + has_announced_today=_has_announced_today, + mark_announced_today=_mark_announced_today, + ) - for row in rows: - name = str(row.get("Nimi", "")).strip() or "(no name)" - uid = str(row.get("User ID", "")).strip() - discord_name = str(row.get("Discord", "")).strip() - bday = str(row.get("Sünnipäev", "")).strip() +register_ops_admin_commands( + tree, + bot, + log, + process=_process, + start_time=_start_time, + log_dir=_LOG_DIR, + guild_obj=GUILD_OBJ, + restart_file=_RESTART_FILE, + get_member_cache_size=_member_cache_size, + get_paused=_get_paused, + set_paused=_set_paused, + count_economy_users=pb_client.count_records, +) - if not uid or uid == "0": - missing_uid.append(name) - if not discord_name: - missing_discord.append(name) - if not bday: - missing_birthday.append(name) - - lines = [S.CHECK_UI["sheet_stats_header"].format(total=total)] - lines.append("") - - def stat_line(label: str, missing: list[str]) -> str: - count = len(missing) - if count == 0: - return S.CHECK_UI["stat_ok"].format(label=label) - names = ", ".join(missing[:5]) - more = S.CHECK_UI["stat_more"].format(count=count - 5) if count > 5 else "" - return S.CHECK_UI["stat_warn"].format(label=label, count=count, names=names, more=more) - - lines.append(stat_line(S.CHECK_UI["stat_uid"], missing_uid)) - lines.append(stat_line(S.CHECK_UI["stat_discord"], missing_discord)) - lines.append(stat_line(S.CHECK_UI["stat_bday"], missing_birthday)) - - return "\n".join(lines) +register_ops_channel_commands( + tree, + bot, + log, + get_allowed_channels=_get_allowed_channels, + set_allowed_channels=_set_allowed_channels, +) @tree.command(name="ping", description=S.CMD["ping"]) @@ -609,12 +495,28 @@ async def cmd_ping(interaction: discord.Interaction): # /help # --------------------------------------------------------------------------- _HELP_PAGE_SIZE = 10 +_DEV_ONLY_HELP_TOKENS: tuple[str, ...] = tuple(f"/{name}" for name in _DEV_ONLY_COMMANDS) + + +def _visible_help_fields(category_key: str) -> list[tuple[str, str]]: + fields: list[tuple[str, str]] = list(S.HELP_CATEGORIES[category_key]["fields"]) + if IS_DEV_PROFILE: + return fields + + visible: list[tuple[str, str]] = [] + for name, value in fields: + blob = f"{name}\n{value}".lower() + if any(tok in blob for tok in _DEV_ONLY_HELP_TOKENS): + continue + visible.append((name, value)) + return visible def _help_embed(category_key: str, page: int = 0) -> discord.Embed: cat = S.HELP_CATEGORIES[category_key] - fields = cat["fields"] - total_pages = math.ceil(len(fields) / _HELP_PAGE_SIZE) + fields = _visible_help_fields(category_key) + total_pages = max(1, math.ceil(len(fields) / _HELP_PAGE_SIZE)) + page = max(0, min(page, total_pages - 1)) page_fields = fields[page * _HELP_PAGE_SIZE : (page + 1) * _HELP_PAGE_SIZE] title = cat["label"] if total_pages > 1: @@ -636,8 +538,9 @@ class HelpView(discord.ui.View): def _rebuild(self) -> None: self.clear_items() - fields = S.HELP_CATEGORIES[self.category]["fields"] - total_pages = math.ceil(len(fields) / _HELP_PAGE_SIZE) + fields = _visible_help_fields(self.category) + total_pages = max(1, math.ceil(len(fields) / _HELP_PAGE_SIZE)) + self.page = max(0, min(self.page, total_pages - 1)) select_row = 0 if total_pages > 1: select_row = 1 @@ -661,7 +564,7 @@ class HelpView(discord.ui.View): await interaction.response.edit_message(embed=_help_embed(self.category, self.page), view=self) async def _next(self, interaction: discord.Interaction) -> None: - total = math.ceil(len(S.HELP_CATEGORIES[self.category]["fields"]) / _HELP_PAGE_SIZE) + total = max(1, math.ceil(len(_visible_help_fields(self.category)) / _HELP_PAGE_SIZE)) self.page = min(total - 1, self.page + 1) self._rebuild() await interaction.response.edit_message(embed=_help_embed(self.category, self.page), view=self) @@ -696,456 +599,6 @@ async def cmd_help(interaction: discord.Interaction): ) -@tree.command(name="status", description=S.CMD["status"]) -@app_commands.guild_only() -@app_commands.default_permissions(manage_guild=True) -async def cmd_staatus(interaction: discord.Interaction): - proc = _process - mem = proc.memory_info() - cpu = proc.cpu_percent(interval=0.1) - uptime = datetime.datetime.now() - _start_time - h, rem = divmod(int(uptime.total_seconds()), 3600) - m, s = divmod(rem, 60) - tasks_count = len(asyncio.all_tasks()) - latency_ms = round(bot.latency * 1000, 1) - cache = sheets.get_cache() - - data = await economy.get_leaderboard(top_n=9999) - user_count = len(data) - - embed = discord.Embed(title="🖥️ Boti olek", color=0x57F287) - embed.add_field(name="🕐 Uptime", value=f"{h}t {m}m {s}s", inline=True) - embed.add_field(name="📡 Latency", value=f"{latency_ms} ms", inline=True) - embed.add_field(name="🧠 RAM (RSS)", value=f"{mem.rss / 1024**2:.1f} MB", inline=True) - embed.add_field(name="⚙️ CPU", value=f"{cpu:.1f}%", inline=True) - embed.add_field(name="🔄 Async tasks", value=str(tasks_count), inline=True) - embed.add_field(name="👤 Eco players", value=str(user_count), inline=True) - embed.add_field(name="📋 Liikmed (cache)", value=str(len(cache)), inline=True) - embed.add_field( - name="📂 Log files", - value="\n".join( - f"`{p.name}` - {p.stat().st_size / 1024:.1f} KB" - for p in sorted(_LOG_DIR.glob("*.log*")) - if p.is_file() - ) or "-", - inline=False, - ) - await interaction.response.send_message(embed=embed, ephemeral=True) - - -@tree.command(name="birthdays", description=S.CMD["birthdays"]) -@app_commands.guild_only() -async def cmd_birthdays(interaction: discord.Interaction): - await interaction.response.defer() - - try: - sheets.refresh() - except Exception as e: - await interaction.followup.send(S.ERR["sheet_error"].format(error=e), ephemeral=True) - return - - pages, start = _build_birthday_pages(guild=interaction.guild) - await interaction.followup.send(embed=pages[start], view=BirthdayPages(pages, start=start)) - - -@tree.command(name="check", description=S.CMD["check"]) -@app_commands.guild_only() -@app_commands.default_permissions(manage_roles=True) -async def cmd_check(interaction: discord.Interaction): - await interaction.response.defer(ephemeral=True) - - guild = interaction.guild - if guild is None: - await interaction.followup.send(S.ERR["guild_only"], ephemeral=True) - return - - # Load fresh sheet data - try: - data = sheets.refresh() - except Exception as e: - await interaction.followup.send(S.ERR["sheet_error"].format(error=e), ephemeral=True) - return - - # Backfill missing User IDs by matching Discord username - ids_filled = 0 - for row in data: - uid = str(row.get("User ID", "")).strip() - if uid and uid not in ("0", "-"): - continue - discord_name = str(row.get("Discord", "")).strip() - if not discord_name: - continue - gm = discord.utils.find( - lambda m, n=discord_name: m.name.lower() == n.lower(), - guild.members, - ) - if gm: - sheets.set_user_id(discord_name, gm.id) - ids_filled += 1 - - data = sheets.get_cache() - - changed_count = 0 - not_found = 0 - already_ok = 0 - errors_total = 0 - birthday_pings = 0 - details: list[str] = [] - sync_updates: list[tuple[int, bool]] = [] - - members = guild.members - for member in members: - if member.bot: - continue - - result = await sync_member(member, guild) - - if result.not_found: - not_found += 1 - continue - - sync_updates.append((member.id, result.synced)) - - if result.errors: - errors_total += len(result.errors) - for err in result.errors: - details.append(f"⚠️ {err}") - - if result.changed: - changed_count += 1 - parts = [] - if result.nickname_changed: - parts.append("hüüdnimi") - if result.roles_added: - parts.append(f"+rollid: {', '.join(result.roles_added)}") - details.append(f"🔧 **{member.display_name}**: {', '.join(parts)}") - else: - already_ok += 1 - - if result.birthday_soon and not _has_announced_today(member.id): - birthday_pings += 1 - await announce_birthday(member, bot) - _mark_announced_today(member.id) - - # Batch-write synced status (single API call instead of one per member) - if sync_updates: - try: - sheets.batch_set_synced(sync_updates) - except Exception as e: - log.error("/check batch_set_synced failed: %s", e) - - # Build summary - summary_lines = [ - S.CHECK_UI["done"], - S.CHECK_UI["already_ok"].format(count=already_ok), - S.CHECK_UI["fixed"].format(count=changed_count), - S.CHECK_UI["not_found"].format(count=not_found), - S.CHECK_UI["bday_pings"].format(count=birthday_pings), - ] - if errors_total: - summary_lines.append(S.CHECK_UI["errors"].format(count=errors_total)) - - summary = "\n".join(summary_lines) - - if details: - detail_text = "\n".join(details[:20]) # cap at 20 to avoid message limit - summary += f"\n\n{S.CHECK_UI['details_header']}\n{detail_text}" - if len(details) > 20: - summary += "\n" + S.CHECK_UI["details_more"].format(count=len(details) - 20) - - stats = _sheet_stats(data) - id_note = S.CHECK_UI["ids_filled"].format(count=ids_filled) if ids_filled else "" - summary = id_note + "\n" + summary + "\n\n" + stats - - await interaction.followup.send(summary.strip(), ephemeral=True) - log.info("/check - OK=%d, Fixed=%d, NotFound=%d, IDs=%d, Errors=%d", - already_ok, changed_count, not_found, ids_filled, errors_total) - - -@tree.command(name="sync", description=S.CMD["sync"]) -@app_commands.guild_only() -@app_commands.default_permissions(manage_guild=True) -async def cmd_sync(interaction: discord.Interaction): - await interaction.response.defer(ephemeral=True) - tree.copy_global_to(guild=GUILD_OBJ) - await tree.sync(guild=GUILD_OBJ) - tree.clear_commands(guild=None) - await tree.sync() - await interaction.followup.send(S.MSG_SYNC_DONE, ephemeral=True) - log.info("/sync triggered by %s", interaction.user) - - -@tree.command(name="adminseason", description=S.CMD["adminseason"]) -@app_commands.guild_only() -@app_commands.default_permissions(manage_guild=True) -@app_commands.describe(top_n=S.OPT["adminseason_top_n"]) -async def cmd_adminseason(interaction: discord.Interaction, top_n: int = 10): - await interaction.response.defer(ephemeral=True) - top = await economy.do_season_reset(top_n) - guild = interaction.guild - - # Strip all vanity roles from every guild member - if guild: - all_role_names = {name for _, name in economy.LEVEL_ROLES} - for role_name in all_role_names: - role = discord.utils.find(lambda r: r.name == role_name, guild.roles) - if not role: - continue - for m in list(role.members): - try: - await m.remove_roles(role, reason="Season reset") - except discord.Forbidden: - pass - - medals = ["\U0001f947", "\U0001f948", "\U0001f949"] - lines = [] - for i, (uid, exp, lvl) in enumerate(top): - member = guild.get_member(int(uid)) if guild else None - name = member.display_name if member else S.LEADERBOARD_UI["unknown_user"].format(uid=uid) - prefix = medals[i] if i < 3 else f"**{i + 1}.**" - lines.append(S.SEASON["entry"].format(prefix=prefix, name=name, exp=exp, level=lvl)) - - embed = discord.Embed( - title=S.TITLE["adminseason"], - description=(S.SEASON["top_header"] + "\n" + "\n".join(lines)) if lines else S.SEASON["no_players"], - color=0xF4C430, - ) - embed.set_footer(text=S.SEASON["footer"]) - await interaction.followup.send(embed=embed, ephemeral=False) - await interaction.followup.send(S.SEASON["done"], ephemeral=True) - log.info("/adminseason triggered by %s (top_n=%d)", interaction.user, top_n) - - -@tree.command(name="member", description=S.CMD["member"]) -@app_commands.guild_only() -@app_commands.default_permissions(manage_roles=True) -async def cmd_member(interaction: discord.Interaction, user: discord.Member): - row = sheets.find_member(user.id, user.name) - if row is None: - await interaction.response.send_message( - S.ERR["member_not_found"].format(name=user.display_name), - ephemeral=True, - ) - return - - embed = discord.Embed(title=S.MEMBER_UI["title"].format(name=user.display_name), color=user.color) - - # Age from birthday - bday_str = str(row.get("Sünnipäev", "")).strip() - if bday_str and bday_str.lower() not in ("-", "x", "n/a", "none", "ei"): - for fmt in ["%d/%m/%Y", "%Y-%m-%d"]: - try: - bday = datetime.datetime.strptime(bday_str, fmt).date() - if 1920 <= bday.year <= datetime.date.today().year: - today = datetime.date.today() - age = today.year - bday.year - ((today.month, today.day) < (bday.month, bday.day)) - embed.add_field(name=S.MEMBER_UI["age_field"], value=S.MEMBER_UI["age_val"].format(age=age), inline=True) - break - except ValueError: - continue - - for sheet_key, label in S.MEMBER_FIELDS: - val = str(row.get(sheet_key, "")).strip() - if val: - embed.add_field(name=label, value=val, inline=True) - - await interaction.response.send_message(embed=embed, ephemeral=True) - - -@tree.command(name="restart", description=S.CMD["restart"]) -@app_commands.guild_only() -@app_commands.default_permissions(manage_guild=True) -async def cmd_restart(interaction: discord.Interaction): - _RESTART_FILE.write_text( - json.dumps({"channel_id": interaction.channel_id}), encoding="utf-8" - ) - await interaction.response.send_message(S.MSG_RESTARTING, ephemeral=True) - log.info("/restart triggered by %s", interaction.user) - subprocess.Popen([sys.executable] + sys.argv, cwd=os.getcwd()) - await bot.close() - - -@tree.command(name="shutdown", description=S.CMD["shutdown"]) -@app_commands.guild_only() -@app_commands.default_permissions(manage_guild=True) -async def cmd_shutdown(interaction: discord.Interaction): - await interaction.response.send_message(S.MSG_SHUTTING_DOWN, ephemeral=True) - log.info("/shutdown triggered by %s", interaction.user) - await bot.close() - - -@tree.command(name="pause", description=S.CMD["pause"]) -@app_commands.guild_only() -@app_commands.default_permissions(manage_guild=True) -async def cmd_pause(interaction: discord.Interaction): - global _PAUSED - _PAUSED = not _PAUSED - msg = S.MSG_PAUSED if _PAUSED else S.MSG_UNPAUSED - log.info("/pause toggled → %s by %s", "PAUSED" if _PAUSED else "UNPAUSED", interaction.user) - await interaction.response.send_message(msg, ephemeral=True) - - -# --------------------------------------------------------------------------- -# Admin economy commands -# --------------------------------------------------------------------------- -async def _dm_user(user_id: int, msg: str) -> None: - """Best-effort DM to a user.""" - try: - user = bot.get_user(user_id) or await bot.fetch_user(user_id) - await user.send(msg) - except Exception: - pass - - -@tree.command(name="admincoins", description=S.CMD["admincoins"]) -@app_commands.guild_only() -@app_commands.describe( - kasutaja=S.OPT["admin_kasutaja"], - kogus=S.OPT["admincoins_kogus"], - põhjus=S.OPT["admin_põhjus"], -) -@app_commands.default_permissions(manage_guild=True) -async def cmd_admincoins(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_coins(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["coins_done"].format(emoji=emoji, name=kasutaja.display_name, verb=verb, coin=economy.COIN, balance=f"{res['balance']:,}", reason=põhjus), - ephemeral=True, - ) - await _dm_user(kasutaja.id, - S.ADMIN["coins_dm"].format(emoji=emoji, verb=verb, coin=economy.COIN, reason=põhjus, balance=f"{res['balance']:,}") - ) - log.info("ADMINCOINS %s → %s (%s) by %s", verb, kasutaja, põhjus, interaction.user) - - -@tree.command(name="adminjail", description=S.CMD["adminjail"]) -@app_commands.guild_only() -@app_commands.describe( - kasutaja=S.OPT["admin_kasutaja"], - minutid=S.OPT["adminjail_minutid"], - põhjus=S.OPT["admin_põhjus"], -) -@app_commands.default_permissions(manage_guild=True) -async def cmd_adminjail(interaction: discord.Interaction, kasutaja: discord.Member, minutid: int, põhjus: str): - if minutid <= 0: - await interaction.response.send_message(S.ERR["positive_duration"], ephemeral=True) - return - res = await economy.do_admin_jail(kasutaja.id, minutid, interaction.user.id, põhjus) - until_ts = _cd_ts(datetime.timedelta(minutes=minutid)) - await interaction.response.send_message( - S.ADMIN["jail_done"].format(name=kasutaja.display_name, minutes=minutid, ts=until_ts, reason=põhjus), - ephemeral=True, - ) - await _dm_user(kasutaja.id, - S.ADMIN["jail_dm"].format(minutes=minutid, reason=põhjus, ts=until_ts) - ) - log.info("ADMINJAIL %s %dmin (%s) by %s", kasutaja, minutid, põhjus, interaction.user) - - -@tree.command(name="adminunjail", description=S.CMD["adminunjail"]) -@app_commands.guild_only() -@app_commands.describe(kasutaja=S.OPT["admin_kasutaja"]) -@app_commands.default_permissions(manage_guild=True) -async def cmd_adminunjail(interaction: discord.Interaction, kasutaja: discord.Member): - await economy.do_admin_unjail(kasutaja.id, interaction.user.id) - await interaction.response.send_message( - S.ADMIN["unjail_done"].format(name=kasutaja.display_name), ephemeral=True - ) - await _dm_user(kasutaja.id, S.ADMIN["unjail_dm"]) - log.info("ADMINUNJAIL %s by %s", kasutaja, interaction.user) - - -@tree.command(name="adminban", description=S.CMD["adminban"]) -@app_commands.guild_only() -@app_commands.describe( - kasutaja=S.OPT["admin_kasutaja"], - põhjus=S.OPT["admin_põhjus"], -) -@app_commands.default_permissions(manage_guild=True) -async def cmd_adminban(interaction: discord.Interaction, kasutaja: discord.Member, põhjus: str): - if bot.user and kasutaja.id == bot.user.id: - await interaction.response.send_message(S.ERR["admin_ban_bot"], ephemeral=True) - return - await economy.do_admin_ban(kasutaja.id, interaction.user.id, põhjus) - await interaction.response.send_message( - S.ADMIN["ban_done"].format(name=kasutaja.display_name, reason=põhjus), - ephemeral=True, - ) - await _dm_user(kasutaja.id, - S.ADMIN["ban_dm"].format(reason=põhjus) - ) - log.info("ADMINBAN %s (%s) by %s", kasutaja, põhjus, interaction.user) - - -@tree.command(name="adminunban", description=S.CMD["adminunban"]) -@app_commands.guild_only() -@app_commands.describe(kasutaja=S.OPT["admin_kasutaja"]) -@app_commands.default_permissions(manage_guild=True) -async def cmd_adminunban(interaction: discord.Interaction, kasutaja: discord.Member): - await economy.do_admin_unban(kasutaja.id, interaction.user.id) - await interaction.response.send_message( - S.ADMIN["unban_done"].format(name=kasutaja.display_name), ephemeral=True - ) - await _dm_user(kasutaja.id, S.ADMIN["unban_dm"]) - log.info("ADMINUNBAN %s by %s", kasutaja, interaction.user) - - -@tree.command(name="adminreset", description=S.CMD["adminreset"]) -@app_commands.guild_only() -@app_commands.describe( - kasutaja=S.OPT["admin_kasutaja"], - põhjus=S.OPT["admin_põhjus"], -) -@app_commands.default_permissions(manage_guild=True) -async def cmd_adminreset(interaction: discord.Interaction, kasutaja: discord.Member, põhjus: str): - if bot.user and kasutaja.id == bot.user.id: - await interaction.response.send_message(S.ERR["admin_reset_bot"], ephemeral=True) - return - await economy.do_admin_reset(kasutaja.id, interaction.user.id) - await interaction.response.send_message( - S.ADMIN["reset_done"].format(name=kasutaja.display_name, reason=põhjus), - ephemeral=True, - ) - await _dm_user(kasutaja.id, - S.ADMIN["reset_dm"].format(reason=põhjus) - ) - log.info("ADMINRESET %s (%s) by %s", kasutaja, põhjus, interaction.user) - - -@tree.command(name="adminview", description=S.CMD["adminview"]) -@app_commands.guild_only() -@app_commands.describe(kasutaja=S.OPT["admin_kasutaja"]) -@app_commands.default_permissions(manage_guild=True) -async def cmd_adminview(interaction: discord.Interaction, kasutaja: discord.Member): - res = await economy.do_admin_inspect(kasutaja.id) - d = res["data"] - items_str = ", ".join(d.get("items", [])) or "-" - uses = d.get("item_uses", {}) - 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"] - 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_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_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.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) - - # --------------------------------------------------------------------------- # TipiBOT economy commands # --------------------------------------------------------------------------- @@ -1160,6 +613,26 @@ def _cd_ts(remaining: datetime.timedelta) -> str: return f"" +register_economy_admin_commands( + tree, + bot, + log, + cd_ts=_cd_ts, + apply_level_role=_apply_level_role, +) + + +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 @@ -1176,6 +649,13 @@ async def _check_cmd_rate(interaction: discord.Interaction) -> bool: return False +register_prestige_commands( + tree, + check_cmd_rate=_check_cmd_rate, + ensure_level_role=_ensure_level_role, +) + + def _parse_amount(value: str, balance: int) -> tuple[int | None, str | None]: """Parse an amount string; 'all' resolves to the user's full balance. Accepts plain integers and valid thousand-separated numbers (1,000 / 1.000 / 1 000). @@ -1199,6 +679,12 @@ def _parse_amount(value: str, balance: int) -> tuple[int | None, str | None]: _reminder_tasks: dict[tuple[int, str], asyncio.Task] = {} +def _cancel_reminder_task(user_id: int, cmd: str) -> None: + task = _reminder_tasks.pop((user_id, cmd), None) + if task and not task.done(): + task.cancel() + + def _schedule_reminder(user_id: int, cmd: str, delay: datetime.timedelta) -> None: """DM the user when their cooldown expires. Replaces any existing task.""" async def _remind(): @@ -1231,6 +717,7 @@ _REMINDER_COOLDOWN_KEYS: dict[str, str] = { "beg": "last_beg", "crime": "last_crime", "rob": "last_rob", + "fish": "last_fish", } @@ -1257,6 +744,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,2461 +773,66 @@ 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) -def _balance_embed(user: discord.User | discord.Member, data: dict) -> discord.Embed: - embed = discord.Embed( - title=f"{economy.COIN} {user.display_name}", - color=0xF4C430, - ) - embed.add_field(name=S.BALANCE_UI["saldo"], value=_coin(data["balance"]), inline=True) - streak = data.get("daily_streak", 0) - if streak: - embed.add_field(name=S.BALANCE_UI["streak"], value=S.BALANCE_UI["streak_val"].format(streak=streak), inline=True) - # Jail status - jail_remaining = economy._is_jailed(data) - if jail_remaining: - embed.add_field(name=S.BALANCE_UI["jailed_until"], value=_cd_ts(jail_remaining), inline=True) - # Items with uses info - item_lines = [] - uses_map = data.get("item_uses", {}) - 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) - if item_lines: - embed.add_field(name=S.BALANCE_UI["items"], value="\n".join(item_lines), inline=False) - return embed - - -@tree.command(name="balance", description=S.CMD["balance"]) -async def cmd_balance(interaction: discord.Interaction, kasutaja: discord.Member | None = None): - target = kasutaja or interaction.user - data = await economy.get_user(target.id) - await interaction.response.send_message(embed=_balance_embed(target, data)) - - -@tree.command(name="cooldowns", description=S.CMD["cooldowns"]) -async def cmd_cooldowns(interaction: discord.Interaction): - data = await economy.get_user(interaction.user.id) - now = datetime.datetime.now(datetime.timezone.utc) - items = set(data.get("items", [])) - - def _status(last_key: str, cd: datetime.timedelta) -> str: - raw = data.get(last_key) - if not raw: - return S.COOLDOWNS_UI["ready"] - last = economy._parse_dt(raw) - if last is None: - return S.COOLDOWNS_UI["ready"] - expires = last + cd - if expires <= now: - return S.COOLDOWNS_UI["ready"] - ts = int(expires.timestamp()) - return f"⏳ " - - 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"] - - lines = [ - S.COOLDOWNS_UI["daily_line"].format(status=_status("last_daily", daily_cd), note=S.COOLDOWNS_UI["note_korvak"] if "korvaklapid" in items else ""), - S.COOLDOWNS_UI["work_line"].format(status=_status("last_work", work_cd), note=S.COOLDOWNS_UI["note_monitor"] if "monitor" in items else ""), - 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"])), - ] - - jailed = data.get("jailed_until") - if jailed: - jail_dt = datetime.datetime.fromisoformat(jailed) - if jail_dt > now: - ts = int(jail_dt.timestamp()) - lines.append(S.COOLDOWNS_UI["jailed"].format(ts=ts)) - else: - lines.append(S.COOLDOWNS_UI["jail_expired"]) - - embed = discord.Embed( - title=S.TITLE["cooldowns"], - description="\n".join(lines), - color=0x5865F2, - ) - await interaction.response.send_message(embed=embed, ephemeral=True) - - -@tree.command(name="jailed", description=S.CMD["jailed"]) -@app_commands.guild_only() -async def cmd_jailed(interaction: discord.Interaction): - await interaction.response.defer() - jailed = await economy.do_get_jailed() - if not jailed: - embed = discord.Embed( - title=S.JAILED_UI["title"], - description=S.JAILED_UI["empty"], - color=0x57F287, - ) - await interaction.followup.send(embed=embed) - return - - now = datetime.datetime.now(datetime.timezone.utc) - lines = [] - for uid, remaining in jailed: - ts = int((now + remaining).timestamp()) - member = interaction.guild.get_member(uid) if interaction.guild else None - mention = member.mention if member else f"<@{uid}>" - lines.append(S.JAILED_UI["entry"].format(mention=mention, ts=ts)) - - plural = "" if len(jailed) == 1 else "i" - embed = discord.Embed( - title=S.JAILED_UI["title"], - description="\n".join(lines), - color=0xED4245, - ) - embed.set_footer(text=S.JAILED_UI["footer"].format(count=len(jailed), plural=plural)) - await interaction.followup.send(embed=embed) - - -@tree.command(name="rank", description=S.CMD["rank"]) -@app_commands.describe(kasutaja=S.OPT["rank_kasutaja"]) -async def cmd_rank(interaction: discord.Interaction, kasutaja: discord.Member | None = None): - target = kasutaja or interaction.user - data = await economy.get_user(target.id) - 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.RANK_UI["title"].format(name=target.display_name, level=level), - color=0x5865F2, - ) - embed.add_field(name=S.RANK_UI["field_title"], value=f"**{role_name}**", inline=True) - embed.add_field(name=S.RANK_UI["field_exp"], value=str(exp), inline=True) - embed.add_field( - name=S.RANK_UI["field_progress"].format(next=next_level), - value=S.RANK_UI["progress_val"].format(bar=bar, progress=progress, needed=needed), - inline=False, - ) - if level < 10: - embed.set_footer(text=S.RANK_UI["footer_t1"]) - elif level < 20: - embed.set_footer(text=S.RANK_UI["footer_t2"]) - else: - embed.set_footer(text=S.RANK_UI["footer_t3"]) - await interaction.response.send_message(embed=embed, ephemeral=True) - if not kasutaja and interaction.guild: - member = interaction.guild.get_member(target.id) - if member: - asyncio.create_task(_ensure_level_role(member, level)) - - -@tree.command(name="stats", description=S.CMD["stats"]) -@app_commands.describe(kasutaja=S.OPT["stats_kasutaja"]) -async def cmd_stats(interaction: discord.Interaction, kasutaja: discord.Member | None = None): - target = kasutaja or interaction.user - d = await economy.get_user(target.id) - - def _s(key: str) -> int: - return d.get(key, 0) - - embed = discord.Embed( - title=f"{S.TITLE['stats']} - {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, - ) - await interaction.response.send_message(embed=embed, ephemeral=True) - - -@tree.command(name="daily", description=S.CMD["daily"]) -async def cmd_daily(interaction: discord.Interaction): - res = await economy.do_daily(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["daily"].format(ts=_cd_ts(res["remaining"])), ephemeral=True - ) - return - - streak = res["streak"] - streak_str = f"🔥 {streak}p" + ( - " (+200%)" if res["streak_mult"] >= 3.0 else - " (+100%)" if res["streak_mult"] >= 2.0 else - " (+50%)" if res["streak_mult"] >= 1.5 else "" - ) - lines = [S.DAILY_UI["earned"].format(earned=_coin(res["earned"]))] - if res["interest"]: - lines.append(S.DAILY_UI["interest"].format(interest=_coin(res["interest"]))) - if res["vip"]: - lines.append(S.DAILY_UI["vip"]) - lines.append(S.DAILY_UI["footer"].format(streak_str=streak_str, balance=_coin(res["balance"]))) - - embed = discord.Embed(title=S.TITLE["daily"], description="\n".join(lines), color=0xF4C430) - await interaction.response.send_message(embed=embed) - asyncio.create_task(_maybe_remind(interaction.user.id, "daily")) - asyncio.create_task(_award_exp(interaction, economy.EXP_REWARDS["daily"])) - - -@tree.command(name="work", description=S.CMD["work"]) -async def cmd_work(interaction: discord.Interaction): - if await _check_cmd_rate(interaction): - return - res = await economy.do_work(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["work"].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 - - desc = S.WORK_UI["desc"].format(job=res["job"], earned=_coin(res["earned"])) - if res["lucky"]: - desc += S.WORK_UI["redbull"] - if res["hiir"]: - desc += S.WORK_UI["hiir"] - if res["laud"]: - desc += S.WORK_UI["laud"] - desc += S.WORK_UI["balance"].format(balance=_coin(res["balance"])) - embed = discord.Embed(title=S.TITLE["work"], description=desc, color=0x57F287) - await interaction.response.send_message(embed=embed) - asyncio.create_task(_maybe_remind(interaction.user.id, "work")) - asyncio.create_task(_award_exp(interaction, economy.EXP_REWARDS["work"])) - - -@tree.command(name="beg", description=S.CMD["beg"]) -async def cmd_beg(interaction: discord.Interaction): - if await _check_cmd_rate(interaction): - return - res = await economy.do_beg(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["beg"].format(ts=_cd_ts(res["remaining"])), ephemeral=True - ) - return - - if res["jailed"]: - title = "🔒 " + S.TITLE["beg"] - color = 0xE67E22 - else: - title = S.TITLE["beg"] - color = 0x99AAB5 - beg_lines = [S.BEG_UI["desc"].format(text=res["text"], earned=_coin(res["earned"]))] - if res["klaviatuur"]: - beg_lines.append(S.BEG_UI["klaviatuur"]) - beg_lines.append(S.BEG_UI["balance"].format(balance=_coin(res["balance"]))) - embed = discord.Embed(title=title, description="\n".join(beg_lines), color=color) - await interaction.response.send_message(embed=embed) - asyncio.create_task(_maybe_remind(interaction.user.id, "beg")) - asyncio.create_task(_award_exp(interaction, economy.EXP_REWARDS["beg"])) - - -@tree.command(name="crime", description=S.CMD["crime"]) -async def cmd_crime(interaction: discord.Interaction): - if await _check_cmd_rate(interaction): - return - res = await economy.do_crime(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["crime"].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 - - if res["success"]: - crime_lines = [S.CRIME_UI["win_desc"].format(text=res["text"], earned=_coin(res["earned"]))] - if res["mikrofon"]: - crime_lines.append(S.CRIME_UI["mikrofon"].lstrip("\n")) - crime_lines.append(S.CRIME_UI["balance"].lstrip("\n").format(balance=_coin(res["balance"]))) - embed = discord.Embed( - title=S.TITLE["crime_win"], - description="\n".join(crime_lines), - color=0x57F287, - ) - else: - jail_part = S.CRIME_UI["fail_jailed"].format(ts=_cd_ts(economy.JAIL_DURATION)) if res.get("jailed") else S.CRIME_UI["fail_shield"] - embed = discord.Embed( - title=S.TITLE["crime_fail"], - description=S.CRIME_UI["fail_base"].format(text=res["text"], fine=_coin(res["fine"])) + jail_part + S.CRIME_UI["balance"].format(balance=_coin(res["balance"])), - color=0xED4245, - ) - await interaction.response.send_message(embed=embed) - asyncio.create_task(_maybe_remind(interaction.user.id, "crime")) - if res["success"]: - asyncio.create_task(_award_exp(interaction, economy.EXP_REWARDS["crime_win"])) - - -@tree.command(name="rob", description=S.CMD["rob"]) -async def cmd_rob(interaction: discord.Interaction, sihtmärk: discord.Member): - if await _check_cmd_rate(interaction): - return - if sihtmärk.id == interaction.user.id: - await interaction.response.send_message(S.ERR["rob_self"], ephemeral=True) - return - if sihtmärk.bot and (bot.user is None or sihtmärk.id != bot.user.id): - await interaction.response.send_message(S.ERR["rob_bot"], ephemeral=True) - return - if bot.user and sihtmärk.id == bot.user.id: - await interaction.response.send_message(S.ERR["rob_house_blocked"], ephemeral=True) - return - - res = await economy.do_rob(interaction.user.id, sihtmärk.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["rob"].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 - ) - elif res["reason"] == "broke": - await interaction.response.send_message( - S.ERR["rob_too_poor"].format(name=sihtmärk.display_name), ephemeral=True - ) - elif res["reason"] == "target_jailed": - await interaction.response.send_message( - S.ERR["rob_target_jailed"].format(name=sihtmärk.display_name), ephemeral=True - ) - return - - if res["success"]: - if res.get("jackpot"): - desc = S.ROB_UI["jackpot_desc"].format(stolen=_coin(res["stolen"]), balance=_coin(res["balance"])) - color = 0xF4C430 - else: - desc = S.ROB_UI["win_desc"].format(stolen=_coin(res["stolen"]), name=sihtmärk.display_name, balance=_coin(res["balance"])) - color = 0x57F287 - embed = discord.Embed(title=S.TITLE["rob_win"], description=desc, color=color) - elif res["reason"] == "valvur": - embed = discord.Embed( - title=S.TITLE["rob_anticheat"], - description=S.ROB_UI["anticheat_desc"].format(name=sihtmärk.display_name, fine=_coin(res["fine"])), - color=0xED4245, - ) - # Notify target if anticheat fully depleted - target_data = await economy.get_user(sihtmärk.id) - if "anticheat" not in target_data.get("items", []): - try: - await sihtmärk.send(S.ROB_UI["anticheat_worn"]) - except discord.Forbidden: - pass - else: - embed = discord.Embed( - title=S.TITLE["rob_fail"], - description=S.ROB_UI["fail_desc"].format(fine=_coin(res["fine"]), balance=_coin(res["balance"])), - color=0xED4245, - ) - await interaction.response.send_message(embed=embed) - asyncio.create_task(_maybe_remind(interaction.user.id, "rob")) - if res["success"]: - asyncio.create_task(_award_exp(interaction, economy.EXP_REWARDS["rob_win"])) - - -# --------------------------------------------------------------------------- -# /heist - multiplayer group robbery of the house -# --------------------------------------------------------------------------- -_HEIST_JOIN_WINDOW = 300 # seconds players have to join -_HEIST_MIN_PLAYERS = 2 -_HEIST_GLOBAL_CD = 14400 # seconds between heist events server-wide (4h) -_HEIST_MAX_PLAYERS = 8 -_HEIST_BASE_CHANCE = 0.35 # 35% solo -_HEIST_CHANCE_STEP = 0.05 # +5% per extra player -_HEIST_MAX_CHANCE = 0.65 # cap at 65% - - -def _build_heist_story(participants: list[discord.Member], success: bool) -> list[str]: - """Return a list of story lines for the heist narrative reveal.""" - story = S.HEIST_STORY - leader = participants[0].display_name - member = participants[1].display_name if len(participants) > 1 else participants[0].display_name - if len(participants) == 1: - names = f"**{leader}**" - elif len(participants) == 2: - names = S.HEIST_UI["names_duo"].format(a=participants[0].display_name, b=participants[1].display_name) - elif len(participants) <= 4: - names = S.HEIST_UI["names_sep"].join(f"**{p.display_name}**" for p in participants) - else: - names = S.HEIST_UI["names_crew"].format(leader=participants[0].display_name) - - vehicle = random.choice(story["vehicles"]) - approach = random.choice(["sneaky", "loud"]) - non_leaders = participants[1:] if len(participants) > 1 else participants - - def fill(tmpl: str) -> str: - picked = random.choice(non_leaders).display_name - return tmpl.format( - leader=f"**{leader}**", member=f"**{picked}**", - names=names, vehicle=vehicle, - ) - - getaway_pool = "getaway_success" if success else "getaway_fail" - - return [ - fill(random.choice(story["arrival"])), - fill(random.choice(story[f"entry_{approach}"])), - fill(random.choice(story["inside"])), - fill(random.choice(story["vault"])), - fill(random.choice(story["vault_open"])), - fill(random.choice(story["police_inbound"])), - fill(random.choice(story[getaway_pool])), - fill(random.choice(story["escape_success" if success else "escape_fail"])), - ] - - -class HeistLobbyView(discord.ui.View): - def __init__(self, organizer: discord.Member): - super().__init__(timeout=_HEIST_JOIN_WINDOW) - self.organizer = organizer - self.participants: list[discord.Member] = [organizer] - self.message: discord.Message | None = None - self.resolved = False - - def _chance(self) -> float: - n = len(self.participants) - return min(_HEIST_BASE_CHANCE + _HEIST_CHANCE_STEP * (n - 1), _HEIST_MAX_CHANCE) - - def _lobby_embed(self) -> discord.Embed: - names = "\n".join(f"• {p.display_name}" for p in self.participants) - desc = S.HEIST_UI["lobby_desc"].format( - n=len(self.participants), max=_HEIST_MAX_PLAYERS, - names=names, chance=int(self._chance() * 100), - ts=int(self._timeout_expiry()), - ) - return discord.Embed(title=S.TITLE["heist_lobby"], description=desc, color=0xE67E22) - - def _timeout_expiry(self) -> float: - import time - return time.time() + (self.timeout or 0) - - @discord.ui.button(label=S.HEIST_UI["btn_join"], style=discord.ButtonStyle.danger) - async def join(self, interaction: discord.Interaction, _: discord.ui.Button): - if any(p.id == interaction.user.id for p in self.participants): - await interaction.response.send_message(S.HEIST_UI["already_joined"], ephemeral=True) - return - if len(self.participants) >= _HEIST_MAX_PLAYERS: - await interaction.response.send_message(S.ERR["heist_full"], ephemeral=True) - return - if interaction.user.id in _active_games: - await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) - return - res = await economy.do_heist_check(interaction.user.id) - if not res["ok"]: - if res["reason"] == "banned": - await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) - elif res["reason"] == "jailed": - await interaction.response.send_message( - S.CD_MSG["jailed"].format(ts=_cd_ts(res["remaining"])), ephemeral=True - ) - else: - await interaction.response.send_message( - S.CD_MSG["heist"].format(ts=_cd_ts(res["remaining"])), ephemeral=True - ) - return - self.participants.append(interaction.user) - _active_games.add(interaction.user.id) - await interaction.response.edit_message(embed=self._lobby_embed()) - - @discord.ui.button(label=S.HEIST_UI["btn_start"], style=discord.ButtonStyle.success) - async def start_now(self, interaction: discord.Interaction, _: discord.ui.Button): - if interaction.user.id != self.organizer.id: - await interaction.response.send_message(S.HEIST_UI["only_organizer"], ephemeral=True) - return - if len(self.participants) < _HEIST_MIN_PLAYERS: - await interaction.response.send_message( - S.ERR["heist_min_players"].format(min=_HEIST_MIN_PLAYERS), ephemeral=True - ) - return - await self._resolve(interaction) - - async def _resolve(self, interaction: discord.Interaction | None = None) -> None: - global _active_heist - if self.resolved: - return - self.resolved = True - _active_heist = None - self.stop() - self.clear_items() - - for p in self.participants: - _active_games.discard(p.id) - - n = len(self.participants) - channel = (interaction.channel if interaction - else self.message.channel if self.message else None) - - if n < _HEIST_MIN_PLAYERS: - embed = discord.Embed( - title=S.TITLE["heist_cancel"], - description=S.HEIST_UI["cancel_desc"].format(min=_HEIST_MIN_PLAYERS), - color=0x99AAB5, - ) - if interaction and not interaction.response.is_done(): - await interaction.response.edit_message(embed=embed, view=self) - elif self.message: - try: - await self.message.edit(embed=embed, view=self) - except discord.HTTPException: - pass - return - - # Pre-roll outcome so story ending matches result - success = random.random() < self._chance() - story_lines = _build_heist_story(self.participants, success) - - # Close lobby message - remove buttons, mark as started - lobby_done = discord.Embed( - title=S.HEIST_UI["started_title"], - description=S.HEIST_UI["started_desc"].format(n=n), - color=0x99AAB5, - ) - if interaction and not interaction.response.is_done(): - await interaction.response.edit_message(embed=lobby_done, view=self) - elif self.message: - try: - await self.message.edit(embed=lobby_done, view=self) - except discord.HTTPException: - pass - - # Send story message and reveal line by line - if channel: - story_embed = discord.Embed(title=S.HEIST_UI["story_title"], description="", color=0xE67E22) - story_msg = await channel.send(embed=story_embed) - accumulated = "" - for i, line in enumerate(story_lines): - await asyncio.sleep(random.uniform(3.0, 4.5)) - accumulated += ("\n\n" if i > 0 else "") + line - story_embed.description = accumulated - try: - await story_msg.edit(embed=story_embed) - except discord.HTTPException: - pass - await asyncio.sleep(2.0) - - # Apply economy changes - res = await economy.do_heist_resolve([p.id for p in self.participants], success) - payout_each = res["payout_each"] - names_str = "\n".join(f"• {p.display_name}" for p in self.participants) - guild = (interaction.guild if interaction - else self.message.guild if self.message else None) - - if success: - result_desc = S.HEIST_UI["win_desc"].format(names=names_str, payout=_coin(payout_each)) - result_embed = discord.Embed( - title=S.TITLE["heist_win"], description=result_desc, color=0x57F287 - ) - for p in self.participants: - exp_res = await economy.award_exp(p.id, economy.EXP_REWARDS["heist_win"]) - if exp_res["old_level"] != exp_res["new_level"] and guild: - gm = guild.get_member(p.id) - if gm: - asyncio.create_task(_ensure_level_role(gm, exp_res["new_level"])) - else: - result_desc = S.HEIST_UI["fail_desc"].format(names=names_str) - result_embed = discord.Embed( - title=S.TITLE["heist_fail"], description=result_desc, color=0xED4245 - ) - - # Set global server cooldown (persisted to PocketBase via house record) - await economy.set_heist_global_cd(time.time() + _HEIST_GLOBAL_CD) - - # Post result as a NEW message so it appears at the bottom of the channel - if channel: - await channel.send(embed=result_embed) - elif self.message: - try: - await self.message.channel.send(embed=result_embed) - except discord.HTTPException: - pass - - async def on_timeout(self) -> None: - await self._resolve() - - -@tree.command(name="heist", description=S.CMD["heist"]) -@app_commands.guild_only() -async def cmd_heist(interaction: discord.Interaction): - global _active_heist - if _active_heist is not None: - await interaction.response.send_message(S.ERR["heist_active"], ephemeral=True) - return - _heist_cd = await economy.get_heist_global_cd() - if time.time() < _heist_cd: - await interaction.response.send_message( - S.CD_MSG["heist_global"].format(ts=_cd_ts(datetime.timedelta(seconds=_heist_cd - time.time()))), - ephemeral=True, - ) - return - if interaction.user.id in _active_games: - await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) - return - res = await economy.do_heist_check(interaction.user.id) - if not res["ok"]: - if res["reason"] == "banned": - await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) - elif res["reason"] == "jailed": - await interaction.response.send_message( - S.CD_MSG["jailed"].format(ts=_cd_ts(res["remaining"])), ephemeral=True - ) - return - - view = HeistLobbyView(interaction.user) - _active_heist = view - _active_games.add(interaction.user.id) - await interaction.response.send_message(embed=view._lobby_embed(), view=view) - view.message = await interaction.original_response() - - -# --------------------------------------------------------------------------- -# /jailbreak - Monopoly-style dice escape -# --------------------------------------------------------------------------- -_DICE_EMOJI = [ - "<:TipiYKS:1483103190491856916>", - "<:TipiKAKS:1483103215841972404>", - "<:TipiKOLM:1483103217846980781>", - "<:TipiNELI:1483103237585240114>", - "<:TipiVIIS:1483103239036469289>", - "<:TipiKUUS:1483103253163020348>", -] - - -class JailbreakView(discord.ui.View): - MAX_TRIES = 3 - - def __init__(self, user_id: int): - super().__init__(timeout=120) - self.user_id = user_id - self.tries = 0 - self._die1: int | None = None - self._refresh() - - def _refresh(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.callback = self._on_roll - self.add_item(btn) - - async def _on_roll(self, interaction: discord.Interaction): - if interaction.user.id != self.user_id: - await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) - return - - if self._die1 is None: - self._die1 = random.randint(1, 6) - e1 = _DICE_EMOJI[self._die1 - 1] - self._refresh() - embed = discord.Embed( - title=S.TITLE["jailbreak"], - description=S.JAILBREAK_UI["die1_desc"].format(die=e1), - 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) - - -class BailView(discord.ui.View): - def __init__(self, user_id: int): - super().__init__(timeout=60) - self.user_id = user_id - - @discord.ui.button(label=S.JAILBREAK_UI["bail_btn"], style=discord.ButtonStyle.danger) - async def pay_bail(self, interaction: discord.Interaction, _: discord.ui.Button): - if interaction.user.id != self.user_id: - await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) - return - res = await economy.do_bail(self.user_id) - self.clear_items() - self.stop() - if not res["ok"] and res.get("reason") == "broke": - embed = discord.Embed( - title=S.TITLE["jailbreak_bail"], - description=S.JAILBREAK_UI["bail_broke_desc"].format(min=_coin(economy.MIN_BAIL), balance=_coin(res["balance"])), - color=0xED4245, - ) - else: - embed = discord.Embed( - title=S.TITLE["jailbreak_bail"], - description=S.JAILBREAK_UI["bail_paid_desc"].format(fine=_coin(res["fine"]), balance=_coin(res["balance"])), - color=0x57F287, - ) - await interaction.response.edit_message(embed=embed, view=self) - - -@tree.command(name="jailbreak", description=S.CMD["jailbreak"]) -async def cmd_jailbreak(interaction: discord.Interaction): - user_data = await economy.get_user(interaction.user.id) - remaining = economy._is_jailed(user_data) - if not remaining: - await interaction.response.send_message(S.ERR["not_jailed"], ephemeral=True) - return - - if user_data.get("jailbreak_used", False): - bal = user_data["balance"] - min_fine = max(economy.MIN_BAIL, int(bal * 0.20)) - max_fine = max(economy.MIN_BAIL, int(bal * 0.30)) - if bal >= economy.MIN_BAIL: - desc = S.JAILBREAK_UI["already_bail"].format(min=_coin(min_fine), max=_coin(max_fine), bal=_coin(bal), ts=_cd_ts(remaining)) - await interaction.response.send_message( - embed=discord.Embed(title=S.TITLE["jailbreak_bail"], description=desc, color=0xED4245), - view=BailView(interaction.user.id), ephemeral=True, - ) - else: - desc = S.JAILBREAK_UI["already_broke"].format(min=_coin(economy.MIN_BAIL), bal=_coin(bal), ts=_cd_ts(remaining)) - await interaction.response.send_message( - embed=discord.Embed(title=S.TITLE["jailbreak_bail"], description=desc, color=0xED4245), - ephemeral=True, - ) - return - - await economy.set_jailbreak_used(interaction.user.id) - embed = discord.Embed( - title=S.TITLE["jailbreak"], - description=S.JAILBREAK_UI["intro_desc"].format(ts=_cd_ts(remaining), tries=JailbreakView.MAX_TRIES), - color=0xF4C430, - ) - await interaction.response.send_message( - embed=embed, view=JailbreakView(interaction.user.id), ephemeral=True - ) - - -# --------------------------------------------------------------------------- -# /roulette animation -# --------------------------------------------------------------------------- -_ROULETTE_R = "\U0001f534" # 🔴 -_ROULETTE_B = "\u26ab" # ⚫ -_ROULETTE_G = "\U0001f7e2" # 🟢 -# delays between frames, fast → slow (12 transitions = 13 total viewport positions) -_ROULETTE_WHEEL_DELAYS = [0.15, 0.15, 0.18, 0.20, 0.22, 0.25, 0.28, 0.35, 0.45, 0.60, 0.80, 1.00] - - -def _build_roulette_strip(result_emoji: str) -> list[str]: - """Build a 17-symbol wheel strip obeying strict transition rules: - R → B or G (R can go to either) - B → R (B must go to R) - G → B (G must go to B) - Result is at strip[14] = center of the final viewport. - Prefix (0-13) is generated backward from the result; - suffix (15-16) is generated forward from the result. - Greens appear randomly in the prefix as near-miss elements (up to 2). - """ - R, B, G = _ROULETTE_R, _ROULETTE_B, _ROULETTE_G - strip: list[str] = [None] * 17 # type: ignore[list-item] - - # ── Suffix: positions 15-16 (deterministic, no greens after result) ── - strip[14] = result_emoji - strip[15] = R if result_emoji == B else B # B→R, R→B, G→B - strip[16] = B if strip[15] == R else R # R→B, B→R - - # ── Prefix: positions 0-13 built backward from result ── - # Inverse transitions: pred(R)=B, pred(B)=R or G, pred(G)=R - # First pass: collect positions where a green is valid (cur == B, with room for pred). - # Green is only relevant when result is not green itself. - green_pos: int | None = None - if result_emoji != G: - candidates: list[int] = [] - cur = result_emoji - for i in range(13, -1, -1): - if cur == B and 2 <= i <= 11: - candidates.append(i) - cur = B if cur == R else (R if cur == G else R) - if candidates: - green_pos = random.choice(candidates) - - # Second pass: generate strip, inserting green at the chosen position. - cur = result_emoji - for i in range(13, -1, -1): - if cur == R: - strip[i] = B - elif cur == G: - strip[i] = R - else: # cur == B - strip[i] = G if i == green_pos else R - cur = strip[i] - - return strip - - -def _roulette_frame_embed(symbols: list[str], stopped: bool = False) -> discord.Embed: - title = S.ROULETTE["spin_stop"] if stopped else S.ROULETTE["spin_title"] - desc = S.ROULETTE["spin_strip"].format( - s0=symbols[0], s1=symbols[1], s2=symbols[2], s3=symbols[3], s4=symbols[4] - ) - return discord.Embed(title=title, description=desc, color=0x99AAB5) - - -@tree.command(name="roulette", description=S.CMD["roulette"]) -@app_commands.describe(panus=S.OPT["roulette_panus"], värv=S.OPT["roulette_värv"]) -@app_commands.choices(värv=[ - app_commands.Choice(name="🔴 Punane", value="punane"), - app_commands.Choice(name="⚫ Must", value="must"), - app_commands.Choice(name="🟢 Roheline", value="roheline"), -]) -async def cmd_roulette(interaction: discord.Interaction, panus: str, värv: app_commands.Choice[str]): - _data = await economy.get_user(interaction.user.id) - panus_int, _err = _parse_amount(panus, _data["balance"]) - if _err: - await interaction.response.send_message(_err, ephemeral=True) - return - if panus_int <= 0: - await interaction.response.send_message(S.ERR["positive_bet"], ephemeral=True) - return - - if interaction.user.id in _active_games: - await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) - return - _active_games.add(interaction.user.id) - res = await economy.do_roulette(interaction.user.id, panus_int, värv.value) - if not res["ok"]: - _active_games.discard(interaction.user.id) - if res["reason"] == "banned": - await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) - elif res["reason"] == "jailed": - await interaction.response.send_message( - S.CD_MSG["jailed"].format(ts=_cd_ts(res["remaining"])), ephemeral=True - ) - else: - await interaction.response.send_message( - S.ERR["broke"].format(bal=_coin(_data["balance"])), ephemeral=True - ) - return - - # ── Spin animation ──────────────────────────────────────────────────── - result_emoji = S.ROULETTE["emoji"][res["result"]] - strip = _build_roulette_strip(result_emoji) - - try: - await interaction.response.send_message(embed=_roulette_frame_embed(strip[0:5])) - spin_msg = await interaction.original_response() - - for i, delay in enumerate(_ROULETTE_WHEEL_DELAYS, 1): - await asyncio.sleep(delay) - stopped = i == len(_ROULETTE_WHEEL_DELAYS) - await spin_msg.edit(embed=_roulette_frame_embed(strip[i:i + 5], stopped=stopped)) - - await asyncio.sleep(0.55) - - # ── Final result embed ──────────────────────────────────────────────── - emoji = S.ROULETTE["emoji"].get(res["result"], "🎰") - genitive = S.ROULETTE["genitive"].get(res["result"], res["result"]) - if res["won"]: - mult_str = f" · **{res['mult']}x**" if res["mult"] > 1 else "" - embed = discord.Embed( - title=S.ROULETTE["win_title"].format(emoji=emoji), - description=S.ROULETTE["win_desc"].format( - genitive=genitive, mult=mult_str, - change=_coin(res["change"]), balance=_coin(res["balance"]), - ), - color=0x57F287, - ) - asyncio.create_task(_award_exp(interaction, economy.gamble_exp(panus_int))) - else: - embed = discord.Embed( - title=S.ROULETTE["lose_title"].format(emoji=emoji), - description=S.ROULETTE["lose_desc"].format( - genitive=genitive, - change=_coin(abs(res["change"])), balance=_coin(res["balance"]), - ), - color=0xED4245, - ) - await spin_msg.edit(embed=embed) - finally: - _active_games.discard(interaction.user.id) - - -@tree.command(name="give", description=S.CMD["give"]) -@app_commands.describe(kasutaja=S.OPT["give_kasutaja"], summa=S.OPT["give_summa"]) -async def cmd_give(interaction: discord.Interaction, kasutaja: discord.Member, summa: str): - _data = await economy.get_user(interaction.user.id) - summa_int, _err = _parse_amount(summa, _data["balance"]) - if _err: - await interaction.response.send_message(_err, ephemeral=True) - return - if summa_int <= 0: - await interaction.response.send_message(S.ERR["positive_amount"], ephemeral=True) - return - if kasutaja.id == interaction.user.id: - await interaction.response.send_message(S.ERR["give_self"], ephemeral=True) - return - if kasutaja.bot: - await interaction.response.send_message(S.ERR["give_bot"], ephemeral=True) - return - - res = await economy.do_give(interaction.user.id, kasutaja.id, summa_int) - if not res["ok"]: - if res["reason"] == "banned": - await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) - elif res["reason"] == "jailed": - await interaction.response.send_message(S.ERR["give_jailed"].format(ts=_cd_ts(res["remaining"])), ephemeral=True) - else: - await interaction.response.send_message( - S.ERR["broke"].format(bal=_coin(_data["balance"])), ephemeral=True - ) - return - - embed = discord.Embed( - title=f"{economy.COIN} {S.TITLE['give']}", - description=S.GIVE_UI["desc"].format(giver=interaction.user.display_name, amount=_coin(summa_int), receiver=kasutaja.display_name), - color=0xF4C430, - ) - await interaction.response.send_message(embed=embed) - - -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, - ): - super().__init__(timeout=120) - self.regular = regular - self.house_entry = house_entry - 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._update_buttons() - - def _current_list(self): - return self.regular if self.mode == "coins" else self.exp_entries - - 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 - - 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) - lines = [] - - if self.mode == "coins" and self.page == 0 and self.house_entry: - _, bal = self.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("") - - start = self.page * self.PER_PAGE - medals = ["🥇", "🥈", "🥉"] - current = self._current_list() - slice_ = current[start:start + self.PER_PAGE] - if not slice_: - lines.append(S.LEADERBOARD_UI["no_entries"]) - else: - for i, entry in enumerate(slice_): - 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} ‹**" - if self.mode == "coins": - lines.append(f"{prefix} {name} - {_coin(entry[1])}") - else: - lines.append(S.LEADERBOARD_UI["exp_entry"].format(prefix=prefix, name=name, exp=entry[1], level=entry[2])) - - 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) - 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) - 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) - 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 - ) - return - await interaction.response.send_message( - S.ERR["not_in_leaderboard"], ephemeral=True - ) - - async def on_timeout(self): - for child in self.children: - child.disabled = True - - -@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) - - house_entry = None - regular = [] - for uid, bal in all_entries: - if bot.user and int(uid) == bot.user.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) - await interaction.followup.send(embed=view._make_embed(), view=view) - - -def _shop_embed(tier: int, user_data: dict) -> discord.Embed: - owned = set(user_data.get("items", [])) - item_uses = user_data.get("item_uses", {}) - _tier_names = {1: S.SHOP_UI["tier_1"], 2: S.SHOP_UI["tier_2"], 3: S.SHOP_UI["tier_3"]} - embed = discord.Embed( - title=f"{economy.COIN} TipiBOTi pood · {_tier_names[tier]}", - description=S.SHOP_UI["desc"].format(bal=_coin(user_data["balance"])), - color=[0x57F287, 0xF4C430, 0xED4245][tier - 1], - ) - for item_id in sorted(economy.SHOP_TIERS[tier], key=lambda k: economy.SHOP[k]["cost"]): - item = economy.SHOP[item_id] - anticheat_uses = item_uses.get("anticheat", 0) if item_id == "anticheat" else 0 - min_lvl = economy.SHOP_LEVEL_REQ.get(item_id, 0) - user_lvl = economy.get_level(user_data.get("exp", 0)) - if item_id in owned and not (item_id == "anticheat" and anticheat_uses <= 0): - if item_id == "anticheat": - _key = "owned_uses_1" if anticheat_uses == 1 else "owned_uses_n" - status = S.SHOP_UI[_key].format(uses=anticheat_uses) - else: - status = S.SHOP_UI["owned"] - elif min_lvl > 0 and user_lvl < min_lvl: - status = S.SHOP_UI["locked"].format(min_lvl=min_lvl, user_lvl=user_lvl) - else: - status = f"{item['cost']} {economy.COIN}" - embed.add_field( - name=f"{item['emoji']} {item['name']} · {status}", - value=item["description"], - inline=False, - ) - return embed - - -class ShopView(discord.ui.View): - def __init__(self, user_data: dict, tier: int = 1): - super().__init__(timeout=120) - self._user_data = user_data - self._tier = tier - self._update_buttons() - - def _update_buttons(self): - self.clear_items() - for t, label in [(1, S.SHOP_BTN[1]), (2, S.SHOP_BTN[2]), (3, S.SHOP_BTN[3])]: - btn = discord.ui.Button( - label=label, - style=discord.ButtonStyle.primary if t == self._tier else discord.ButtonStyle.secondary, - custom_id=f"shop_tier_{t}", - ) - btn.callback = self._make_callback(t) - self.add_item(btn) - - def _make_callback(self, tier: int): - async def callback(interaction: discord.Interaction): - self._tier = tier - self._update_buttons() - self._user_data = await economy.get_user(interaction.user.id) - await interaction.response.edit_message( - embed=_shop_embed(self._tier, self._user_data), view=self - ) - return callback - - -@tree.command(name="shop", description=S.CMD["shop"]) -async def cmd_shop(interaction: discord.Interaction): - data = await economy.get_user(interaction.user.id) - await interaction.response.send_message( - embed=_shop_embed(1, data), view=ShopView(data, tier=1), ephemeral=True - ) - - -@tree.command(name="buy", description=S.CMD["buy"]) -@app_commands.describe(ese=S.OPT["buy_ese"]) -@app_commands.choices(ese=[ - app_commands.Choice(name=f"{v['name']} ({v['cost']} TipiCOINi)", value=k) - for k, v in economy.SHOP.items() -]) -async def cmd_buy(interaction: discord.Interaction, ese: app_commands.Choice[str]): - res = await economy.do_buy(interaction.user.id, ese.value) - if not res["ok"]: - if res["reason"] == "banned": - await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) - elif res["reason"] == "owned": - await interaction.response.send_message(S.ERR["item_owned"], ephemeral=True) - elif res["reason"] == "level_required": - await interaction.response.send_message( - S.ERR["item_level_req"].format(min_level=res["min_level"], user_level=res["user_level"]), - ephemeral=True, - ) - elif res["reason"] == "insufficient": - await interaction.response.send_message( - S.ERR["broke_need"].format(need=_coin(res["need"])), ephemeral=True - ) - else: - await interaction.response.send_message(S.ERR["item_not_found"], ephemeral=True) - return - - item = res["item"] - embed = discord.Embed( - title=S.BUY_UI["title"].format(emoji=item["emoji"], name=item["name"]), - description=S.BUY_UI["desc"].format(description=item["description"], balance=_coin(res["balance"])), - color=0x57F287, - ) - await interaction.response.send_message(embed=embed) - - -# --------------------------------------------------------------------------- -# Rock Paper Scissors (vs Bot OR PvP) -# --------------------------------------------------------------------------- -_RPS_CHOICES = S.RPS_CHOICES -_RPS_BEATS = {"🪨": "✂️", "📄": "🪨", "✂️": "📄"} - - -# ── Single-player (vs bot) ────────────────────────────────────────────────── -class RPSView(discord.ui.View): - def __init__(self, challenger: discord.User, bet: int = 0): - super().__init__(timeout=60) - self.challenger = challenger - self.bet = bet - - async def _resolve(self, interaction: discord.Interaction, player_pick: str): - if interaction.user.id != self.challenger.id: - await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) - return - self.stop() - _active_games.discard(self.challenger.id) - bot_pick = random.choice(list(_RPS_CHOICES)) - p_name = _RPS_CHOICES[player_pick] - b_name = _RPS_CHOICES[bot_pick] - if player_pick == bot_pick: - outcome, result, color = "tie", S.RPS_UI["result_tie"], 0x99AAB5 - elif _RPS_BEATS[player_pick] == bot_pick: - outcome, result, color = "win", S.RPS_UI["result_win"], 0x57F287 - else: - outcome, result, color = "lose", S.RPS_UI["result_lose"], 0xED4245 - - bet_line = "" - if self.bet > 0: - res = await economy.do_game_bet(interaction.user.id, self.bet, outcome) - if outcome == "win": - bet_line = S.RPS_UI["bet_win"].format(amount=_coin(self.bet), balance=_coin(res["balance"])) - elif outcome == "lose": - bet_line = S.RPS_UI["bet_lose"].format(amount=_coin(self.bet), balance=_coin(res["balance"])) - else: - bet_line = S.RPS_UI["bet_tie"].format(balance=_coin(res["balance"])) - - embed = discord.Embed( - title=S.TITLE["rps"], - description=S.RPS_UI["result_desc"].format(player_pick=player_pick, player_name=p_name, bot_pick=bot_pick, bot_name=b_name, result=result, bet_line=bet_line), - color=color, - ) - await interaction.response.edit_message(embed=embed, view=None) - - @discord.ui.button(label=S.RPS_UI["btn_rock"], style=discord.ButtonStyle.secondary) - async def pick_rock(self, interaction: discord.Interaction, _: discord.ui.Button): - await self._resolve(interaction, "🪨") - - @discord.ui.button(label=S.RPS_UI["btn_paper"], style=discord.ButtonStyle.secondary) - async def pick_paper(self, interaction: discord.Interaction, _: discord.ui.Button): - await self._resolve(interaction, "📄") - - @discord.ui.button(label=S.RPS_UI["btn_scissors"], style=discord.ButtonStyle.secondary) - async def pick_scissors(self, interaction: discord.Interaction, _: discord.ui.Button): - await self._resolve(interaction, "✂️") - - async def on_timeout(self): - _active_games.discard(self.challenger.id) - for item in self.children: - item.disabled = True - - -# ── PvP ───────────────────────────────────────────────────────────────────── -class RpsGame: - """Shared mutable state for a PvP RPS match.""" - - def __init__(self, player_a: discord.Member, player_b: discord.Member, bet: int): - self.player_a = player_a - self.player_b = player_b - self.bet = bet - self.choice_a: str | None = None - self.choice_b: str | None = None - self.dm_msg_a: discord.Message | None = None - self.dm_msg_b: discord.Message | None = None - self.server_message: discord.Message | None = None - self._resolved = False - self._lock = asyncio.Lock() - - async def maybe_resolve(self) -> None: - async with self._lock: - if self._resolved or self.choice_a is None or self.choice_b is None: - return - self._resolved = True - - a, b = self.choice_a, self.choice_b - if a == b: - winner, color = None, 0x99AAB5 - result_a = result_b = S.RPS_UI["result_tie"] - elif _RPS_BEATS[a] == b: - winner, color = "a", 0x57F287 - result_a = S.RPS_UI["result_win"] - result_b = f"❌ {self.player_a.display_name} võitis." - else: - winner, color = "b", 0xED4245 - result_a = f"❌ {self.player_b.display_name} võitis." - result_b = S.RPS_UI["result_win"] - - bet_line_a = bet_line_b = "" - if self.bet > 0: - if winner == "a": - res = await economy.do_give(self.player_b.id, self.player_a.id, self.bet) - elif winner == "b": - res = await economy.do_give(self.player_a.id, self.player_b.id, self.bet) - else: - res = {"ok": True} - - if self.bet > 0 and winner is not None: - if res.get("ok"): - bet_line_a = f"\n{'+' if winner == 'a' else '-'}{_coin(self.bet)}" - bet_line_b = f"\n{'+' if winner == 'b' else '-'}{_coin(self.bet)}" - else: - bet_line_a = bet_line_b = S.RPS_UI["duel_broke"] - - data_a = await economy.get_user(self.player_a.id) - data_b = await economy.get_user(self.player_b.id) - bal_a, bal_b = data_a["balance"], data_b["balance"] - - if self.dm_msg_a: - await self.dm_msg_a.edit( - content=S.RPS_UI["duel_result_a"].format( - opponent=self.player_b.display_name, pick_a=a, name_a=_RPS_CHOICES[a], - pick_b=b, name_b=_RPS_CHOICES[b], result=result_a, bet_line=bet_line_a, balance=_coin(bal_a) - ), - view=None, - ) - if self.dm_msg_b: - await self.dm_msg_b.edit( - content=S.RPS_UI["duel_result_a"].format( - opponent=self.player_a.display_name, pick_a=b, name_a=_RPS_CHOICES[b], - pick_b=a, name_b=_RPS_CHOICES[a], result=result_b, bet_line=bet_line_b, balance=_coin(bal_b) - ), - view=None, - ) - - if self.server_message: - if winner == "a": - verdict = S.RPS_UI["duel_verdict_win"].format(name=self.player_a.display_name) - elif winner == "b": - verdict = S.RPS_UI["duel_verdict_win"].format(name=self.player_b.display_name) - else: - verdict = S.RPS_UI["duel_verdict_tie"] - embed = discord.Embed( - title=S.TITLE["rps_duel_done"], - description=S.RPS_UI["duel_done_desc"].format( - a=self.player_a.mention, pick_a=a, pick_b=b, b=self.player_b.mention, - verdict=verdict, name_a=self.player_a.display_name, bal_a=_coin(bal_a), - name_b=self.player_b.display_name, bal_b=_coin(bal_b) - ), - color=color, - ) - await self.server_message.edit(embed=embed, view=None) - _active_games.discard(self.player_a.id) - _active_games.discard(self.player_b.id) - - -class RpsDmView(discord.ui.View): - """DM view for each player to make their pick in a PvP match.""" - - def __init__(self, game: RpsGame, side: str): - super().__init__(timeout=120) - self.game = game - self.side = side - - async def _pick(self, interaction: discord.Interaction, choice: str) -> None: - if self.side == "a": - self.game.choice_a = choice - else: - self.game.choice_b = choice - for item in self.children: - item.disabled = True - self.stop() - await interaction.response.edit_message( - content=S.RPS_UI["duel_waiting"].format(choice=choice, name=_RPS_CHOICES[choice]), - view=self, - ) - await self.game.maybe_resolve() - - async def on_timeout(self) -> None: - async with self.game._lock: - if self.game._resolved: - return - self.game._resolved = True - _active_games.discard(self.game.player_a.id) - _active_games.discard(self.game.player_b.id) - for item in self.children: - item.disabled = True - for player in (self.game.player_a, self.game.player_b): - try: - await player.send(S.RPS_UI["duel_expire_dm"]) - except discord.Forbidden: - pass - if self.game.server_message: - embed = discord.Embed( - title=S.TITLE["rps_duel_expire"], - description=S.RPS_UI["duel_expire_desc"].format(a=self.game.player_a.mention, b=self.game.player_b.mention), - color=0x99AAB5, - ) - await self.game.server_message.edit(embed=embed, view=None) - - @discord.ui.button(label=S.RPS_UI["btn_rock"], style=discord.ButtonStyle.secondary) - async def pick_rock(self, interaction: discord.Interaction, _: discord.ui.Button): - await self._pick(interaction, "🪨") - - @discord.ui.button(label=S.RPS_UI["btn_paper"], style=discord.ButtonStyle.secondary) - async def pick_paper(self, interaction: discord.Interaction, _: discord.ui.Button): - await self._pick(interaction, "📄") - - @discord.ui.button(label=S.RPS_UI["btn_scissors"], style=discord.ButtonStyle.secondary) - async def pick_scissors(self, interaction: discord.Interaction, _: discord.ui.Button): - await self._pick(interaction, "✂️") - - -class RpsChallengeView(discord.ui.View): - """Server-side accept/decline view for PvP RPS challenge.""" - - def __init__(self, game: RpsGame): - super().__init__(timeout=60) - self.game = game - - def _disable_all(self) -> None: - for item in self.children: - item.disabled = True - - @discord.ui.button(label=S.RPS_UI["btn_accept"], style=discord.ButtonStyle.success) - async def accept(self, interaction: discord.Interaction, _: discord.ui.Button): - if interaction.user.id != self.game.player_b.id: - await interaction.response.send_message(S.ERR["not_your_challenge"], ephemeral=True) - return - if self.game.player_b.id in _active_games: - await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) - return - self.stop() - self._disable_all() - _active_games.add(self.game.player_b.id) - - if self.game.bet > 0: - data_a = await economy.get_user(self.game.player_a.id) - data_b = await economy.get_user(self.game.player_b.id) - for player, data in ((self.game.player_a, data_a), (self.game.player_b, data_b)): - if data["balance"] < self.game.bet: - embed = discord.Embed( - title=S.TITLE["rps_duel_cancel"], - description=S.RPS_UI["duel_insufficient"].format(mention=player.mention), - color=0xED4245, - ) - await interaction.response.edit_message(embed=embed, view=None) - async with self.game._lock: - self.game._resolved = True - _active_games.discard(self.game.player_a.id) - _active_games.discard(self.game.player_b.id) - return - - bet_str = S.RPS_UI["duel_active_bet"].format(bet=_coin(self.game.bet)) if self.game.bet > 0 else "" - embed = discord.Embed( - title=S.TITLE["rps_duel_active"], - description=S.RPS_UI["duel_active_desc"].format(a=self.game.player_a.mention, b=self.game.player_b.mention, bet=bet_str), - color=0x5865F2, - ) - await interaction.response.edit_message(embed=embed, view=self) - - bet_dm = S.RPS_UI["duel_dm_bet"].format(bet=_coin(self.game.bet)) if self.game.bet > 0 else "" - dm_failed: list[str] = [] - for player, side in ((self.game.player_a, "a"), (self.game.player_b, "b")): - view = RpsDmView(self.game, side) - opponent = self.game.player_b if side == "a" else self.game.player_a - try: - msg = await player.send( - S.RPS_UI["duel_dm"].format(opponent=opponent.display_name, bet=bet_dm), - view=view, - ) - if side == "a": - self.game.dm_msg_a = msg - else: - self.game.dm_msg_b = msg - except discord.Forbidden: - dm_failed.append(player.display_name) - - if dm_failed: - async with self.game._lock: - self.game._resolved = True - _active_games.discard(self.game.player_a.id) - _active_games.discard(self.game.player_b.id) - embed = discord.Embed( - title=S.TITLE["rps_duel_cancel"], - description=S.RPS_UI["duel_dm_fail"].format(names=", ".join(dm_failed)), - color=0xED4245, - ) - await self.game.server_message.edit(embed=embed, view=None) - - @discord.ui.button(label=S.RPS_UI["btn_decline"], style=discord.ButtonStyle.danger) - async def decline(self, interaction: discord.Interaction, _: discord.ui.Button): - if interaction.user.id != self.game.player_b.id: - await interaction.response.send_message(S.ERR["not_your_challenge"], ephemeral=True) - return - self.stop() - self._disable_all() - _active_games.discard(self.game.player_a.id) - embed = discord.Embed( - title=S.TITLE["rps_duel_decline"], - description=S.RPS_UI["duel_decline"].format(name=self.game.player_b.display_name), - color=0xED4245, - ) - await interaction.response.edit_message(embed=embed, view=self) - - async def on_timeout(self) -> None: - _active_games.discard(self.game.player_a.id) - self._disable_all() - if self.game.server_message: - embed = discord.Embed( - title=S.TITLE["rps_duel_expire"], - description=S.RPS_UI["duel_no_answer"].format(name=self.game.player_b.display_name), - color=0x99AAB5, - ) - await self.game.server_message.edit(embed=embed, view=self) - - -@tree.command(name="rps", description=S.CMD["rps"]) -@app_commands.describe(panus=S.OPT["rps_panus"], vastane=S.OPT["rps_vastane"]) -async def cmd_rps(interaction: discord.Interaction, panus: str = "0", vastane: discord.Member | None = None): - _data = await economy.get_user(interaction.user.id) - panus_int, _err = _parse_amount(panus, _data["balance"]) - if _err: - await interaction.response.send_message(_err, ephemeral=True) - return - if panus_int < 0: - await interaction.response.send_message(S.ERR["positive_bet"], ephemeral=True) - return - if panus_int > 0: - if rem := economy.jailed_remaining(_data): - await interaction.response.send_message( - S.CD_MSG["jailed"].format(ts=_cd_ts(rem)), ephemeral=True - ) - return - - # ── PvP mode ─ - if vastane is not None: - if interaction.user.id in _active_games: - await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) - return - if vastane.id == interaction.user.id: - await interaction.response.send_message(S.ERR["rps_self"], ephemeral=True) - return - if vastane.bot: - await interaction.response.send_message(S.ERR["rps_bot"], ephemeral=True) - return - if panus_int > 0 and _data["balance"] < panus_int: - await interaction.response.send_message( - S.ERR["broke"].format(bal=_coin(_data["balance"])), ephemeral=True - ) - return - - game = RpsGame(interaction.user, vastane, panus_int) - bet_challenge = S.RPS_UI["challenge_bet"].format(bet=_coin(panus_int)) if panus_int > 0 else "" - embed = discord.Embed( - title=S.TITLE["rps_duel"], - description=S.RPS_UI["challenge_desc"].format(challenger=interaction.user.mention, opponent=vastane.mention, bet=bet_challenge), - color=0x5865F2, - ) - embed.set_footer(text=S.RPS_UI["challenge_footer"]) - challenge_view = RpsChallengeView(game) - await interaction.response.send_message(embed=embed, view=challenge_view) - _active_games.add(interaction.user.id) - game.server_message = await interaction.original_response() - return - - # ── vs Bot mode ────────────────────────────────────────────────────── - if panus_int > 0 and _data["balance"] < panus_int: - await interaction.response.send_message( - S.ERR["broke"].format(bal=_coin(_data["balance"])), ephemeral=True - ) - return - if interaction.user.id in _active_games: - await interaction.response.send_message(S.ERR["already_in_game"], 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( - title=S.TITLE["rps"], - description=S.RPS_UI["vs_bot_desc"] + bet_str, - color=0x5865F2, - ) - await interaction.response.send_message(embed=embed, view=RPSView(interaction.user, panus_int)) - - -# --------------------------------------------------------------------------- -# /slots -# --------------------------------------------------------------------------- -_SLOTS_SPIN = "" -_SLOTS_DELAY = 0.7 - - -def _slots_embed(r1: str, r2: str, r3: str, - title: str = "", # set dynamically - color: int = 0x5865F2, - footer: str = "") -> discord.Embed: - desc = f"{r1} | {r2} | {r3}" - if footer: - desc += f"\n\n{footer}" - return discord.Embed(title=title, description=desc, color=color) - - -@tree.command(name="slots", description=S.CMD["slots"]) -@app_commands.describe(panus=S.OPT["slots_panus"]) -async def cmd_slots(interaction: discord.Interaction, panus: str): - _data = await economy.get_user(interaction.user.id) - panus_int, _err = _parse_amount(panus, _data["balance"]) - if _err: - await interaction.response.send_message(_err, ephemeral=True) - return - if panus_int <= 0: - await interaction.response.send_message(S.ERR["positive_bet"], ephemeral=True) - return - if interaction.user.id in _active_games: - await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) - return - _active_games.add(interaction.user.id) - res = await economy.do_slots(interaction.user.id, panus_int) - if not res["ok"]: - _active_games.discard(interaction.user.id) - if res["reason"] == "banned": - await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) - return - if res["reason"] == "jailed": - await interaction.response.send_message( - S.CD_MSG["jailed"].format(ts=_cd_ts(res["remaining"])), ephemeral=True - ) - return - await interaction.response.send_message( - S.ERR["broke"].format(bal=_coin(_data["balance"])), ephemeral=True - ) - return - - reels = res["reels"] - tier = res["tier"] - change = res["change"] - sp = _SLOTS_SPIN - - # ── Animated reveal ──────────────────────────────────────────────────── - try: - await interaction.response.send_message(embed=_slots_embed(sp, sp, sp, title=S.SLOTS_UI["playing"])) - msg = await interaction.original_response() - - await asyncio.sleep(_SLOTS_DELAY) - await msg.edit(embed=_slots_embed(reels[0], sp, sp, title=S.SLOTS_UI["playing"])) - await asyncio.sleep(_SLOTS_DELAY) - await msg.edit(embed=_slots_embed(reels[0], reels[1], sp, title=S.SLOTS_UI["playing"])) - await asyncio.sleep(_SLOTS_DELAY) - await msg.edit(embed=_slots_embed(reels[0], reels[1], reels[2], title=S.SLOTS_UI["playing"])) - await asyncio.sleep(_SLOTS_DELAY * 0.6) - - # ── Final verdict ───────────────────────────────────────────────────── - tier_key = tier if tier in S.SLOTS_TIERS else "miss" - title, color = S.SLOTS_TIERS[tier_key] - if tier == "jackpot": - footer = S.SLOTS_UI["jackpot_footer"].format(change=_coin(change)) - elif tier == "triple": - footer = S.SLOTS_UI["triple_footer"].format(change=_coin(change)) - elif tier == "pair": - footer = S.SLOTS_UI["pair_footer"].format(change=_coin(change)) - else: - footer = S.SLOTS_UI["miss_footer"].format(amount=_coin(panus_int)) - footer += S.SLOTS_UI["balance_line"].format(balance=_coin(res["balance"])) - - await msg.edit(embed=_slots_embed(reels[0], reels[1], reels[2], - title=title, color=color, footer=footer)) - if tier in ("jackpot", "triple", "pair"): - asyncio.create_task(_award_exp(interaction, economy.gamble_exp(panus_int))) - finally: - _active_games.discard(interaction.user.id) - - -# --------------------------------------------------------------------------- -# /blackjack -# --------------------------------------------------------------------------- -_BJ_RANKS = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"] -_BJ_SUITS = ["♠", "♥", "♦", "♣"] -_BJ_DEAL_DELAY = 0.65 - - -def _bj_deck() -> list[tuple[str, str]]: - deck = [(r, s) for r in _BJ_RANKS for s in _BJ_SUITS] - random.shuffle(deck) - return deck - - -def _bj_value(hand: list[tuple[str, str]]) -> int: - total, aces = 0, 0 - for rank, _ in hand: - if rank == "A": - total += 11 - aces += 1 - elif rank in ("J", "Q", "K", "10"): - total += 10 - else: - total += int(rank) - while total > 21 and aces: - total -= 10 - aces -= 1 - return total - - -def _bj_hand_str(hand: list[tuple[str, str]], hide_second: bool = False) -> str: - if hide_second and len(hand) >= 2: - return f"`{hand[0][0]}{hand[0][1]}` `🂠`" - return " ".join(f"`{r}{s}`" for r, s in hand) - - -def _bj_is_blackjack(hand: list[tuple[str, str]]) -> bool: - return len(hand) == 2 and _bj_value(hand) == 21 - - -def _bj_embed( - player_hand: list, - dealer_hand: list, - title: str, - color: int, - *, - hide_dealer: bool = True, - doubled_total: int = 0, - result_field: tuple | None = None, -) -> discord.Embed: - p_str = _bj_hand_str(player_hand) if player_hand else "-" - p_val = f" `{_bj_value(player_hand)}`" if player_hand else "" - if not dealer_hand: - d_str, d_val = "-", "" - elif hide_dealer: - d_str = _bj_hand_str(dealer_hand, hide_second=True) - d_val = f" `{_bj_value([dealer_hand[0]])}`" - else: - d_str = _bj_hand_str(dealer_hand) - d_val = f" `{_bj_value(dealer_hand)}`" - desc = f"**{S.BJ_UI['dealer']}:** {d_str}{d_val}\n**{S.BJ_UI['player']}:** {p_str}{p_val}" - if doubled_total: - desc += "\n" + S.BJ["doubled_label"].format(total=_coin(doubled_total)) - embed = discord.Embed(title=title, description=desc, color=color) - if result_field: - embed.add_field(name=result_field[0], value=result_field[1], inline=False) - return embed - - -class BlackjackView(discord.ui.View): - def __init__( - self, - user_id: int, - bet: int, - player_hand: list, - dealer_hand: list, - deck: list, - ): - super().__init__(timeout=120) - self.user_id = user_id - self.bet = bet # original per-hand bet - self.hands: list[list] = [player_hand] - self.bets: list[int] = [bet] - self.hand_idx: int = 0 - self.dealer_hand = dealer_hand - self.deck = deck - self._doubled_hands: set[int] = set() - self._split_aces: bool = False - self.message: discord.Message | None = None - self._refresh_buttons() - - @property - def _cur_hand(self) -> list: - return self.hands[self.hand_idx] - - def _can_split(self) -> bool: - return ( - len(self.hands) == 1 - and len(self._cur_hand) == 2 - and self._cur_hand[0][0] == self._cur_hand[1][0] - ) - - def _refresh_buttons(self) -> None: - self.clear_items() - is_split = len(self.hands) > 1 - can_double = ( - not is_split - and 0 not in self._doubled_hands - and len(self._cur_hand) == 2 - ) - hit_btn = discord.ui.Button(label=S.BJ["btn_hit"], style=discord.ButtonStyle.primary) - hit_btn.callback = self._hit - stand_btn = discord.ui.Button(label=S.BJ["btn_stand"], style=discord.ButtonStyle.secondary) - stand_btn.callback = self._stand - double_btn = discord.ui.Button( - label=S.BJ["btn_double"].format(bet=self.bet), - style=discord.ButtonStyle.success, - disabled=not can_double, - ) - double_btn.callback = self._double - self.add_item(hit_btn) - self.add_item(stand_btn) - self.add_item(double_btn) - if self._can_split(): - split_btn = discord.ui.Button( - label=S.BJ["btn_split"].format(bet=self.bet), - style=discord.ButtonStyle.danger, - ) - split_btn.callback = self._split_hand - self.add_item(split_btn) - - def _cur_embed(self, game_over: bool = False, hand_results: list | None = None) -> discord.Embed: - if not self.dealer_hand: - d_str, d_val = "-", "" - elif not game_over: - d_str = _bj_hand_str(self.dealer_hand, hide_second=True) - d_val = f" `{_bj_value([self.dealer_hand[0]])}`" - else: - d_str = _bj_hand_str(self.dealer_hand) - d_val = f" `{_bj_value(self.dealer_hand)}`" - desc = f"**{S.BJ_UI['dealer']}:** {d_str}{d_val}" - - if len(self.hands) == 1: - hand = self.hands[0] - pv = _bj_value(hand) - doubled_str = f" 💰 *{_coin(self.bets[0])}*" if 0 in self._doubled_hands else "" - desc += f"\n**{S.BJ_UI['player']}:** {_bj_hand_str(hand)} `{pv}`{doubled_str}" - else: - for i, hand in enumerate(self.hands): - pv = _bj_value(hand) - if hand_results and i < len(hand_results): - icon = {"win": "✅", "push": "🤝", "lose": "❌"}[hand_results[i]] - label = f"{icon} " + S.BJ_UI["hand_n"].format(n=i + 1) - elif game_over or i < self.hand_idx: - label = S.BJ_UI["hand_n"].format(n=i + 1) - elif i == self.hand_idx: - label = S.BJ_UI["hand_active"].format(n=i + 1) - else: - label = S.BJ_UI["hand_pending"].format(n=i + 1) - bust_str = S.BJ_UI["bust"] if pv > 21 else "" - desc += f"\n**{label}:** {_bj_hand_str(hand)} `{pv}`{bust_str}" - - return discord.Embed(title=S.TITLE["blackjack"], description=desc, color=0x5865F2) - - async def _resolve_all(self, interaction: discord.Interaction) -> None: - _active_games.discard(self.user_id) - self.clear_items() - self.stop() - dv = _bj_value(self.dealer_hand) - total_payout = 0 - hand_results: list[str] = [] - - for hand, bet in zip(self.hands, self.bets): - pv = _bj_value(hand) - if pv > 21: - hand_results.append("lose") - elif dv > 21 or pv > dv: - hand_results.append("win") - total_payout += bet * 2 - elif pv == dv: - hand_results.append("push") - total_payout += bet - else: - hand_results.append("lose") - - total_invested = sum(self.bets) - res = await economy.do_blackjack_payout(self.user_id, total_payout, total_invested) - net = total_payout - total_invested - result_str = ( - f"+{_coin(total_payout)}" - if net > 0 - else (S.BJ["push_result"] if net == 0 else f"-{_coin(total_invested)}") - ) - - if len(self.hands) == 1: - r = hand_results[0] - doubled = 0 in self._doubled_hands - if r == "win": - title_key, color = ("blackjack_dwin" if doubled else "blackjack_win"), 0x57F287 - elif r == "push": - title_key, color = "blackjack_push", 0x99AAB5 - else: - pv = _bj_value(self.hands[0]) - if pv > 21: - title_key = "blackjack_dbust" if doubled else "blackjack_bust" - else: - title_key = "blackjack_lose" - color = 0xED4245 - else: - if net > 0: - title_key, color = "blackjack_win", 0x57F287 - elif net == 0: - title_key, color = "blackjack_push", 0x99AAB5 - else: - title_key, color = "blackjack_lose", 0xED4245 - - embed = self._cur_embed(game_over=True, hand_results=hand_results) - embed.title = S.TITLE[title_key] - embed.color = color - embed.add_field( - name=S.BJ["result_field"], - value=result_str + S.BJ_UI["balance_line"].format(balance=_coin(res["balance"])), - inline=False, - ) - await self.message.edit(embed=embed, view=self) - if total_payout > total_invested: - asyncio.create_task(_award_exp(interaction, economy.gamble_exp(total_invested))) - - async def _do_dealer_reveal(self, interaction: discord.Interaction) -> None: - await self.message.edit(embed=self._cur_embed(game_over=True), view=None) - await asyncio.sleep(_BJ_DEAL_DELAY) - while _bj_value(self.dealer_hand) < 17: - self.dealer_hand.append(self.deck.pop()) - await self.message.edit(embed=self._cur_embed(game_over=True), view=None) - await asyncio.sleep(_BJ_DEAL_DELAY) - await self._resolve_all(interaction) - - async def _advance_or_finish(self, interaction: discord.Interaction) -> None: - self.hand_idx += 1 - if self.hand_idx < len(self.hands): - self._refresh_buttons() - await self.message.edit(embed=self._cur_embed(), view=self) - else: - self.hand_idx = len(self.hands) - 1 - await self._do_dealer_reveal(interaction) - - async def _hit(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 - await interaction.response.defer() - self._cur_hand.append(self.deck.pop()) - val = _bj_value(self._cur_hand) - if val > 21: - await self.message.edit(embed=self._cur_embed(), view=None) - await asyncio.sleep(_BJ_DEAL_DELAY) - if len(self.hands) > 1: - await self._advance_or_finish(interaction) - else: - await self._resolve_all(interaction) - elif val == 21: - await self.message.edit(embed=self._cur_embed(), view=None) - await asyncio.sleep(_BJ_DEAL_DELAY * 0.5) - if len(self.hands) > 1: - await self._advance_or_finish(interaction) - else: - await self._do_dealer_reveal(interaction) - else: - self._refresh_buttons() - await self.message.edit(embed=self._cur_embed(), view=self) - - async def _stand(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 - await interaction.response.defer() - if len(self.hands) > 1: - await self._advance_or_finish(interaction) - else: - await self._do_dealer_reveal(interaction) - - async def _double(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 - res = await economy.do_blackjack_bet(self.user_id, self.bet) - if not res["ok"]: - await interaction.response.send_message( - S.ERR["broke"].format(bal=_coin(res.get("balance", 0))), ephemeral=True - ) - return - await interaction.response.defer() - self._doubled_hands.add(0) - self.bets[0] *= 2 - self._cur_hand.append(self.deck.pop()) - await self.message.edit(embed=self._cur_embed(), view=None) - await asyncio.sleep(_BJ_DEAL_DELAY) - await self._do_dealer_reveal(interaction) - - async def _split_hand(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 - res = await economy.do_blackjack_bet(self.user_id, self.bet) - if not res["ok"]: - await interaction.response.send_message( - S.ERR["broke"].format(bal=_coin(res.get("balance", 0))), ephemeral=True - ) - return - await interaction.response.defer() - card1, card2 = self._cur_hand[0], self._cur_hand[1] - self._split_aces = card1[0] == "A" - self.hands = [[card1, self.deck.pop()], [card2, self.deck.pop()]] - self.bets = [self.bet, self.bet] - self.hand_idx = 0 - await self.message.edit(embed=self._cur_embed(), view=None) - await asyncio.sleep(_BJ_DEAL_DELAY) - if self._split_aces: - await self._do_dealer_reveal(interaction) - else: - self._refresh_buttons() - await self.message.edit(embed=self._cur_embed(), view=self) - - async def on_timeout(self) -> None: - _active_games.discard(self.user_id) - try: - await economy.do_blackjack_payout(self.user_id, 0, sum(self.bets)) - except Exception: - pass - self.clear_items() - if self.message: - try: - await self.message.edit(view=self) - except discord.HTTPException: - pass - - -@tree.command(name="blackjack", description=S.CMD["blackjack"]) -@app_commands.describe(panus=S.OPT["blackjack_panus"]) -async def cmd_blackjack(interaction: discord.Interaction, panus: str): - _data = await economy.get_user(interaction.user.id) - bet, _err = _parse_amount(panus, _data["balance"]) - if _err: - await interaction.response.send_message(_err, ephemeral=True) - return - if bet <= 0: - await interaction.response.send_message(S.ERR["positive_bet"], ephemeral=True) - return - if interaction.user.id in _active_games: - await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) - return - - res = await economy.do_blackjack_bet(interaction.user.id, bet) - if not res["ok"]: - if res["reason"] == "banned": - await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) - elif res["reason"] == "jailed": - await interaction.response.send_message( - S.CD_MSG["jailed"].format(ts=_cd_ts(res["remaining"])), ephemeral=True - ) - else: - await interaction.response.send_message( - S.ERR["broke"].format(bal=_coin(_data["balance"])), ephemeral=True - ) - return - _active_games.add(interaction.user.id) - - deck = _bj_deck() - player_hand: list = [] - dealer_hand: list = [] - - # ── Animated deal: player, dealer, player, dealer ───────────────────── - await interaction.response.send_message( - embed=discord.Embed(title=S.TITLE["blackjack"], description=S.BJ["dealing"], color=0x5865F2) - ) - msg = await interaction.original_response() - - for target in ["player", "dealer", "player", "dealer"]: - if target == "player": - player_hand.append(deck.pop()) - else: - dealer_hand.append(deck.pop()) - await asyncio.sleep(_BJ_DEAL_DELAY) - await msg.edit(embed=_bj_embed(player_hand, dealer_hand, S.TITLE["blackjack"], 0x5865F2, hide_dealer=True)) - - await asyncio.sleep(_BJ_DEAL_DELAY * 0.5) - - # ── Immediate blackjack check ───────────────────────────────────────── - if _bj_is_blackjack(player_hand): - # Flip dealer card before resolving so player can see both hands - await msg.edit(embed=_bj_embed(player_hand, dealer_hand, S.TITLE["blackjack"], 0x5865F2, hide_dealer=False)) - await asyncio.sleep(_BJ_DEAL_DELAY) - if _bj_is_blackjack(dealer_hand): - push_res = await economy.do_blackjack_payout(interaction.user.id, bet, bet) - embed = _bj_embed( - player_hand, dealer_hand, S.TITLE["blackjack_push"], 0x99AAB5, - hide_dealer=False, - result_field=(S.BJ["result_field"], S.BJ["push_result"] + S.BJ_UI["balance_line"].format(balance=_coin(push_res["balance"]))), - ) - else: - payout = bet + int(bet * 1.5) - bj_res = await economy.do_blackjack_payout(interaction.user.id, payout, bet) - embed = _bj_embed( - player_hand, dealer_hand, S.TITLE["blackjack_bj"], 0xF4C430, - hide_dealer=False, - result_field=(S.BJ["result_field"], f"+{_coin(payout)}" + S.BJ_UI["balance_line"].format(balance=_coin(bj_res["balance"]))), - ) - asyncio.create_task(_award_exp(interaction, economy.gamble_exp(bet))) - _active_games.discard(interaction.user.id) - await msg.edit(embed=embed) - return - - # ── Normal game ─────────────────────────────────────────────────────── - view = BlackjackView(interaction.user.id, bet, player_hand, dealer_hand, deck) - view.message = msg - await msg.edit( - embed=_bj_embed(player_hand, dealer_hand, S.TITLE["blackjack"], 0x5865F2, hide_dealer=True), - view=view, - ) - - -# --------------------------------------------------------------------------- -# /request - crowdfunding -# --------------------------------------------------------------------------- -class FundModal(discord.ui.Modal): - summa = discord.ui.TextInput( - label=S.REQUEST_UI["modal_label"], - min_length=1, - max_length=10, - ) - - def __init__(self, view: "RequestView"): - super().__init__(title=S.REQUEST_UI["modal_title"]) - self._view = view - self.summa.placeholder = f"1 - {view.remaining}" - - async def on_submit(self, interaction: discord.Interaction): - amount, _err = _parse_amount(self.summa.value, 0) - if _err or amount is None: - await interaction.response.send_message(S.ERR["invalid_amount"], ephemeral=True) - return - if amount <= 0 or amount > self._view.remaining: - await interaction.response.send_message( - S.ERR["fund_range"].format(max=self._view.remaining), ephemeral=True - ) - return - - res = await economy.do_give(interaction.user.id, self._view.requester.id, amount) - if not res["ok"]: - data = await economy.get_user(interaction.user.id) - await interaction.response.send_message( - S.ERR["broke"].format(bal=_coin(data["balance"])), ephemeral=True - ) - return - - self._view.remaining -= amount - funded_line = S.REQUEST_UI["funded_line"].format(name=interaction.user.display_name, amount=_coin(amount)) - if self._view.remaining <= 0: - self._view.fund_btn.disabled = True - self._view.fund_btn.label = S.REQUEST_UI["btn_funded"] - self._view.fund_btn.style = discord.ButtonStyle.secondary - self._view.stop() - funded_line += S.REQUEST_UI["funded_full"] - else: - self._view.fund_btn.label = S.REQUEST_UI["btn_fund_remaining"].format(remaining=self._view.remaining) - funded_line += S.REQUEST_UI["funded_partial"].format(remaining=_coin(self._view.remaining)) - - await interaction.response.send_message(funded_line) - if self._view.message: - await self._view.message.edit(view=self._view) - - -class RequestView(discord.ui.View): - def __init__(self, requester: discord.Member, amount: int, target: discord.Member | None): - super().__init__(timeout=300) - self.requester = requester - self.remaining = amount - self.target = target - self.message: discord.Message | None = None - self.fund_btn = discord.ui.Button(label=S.REQUEST_UI["btn_fund"], style=discord.ButtonStyle.success) - self.fund_btn.callback = self._fund - self.add_item(self.fund_btn) - - async def _fund(self, interaction: discord.Interaction): - if interaction.user.id == self.requester.id: - await interaction.response.send_message(S.ERR["request_self_fund"], ephemeral=True) - return - if self.target and interaction.user.id != self.target.id: - await interaction.response.send_message( - S.ERR["request_targeted"].format(name=self.target.display_name), ephemeral=True - ) - return - await interaction.response.send_modal(FundModal(self)) - - async def on_timeout(self): - for item in self.children: - item.disabled = True - - -_MAX_REQUEST = 1_000_000 - - -@tree.command(name="request", description=S.CMD["request"]) -@app_commands.describe( - summa=S.OPT["request_summa"], - põhjus=S.OPT["request_põhjus"], - sihtmärk=S.OPT["request_sihtmärk"], +register_economy_support_commands( + tree, + parse_amount=_parse_amount, + coin=_coin, + cancel_reminder_task=_cancel_reminder_task, ) -async def cmd_request( - interaction: discord.Interaction, - summa: str, - põhjus: str, - sihtmärk: discord.Member | None = None, -): - summa_int, _err = _parse_amount(summa, 0) - if _err or summa_int is None: - await interaction.response.send_message(_err or S.ERR["invalid_amount"], ephemeral=True) - return - if summa_int <= 0: - await interaction.response.send_message(S.ERR["positive_amount"], ephemeral=True) - return - if summa_int > _MAX_REQUEST: - await interaction.response.send_message( - S.ERR["fund_range"].format(max=_coin(_MAX_REQUEST)), ephemeral=True - ) - return - summa = summa_int - if sihtmärk and sihtmärk.id == interaction.user.id: - await interaction.response.send_message(S.ERR["request_self"], ephemeral=True) - return - if sihtmärk and sihtmärk.bot: - await interaction.response.send_message(S.ERR["request_bot"], ephemeral=True) - return - audience = S.REQUEST_UI["audience_targeted"].format(name=sihtmärk.display_name) if sihtmärk else S.REQUEST_UI["audience_all"] - embed = discord.Embed( - title=S.TITLE["request"], - description=S.REQUEST_UI["desc"].format(requester=interaction.user.display_name, amount=_coin(summa), reason=põhjus, audience=audience), - color=0xF4C430, - ) - embed.set_footer(text=S.REQUEST_UI["footer"]) - view = RequestView(interaction.user, summa, sihtmärk) - await interaction.response.send_message(embed=embed, view=view) - view.message = await interaction.original_response() - -# --------------------------------------------------------------------------- -# /reminders -# --------------------------------------------------------------------------- -class RemindersSelect(discord.ui.Select): - def __init__(self, user_id: int, current: list[str]): - self.user_id = user_id - options = [ - discord.SelectOption( - label=label, - description=desc, - value=cmd, - default=cmd in current, - ) - for cmd, label, desc in S.REMINDER_OPTS - ] - super().__init__( - placeholder=S.REMINDERS_UI["select_placeholder"], - options=options, - min_values=0, - max_values=len(S.REMINDER_OPTS), - ) - - async def callback(self, interaction: discord.Interaction): - if interaction.user.id != self.user_id: - await interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True) - return - await economy.do_set_reminders(self.user_id, self.values) - enabled = set(self.values) - for cmd in [opt[0] for opt in S.REMINDER_OPTS]: - if cmd not in enabled: - task = _reminder_tasks.pop((self.user_id, cmd), None) - if task and not task.done(): - task.cancel() - if self.values: - names = " ".join(f"`/{v}`" for v in self.values) - msg = S.REMINDERS_UI["saved_on"].format(names=names) - else: - msg = S.REMINDERS_UI["saved_off"] - await interaction.response.send_message(msg, ephemeral=True) - - -class RemindersView(discord.ui.View): - def __init__(self, user_id: int, current: list[str]): - super().__init__(timeout=60) - self.add_item(RemindersSelect(user_id, current)) - - -@tree.command(name="reminders", description=S.CMD["reminders"]) -async def cmd_reminders(interaction: discord.Interaction): - user_data = await economy.get_user(interaction.user.id) - current = user_data.get("reminders", []) - if current: - status = " ".join(f"`/{c}`" for c in current) - desc = S.REMINDERS_UI["desc_active"].format(status=status) - else: - desc = S.REMINDERS_UI["desc_none"] - embed = discord.Embed( - title=S.TITLE["reminders"], - description=desc, - color=0x5865F2, - ) - embed.set_footer(text=S.REMINDERS_UI["footer"]) - await interaction.response.send_message(embed=embed, view=RemindersView(interaction.user.id, current), ephemeral=True) - - -@tree.command(name="send", description=S.CMD["send"]) -@app_commands.guild_only() -@app_commands.describe( - kanal=S.OPT["send_kanal"], - sõnum=S.OPT["send_sõnum"], +register_economy_profile_commands( + tree, + coin=_coin, + cd_ts=_cd_ts, + ensure_level_role=_ensure_level_role, ) -@app_commands.default_permissions(manage_guild=True) -async def cmd_send(interaction: discord.Interaction, kanal: discord.TextChannel, sõnum: str): - try: - await kanal.send(sõnum) - await interaction.response.send_message( - S.SEND_UI["sent"].format(channel=kanal.mention), ephemeral=True - ) - except discord.Forbidden: - await interaction.response.send_message( - S.SEND_UI["forbidden"].format(channel=kanal.mention), ephemeral=True - ) - except Exception as e: - await interaction.response.send_message( - S.ERR["send_failed"].format(error=e), ephemeral=True - ) +register_economy_income_commands( + tree, + bot, + coin=_coin, + cd_ts=_cd_ts, + check_cmd_rate=_check_cmd_rate, + maybe_remind=_maybe_remind, + award_exp=_award_exp, +) -@tree.command(name="economysetup", description=S.CMD["economysetup"]) -@app_commands.guild_only() -@app_commands.default_permissions(manage_guild=True) -async def cmd_economysetup(interaction: discord.Interaction): - await interaction.response.defer(ephemeral=True) - guild = interaction.guild - bot_member = guild.get_member(bot.user.id) - bot_top_pos = max((r.position for r in bot_member.roles if r.managed), default=1) +register_economy_fish_commands( + tree, + coin=_coin, + cd_ts=_cd_ts, + check_cmd_rate=_check_cmd_rate, + maybe_remind=_maybe_remind, + award_exp=_award_exp, + active_games=_active_games, +) - all_role_names = [economy.ECONOMY_ROLE] + [name for _, name in economy.LEVEL_ROLES] +register_economy_games_commands( + tree, + coin=_coin, + cd_ts=_cd_ts, + parse_amount=_parse_amount, + gamble_cd=_gamble_cd, + award_exp=_award_exp, + active_games=_active_games, +) - created, existing = [], [] - for name in all_role_names: - role = discord.utils.find(lambda r, n=name: r.name == n, guild.roles) - if role is None: - await guild.create_role(name=name, reason="/economysetup") - created.append(name) - else: - existing.append(name) - - positions: dict[discord.Role, int] = {} - base = max(bot_top_pos - 1, 1) - for i, name in enumerate(all_role_names): - role = discord.utils.find(lambda r, n=name: r.name == n, guild.roles) - if role: - positions[role] = max(base - i, 1) - if positions: - try: - await guild.edit_role_positions(positions=positions) - except discord.Forbidden: - pass - - embed = discord.Embed(title=S.TITLE["economysetup"], color=0x57F287) - if created: - embed.add_field(name=S.ECONOMYSETUP_UI["created_field"], value="\n".join(created), inline=True) - if existing: - embed.add_field(name=S.ECONOMYSETUP_UI["existing_field"], value="\n".join(existing), inline=True) - embed.set_footer(text=S.ECONOMYSETUP_UI["footer"]) - await interaction.followup.send(embed=embed, ephemeral=True) - log.info("/economysetup triggered by %s", interaction.user) - - -@tree.command(name="allowchannel", description=S.CMD["allowchannel"]) -@app_commands.guild_only() -@app_commands.describe(kanal=S.OPT["allowchannel_kanal"]) -@app_commands.default_permissions(manage_guild=True) -async def cmd_allowchannel(interaction: discord.Interaction, kanal: discord.TextChannel): - allowed = _get_allowed_channels() - if kanal.id in allowed: - await interaction.response.send_message( - S.CHANNEL_UI["already_allowed"].format(channel=kanal.mention), ephemeral=True - ) - return - allowed.append(kanal.id) - _set_allowed_channels(allowed) - log.info("ALLOWCHANNEL +%s by %s", kanal, interaction.user) - await interaction.response.send_message( - S.CHANNEL_UI["added"].format(channel=kanal.mention), ephemeral=True - ) - - -@tree.command(name="denychannel", description=S.CMD["denychannel"]) -@app_commands.guild_only() -@app_commands.describe(kanal=S.OPT["denychannel_kanal"]) -@app_commands.default_permissions(manage_guild=True) -async def cmd_denychannel(interaction: discord.Interaction, kanal: discord.TextChannel): - allowed = _get_allowed_channels() - if kanal.id not in allowed: - await interaction.response.send_message( - S.CHANNEL_UI["not_in_list"].format(channel=kanal.mention), ephemeral=True - ) - return - allowed.remove(kanal.id) - _set_allowed_channels(allowed) - log.info("DENYCHANNEL -%s by %s", kanal, interaction.user) - if allowed: - await interaction.response.send_message( - S.CHANNEL_UI["removed"].format(channel=kanal.mention), ephemeral=True - ) - else: - await interaction.response.send_message( - S.CHANNEL_UI["removed_last"].format(channel=kanal.mention), ephemeral=True - ) - - -@tree.command(name="channels", description=S.CMD["channels"]) -@app_commands.guild_only() -@app_commands.default_permissions(manage_guild=True) -async def cmd_channels(interaction: discord.Interaction): - allowed = _get_allowed_channels() - if not allowed: - desc = S.CHANNEL_UI["list_empty"] - else: - lines = "\n".join(f"\u2022 <#{cid}>" for cid in allowed) - desc = S.CHANNEL_UI["list_filled"].format(lines=lines) - embed = discord.Embed(title=S.CHANNEL_UI["list_title"], description=desc, color=0x5865F2) - await interaction.response.send_message(embed=embed, ephemeral=True) +register_economy_extra_commands( + tree, + bot, + coin=_coin, + cd_ts=_cd_ts, + parse_amount=_parse_amount, + ensure_level_role=_ensure_level_role, + active_games=_active_games, +) # --------------------------------------------------------------------------- @@ -3789,7 +883,11 @@ def _asyncio_exception_handler(loop: asyncio.AbstractEventLoop, context: dict) - if __name__ == "__main__": if not config.DISCORD_TOKEN: - raise SystemExit("DISCORD_TOKEN pole seadistatud. Kopeeri .env.example failiks .env ja täida see.") + profile_key = "DISCORD_TOKEN_ECONOMY" if config.BOT_PROFILE == "economy" else "DISCORD_TOKEN_DEV" + raise SystemExit( + f"{profile_key} pole seadistatud profiilile '{config.BOT_PROFILE}'. " + "Kopeeri .env.example failiks .env ja täida see." + ) async def _main() -> None: loop = asyncio.get_event_loop() diff --git a/config.py b/config.py index f435bcb..b3d04bb 100644 --- a/config.py +++ b/config.py @@ -3,14 +3,58 @@ from dotenv import load_dotenv load_dotenv() -DISCORD_TOKEN = os.getenv("DISCORD_TOKEN") +BOT_PROFILE = os.getenv("BOT_PROFILE", "dev").strip().lower() or "dev" +if BOT_PROFILE not in {"dev", "economy"}: + raise SystemExit("BOT_PROFILE must be either 'dev' or 'economy'.") + + +def _env_int(name: str, default: int) -> int: + raw = os.getenv(name) + if raw is None or not raw.strip(): + return default + return int(raw) + + +_LEGACY_DISCORD_TOKEN = os.getenv("DISCORD_TOKEN", "") +DISCORD_TOKEN_DEV = os.getenv("DISCORD_TOKEN_DEV", "") +DISCORD_TOKEN_ECONOMY = os.getenv("DISCORD_TOKEN_ECONOMY", "") +DISCORD_TOKEN = ( + DISCORD_TOKEN_ECONOMY if BOT_PROFILE == "economy" else DISCORD_TOKEN_DEV +) or _LEGACY_DISCORD_TOKEN + SHEET_ID = os.getenv("SHEET_ID") GOOGLE_CREDS_PATH = os.getenv("GOOGLE_CREDS_PATH", "credentials.json") -GUILD_ID = int(os.getenv("GUILD_ID", "0")) -BIRTHDAY_CHANNEL_ID = int(os.getenv("BIRTHDAY_CHANNEL_ID", "0")) + +_LEGACY_GUILD_ID = _env_int("GUILD_ID", 0) +GUILD_ID_DEV = _env_int("GUILD_ID_DEV", _LEGACY_GUILD_ID) +GUILD_ID_ECONOMY = _env_int("GUILD_ID_ECONOMY", _LEGACY_GUILD_ID) +GUILD_ID = GUILD_ID_ECONOMY if BOT_PROFILE == "economy" else GUILD_ID_DEV + +_LEGACY_BIRTHDAY_CHANNEL_ID = _env_int("BIRTHDAY_CHANNEL_ID", 0) +BIRTHDAY_CHANNEL_ID_DEV = _env_int("BIRTHDAY_CHANNEL_ID_DEV", _LEGACY_BIRTHDAY_CHANNEL_ID) +BIRTHDAY_CHANNEL_ID_ECONOMY = _env_int("BIRTHDAY_CHANNEL_ID_ECONOMY", 0) +BIRTHDAY_CHANNEL_ID = ( + BIRTHDAY_CHANNEL_ID_ECONOMY + if BOT_PROFILE == "economy" + else BIRTHDAY_CHANNEL_ID_DEV +) + BIRTHDAY_WINDOW_DAYS = int(os.getenv("BIRTHDAY_WINDOW_DAYS", "7")) BASE_ROLE_IDS: list[int] = [1478304631930228779, 1478302278862766190] PB_URL = os.getenv("PB_URL", "http://127.0.0.1:8090") PB_ADMIN_EMAIL = os.getenv("PB_ADMIN_EMAIL", "") PB_ADMIN_PASSWORD = os.getenv("PB_ADMIN_PASSWORD", "") + +_LEGACY_PB_COLLECTION = os.getenv("PB_ECONOMY_COLLECTION", "").strip() +PB_ECONOMY_COLLECTION_DEV = ( + os.getenv("PB_ECONOMY_COLLECTION_DEV", "").strip() + or (_LEGACY_PB_COLLECTION if _LEGACY_PB_COLLECTION else "economy_users_dev") +) +PB_ECONOMY_COLLECTION_ECONOMY = ( + os.getenv("PB_ECONOMY_COLLECTION_ECONOMY", "").strip() + or (_LEGACY_PB_COLLECTION if _LEGACY_PB_COLLECTION else "economy_users_prod") +) +PB_ECONOMY_COLLECTION = ( + PB_ECONOMY_COLLECTION_ECONOMY if BOT_PROFILE == "economy" else PB_ECONOMY_COLLECTION_DEV +) diff --git a/dev_member_commands.py b/dev_member_commands.py new file mode 100644 index 0000000..6234286 --- /dev/null +++ b/dev_member_commands.py @@ -0,0 +1,314 @@ +from __future__ import annotations + +import datetime +import logging +from typing import Callable + +import discord +from discord import app_commands + +import sheets +import strings as S +from member_sync import announce_birthday, sync_member + + +class BirthdayPages(discord.ui.View): + def __init__(self, pages: list[discord.Embed], start: int = 0): + super().__init__(timeout=120) + self.pages = pages + self.current = start + self._update_buttons() + + def _update_buttons(self): + self.prev_button.disabled = self.current == 0 + self.next_button.disabled = self.current >= len(self.pages) - 1 + + @discord.ui.button(label="◀", style=discord.ButtonStyle.secondary) + async def prev_button(self, interaction: discord.Interaction, button: discord.ui.Button): + self.current -= 1 + self._update_buttons() + await interaction.response.edit_message(embed=self.pages[self.current], view=self) + + @discord.ui.button(label="▶", style=discord.ButtonStyle.secondary) + async def next_button(self, interaction: discord.Interaction, button: discord.ui.Button): + self.current += 1 + self._update_buttons() + await interaction.response.edit_message(embed=self.pages[self.current], view=self) + + +def _build_birthday_pages( + guild: discord.Guild | None = None, +) -> tuple[list[discord.Embed], int]: + """Build 12 monthly embeds (one per calendar month). + + Returns (pages, start_index) where start_index is the current month. + """ + rows = sheets.get_cache() + today = datetime.date.today() + + by_month: dict[int, list[tuple[int, str, int | None]]] = {m: [] for m in range(1, 13)} + + for row in rows: + name = str(row.get("Nimi", "")).strip() + bday_str = str(row.get("Sünnipäev", "")).strip() + if not name or not bday_str or bday_str.strip().lower() in ("-", "x", "n/a", "none", "ei"): + continue + bday = None + for fmt in ["%d/%m/%Y", "%Y-%m-%d", "%m-%d"]: + try: + bday = datetime.datetime.strptime(bday_str, fmt).date() + break + except ValueError: + continue + if bday is None: + continue + raw_uid = str(row.get("User ID", "")).strip() + try: + uid = int(raw_uid) if raw_uid and raw_uid not in ("0", "-") else None + except ValueError: + uid = None + by_month[bday.month].append((bday.day, name, uid)) + + pages: list[discord.Embed] = [] + for month in range(1, 13): + entries = sorted(by_month[month], key=lambda x: x[0]) + embed = discord.Embed( + title=f"🎂 {S.BIRTHDAY_MONTHS[month - 1]}", + color=0xF4A261, + ) + if not entries: + embed.description = S.BIRTHDAY_UI["no_entries"] + else: + lines = [] + for day, name, uid in entries: + try: + this_year = datetime.date(today.year, month, day) + except ValueError: + this_year = datetime.date(today.year, month, day - 1) + next_bday = this_year if this_year >= today else this_year.replace(year=today.year + 1) + days_until = (next_bday - today).days + if days_until == 0: + when = S.BIRTHDAY_UI["today"] + elif days_until == 1: + when = S.BIRTHDAY_UI["tomorrow"] + else: + when = S.BIRTHDAY_UI["in_days"].format(days=days_until) + display = name + if guild and uid: + member = guild.get_member(uid) + if member: + display = member.mention + lines.append(f"{display} - {day:02d}/{month:02d} · {when}") + embed.description = "\n".join(lines) + embed.set_footer(text=S.BIRTHDAY_UI["footer"].format(month=month, month_name=S.BIRTHDAY_MONTHS[month - 1])) + pages.append(embed) + + return pages, today.month - 1 + + +def _sheet_stats(rows: list[dict]) -> str: + """Return a formatted string with sheet completeness statistics.""" + total = len(rows) + missing_uid = [] + missing_discord = [] + missing_birthday = [] + + for row in rows: + name = str(row.get("Nimi", "")).strip() or S.CHECK_UI["no_name"] + uid = str(row.get("User ID", "")).strip() + discord_name = str(row.get("Discord", "")).strip() + bday = str(row.get("Sünnipäev", "")).strip() + + if not uid or uid == "0": + missing_uid.append(name) + if not discord_name: + missing_discord.append(name) + if not bday: + missing_birthday.append(name) + + lines = [S.CHECK_UI["sheet_stats_header"].format(total=total)] + lines.append("") + + def stat_line(label: str, missing: list[str]) -> str: + count = len(missing) + if count == 0: + return S.CHECK_UI["stat_ok"].format(label=label) + names = ", ".join(missing[:5]) + more = S.CHECK_UI["stat_more"].format(count=count - 5) if count > 5 else "" + return S.CHECK_UI["stat_warn"].format(label=label, count=count, names=names, more=more) + + lines.append(stat_line(S.CHECK_UI["stat_uid"], missing_uid)) + lines.append(stat_line(S.CHECK_UI["stat_discord"], missing_discord)) + lines.append(stat_line(S.CHECK_UI["stat_bday"], missing_birthday)) + + return "\n".join(lines) + + +def register_dev_member_commands( + tree: app_commands.CommandTree, + bot: discord.Client, + log: logging.Logger, + has_announced_today: Callable[[int], bool], + mark_announced_today: Callable[[int], None], +) -> None: + @tree.command(name="birthdays", description=S.CMD["birthdays"]) + @app_commands.guild_only() + async def cmd_birthdays(interaction: discord.Interaction): + await interaction.response.defer() + + try: + sheets.refresh() + except Exception as e: + await interaction.followup.send(S.ERR["sheet_error"].format(error=e), ephemeral=True) + return + + pages, start = _build_birthday_pages(guild=interaction.guild) + await interaction.followup.send(embed=pages[start], view=BirthdayPages(pages, start=start)) + + @tree.command(name="check", description=S.CMD["check"]) + @app_commands.guild_only() + @app_commands.default_permissions(manage_roles=True) + async def cmd_check(interaction: discord.Interaction): + await interaction.response.defer(ephemeral=True) + + guild = interaction.guild + if guild is None: + await interaction.followup.send(S.ERR["guild_only"], ephemeral=True) + return + + try: + data = sheets.refresh() + except Exception as e: + await interaction.followup.send(S.ERR["sheet_error"].format(error=e), ephemeral=True) + return + + ids_filled = 0 + for row in data: + uid = str(row.get("User ID", "")).strip() + if uid and uid not in ("0", "-"): + continue + discord_name = str(row.get("Discord", "")).strip() + if not discord_name: + continue + guild_member = discord.utils.find( + lambda m, n=discord_name: m.name.lower() == n.lower(), + guild.members, + ) + if guild_member: + sheets.set_user_id(discord_name, guild_member.id) + ids_filled += 1 + + data = sheets.get_cache() + + changed_count = 0 + not_found = 0 + already_ok = 0 + errors_total = 0 + birthday_pings = 0 + details: list[str] = [] + sync_updates: list[tuple[int, bool]] = [] + + for member in guild.members: + if member.bot: + continue + + result = await sync_member(member, guild) + + if result.not_found: + not_found += 1 + continue + + sync_updates.append((member.id, result.synced)) + + if result.errors: + errors_total += len(result.errors) + for err in result.errors: + details.append(S.CHECK_UI["detail_error"].format(error=err)) + + if result.changed: + changed_count += 1 + parts = [] + if result.nickname_changed: + parts.append(S.CHECK_UI["detail_nickname"]) + if result.roles_added: + parts.append(S.CHECK_UI["detail_roles_added"].format(roles=", ".join(result.roles_added))) + details.append(S.CHECK_UI["detail_changed"].format(name=member.display_name, parts=", ".join(parts))) + else: + already_ok += 1 + + if result.birthday_soon and not has_announced_today(member.id): + birthday_pings += 1 + await announce_birthday(member, bot) + mark_announced_today(member.id) + + if sync_updates: + try: + sheets.batch_set_synced(sync_updates) + except Exception as e: + log.error("/check batch_set_synced failed: %s", e) + + summary_lines = [ + S.CHECK_UI["done"], + S.CHECK_UI["already_ok"].format(count=already_ok), + S.CHECK_UI["fixed"].format(count=changed_count), + S.CHECK_UI["not_found"].format(count=not_found), + S.CHECK_UI["bday_pings"].format(count=birthday_pings), + ] + if errors_total: + summary_lines.append(S.CHECK_UI["errors"].format(count=errors_total)) + + summary = "\n".join(summary_lines) + + if details: + detail_text = "\n".join(details[:20]) + summary += f"\n\n{S.CHECK_UI['details_header']}\n{detail_text}" + if len(details) > 20: + summary += "\n" + S.CHECK_UI["details_more"].format(count=len(details) - 20) + + stats = _sheet_stats(data) + id_note = S.CHECK_UI["ids_filled"].format(count=ids_filled) if ids_filled else "" + summary = id_note + "\n" + summary + "\n\n" + stats + + await interaction.followup.send(summary.strip(), ephemeral=True) + log.info( + "/check - OK=%d, Fixed=%d, NotFound=%d, IDs=%d, Errors=%d", + already_ok, + changed_count, + not_found, + ids_filled, + errors_total, + ) + + @tree.command(name="member", description=S.CMD["member"]) + @app_commands.guild_only() + @app_commands.default_permissions(manage_roles=True) + async def cmd_member(interaction: discord.Interaction, user: discord.Member): + row = sheets.find_member(user.id, user.name) + if row is None: + await interaction.response.send_message( + S.ERR["member_not_found"].format(name=user.display_name), + ephemeral=True, + ) + return + + embed = discord.Embed(title=S.MEMBER_UI["title"].format(name=user.display_name), color=user.color) + + bday_str = str(row.get("Sünnipäev", "")).strip() + if bday_str and bday_str.lower() not in ("-", "x", "n/a", "none", "ei"): + for fmt in ["%d/%m/%Y", "%Y-%m-%d"]: + try: + bday = datetime.datetime.strptime(bday_str, fmt).date() + if 1920 <= bday.year <= datetime.date.today().year: + today = datetime.date.today() + age = today.year - bday.year - ((today.month, today.day) < (bday.month, bday.day)) + embed.add_field(name=S.MEMBER_UI["age_field"], value=S.MEMBER_UI["age_val"].format(age=age), inline=True) + break + except ValueError: + continue + + for sheet_key, label in S.MEMBER_FIELDS: + val = str(row.get(sheet_key, "")).strip() + if val: + embed.add_field(name=label, value=val, inline=True) + + await interaction.response.send_message(embed=embed, ephemeral=True) diff --git a/dev_member_runtime.py b/dev_member_runtime.py new file mode 100644 index 0000000..a7cb70c --- /dev/null +++ b/dev_member_runtime.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import logging +from collections.abc import Callable + +import discord + +import config +import sheets +from member_sync import announce_birthday, is_birthday_today, sync_member + + +async def run_birthday_daily( + bot: discord.Client, + log: logging.Logger, + has_announced_today: Callable[[int], bool], + mark_announced_today: Callable[[int], None], +) -> None: + """Announce birthdays in the configured guild for users whose birthday is today.""" + guild = bot.get_guild(config.GUILD_ID) + if guild is None: + log.warning("Birthday task: guild %s not found", config.GUILD_ID) + return + + try: + data = sheets.refresh() + except Exception as e: + log.error("Birthday task: sheet refresh failed: %s", e) + data = sheets.get_cache() + + announced = 0 + for row in data: + bday_str = str(row.get("Sünnipäev", "")).strip() + if not is_birthday_today(bday_str): + continue + + member = None + raw_id = str(row.get("User ID", "")).strip() + if raw_id: + try: + member = guild.get_member(int(raw_id)) + except ValueError: + pass + if member is None: + discord_name = str(row.get("Discord", "")).strip() + if discord_name: + member = discord.utils.find( + lambda m, n=discord_name: m.name.lower() == n.lower(), + guild.members, + ) + if member and not has_announced_today(member.id): + await announce_birthday(member, bot) + mark_announced_today(member.id) + announced += 1 + + log.info("Sünnipäeva ülesanne: teavitati %d liiget", announced) + + +async def handle_member_join( + member: discord.Member, + bot: discord.Client, + log: logging.Logger, + has_announced_today: Callable[[int], bool], + mark_announced_today: Callable[[int], None], + log_sync_result: Callable[[discord.Member, object], None], +) -> None: + """Sync a newly joined member against sheet data and trigger birthday notice if needed.""" + log.info("Member joined: %s (ID: %s)", member, member.id) + + if not sheets.get_cache(): + sheets.refresh() + + result = await sync_member(member, member.guild) + + if result.not_found: + try: + sheets.add_new_member_row(member.name, member.id) + log.info( + " → %s not in sheet, added new row (Discord=%s, ID=%s)", + member, + member.name, + member.id, + ) + except Exception as e: + log.error(" → Failed to add sheet row for %s: %s", member, e) + return + + log_sync_result(member, result) + sheets.set_synced(member.id, result.synced) + + if result.birthday_soon and not has_announced_today(member.id): + await announce_birthday(member, bot) + mark_announced_today(member.id) diff --git a/docs/POCKETBASE_SETUP.md b/docs/POCKETBASE_SETUP.md index 52de860..f4ed270 100644 --- a/docs/POCKETBASE_SETUP.md +++ b/docs/POCKETBASE_SETUP.md @@ -13,9 +13,14 @@ Create your admin account on first launch via the Admin UI. --- -## 2. Create the `economy_users` collection +## 2. Create economy collections for both bot profiles -In the Admin UI → **Collections** → **New collection** → name it exactly `economy_users`. +In the Admin UI → **Collections** → **New collection** and create: + +- `economy_users_dev` +- `economy_users_prod` + +Use the same schema for both collections. Add the following fields: @@ -51,6 +56,8 @@ Add to your `.env`: PB_URL=http://127.0.0.1:8090 PB_ADMIN_EMAIL=your-admin@email.com PB_ADMIN_PASSWORD=your-admin-password +PB_ECONOMY_COLLECTION_DEV=economy_users_dev +PB_ECONOMY_COLLECTION_ECONOMY=economy_users_prod ``` --- 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/economy_admin_commands.py b/economy_admin_commands.py new file mode 100644 index 0000000..d2304ba --- /dev/null +++ b/economy_admin_commands.py @@ -0,0 +1,310 @@ +from __future__ import annotations + +import datetime +import logging +from collections.abc import Awaitable, Callable + +import discord +from discord import app_commands + +import economy +import strings as S + + +async def _dm_user(bot: discord.Client, user_id: int, msg: str) -> None: + """Best-effort DM to a user.""" + try: + user = bot.get_user(user_id) or await bot.fetch_user(user_id) + await user.send(msg) + except Exception: + pass + + +def register_economy_admin_commands( + tree: app_commands.CommandTree, + bot: discord.Client, + log: logging.Logger, + cd_ts: Callable[[datetime.timedelta], str], + apply_level_role: Callable[[discord.Member, int, int], Awaitable[None]], +) -> None: + @tree.command(name="adminseason", description=S.CMD["adminseason"]) + @app_commands.guild_only() + @app_commands.default_permissions(manage_guild=True) + @app_commands.describe(top_n=S.OPT["adminseason_top_n"]) + async def cmd_adminseason(interaction: discord.Interaction, top_n: int = 10): + await interaction.response.defer(ephemeral=True) + top = await economy.do_season_reset(top_n) + guild = interaction.guild + + if guild: + all_role_names = {name for _, name in economy.LEVEL_ROLES} + for role_name in all_role_names: + role = discord.utils.find(lambda r: r.name == role_name, guild.roles) + if not role: + continue + for member in list(role.members): + try: + await member.remove_roles(role, reason="Season reset") + except discord.Forbidden: + pass + + medals = ["🥇", "🥈", "🥉"] + lines = [] + for i, (uid, exp, lvl) in enumerate(top): + member = guild.get_member(int(uid)) if guild else None + name = member.display_name if member else S.LEADERBOARD_UI["unknown_user"].format(uid=uid) + prefix = medals[i] if i < 3 else f"**{i + 1}.**" + lines.append(S.SEASON["entry"].format(prefix=prefix, name=name, exp=exp, level=lvl)) + + embed = discord.Embed( + title=S.TITLE["adminseason"], + description=(S.SEASON["top_header"] + "\n" + "\n".join(lines)) if lines else S.SEASON["no_players"], + color=0xF4C430, + ) + embed.set_footer(text=S.SEASON["footer"]) + await interaction.followup.send(embed=embed, ephemeral=False) + await interaction.followup.send(S.SEASON["done"], ephemeral=True) + log.info("/adminseason triggered by %s (top_n=%d)", interaction.user, top_n) + + @tree.command(name="admincoins", description=S.CMD["admincoins"]) + @app_commands.guild_only() + @app_commands.describe( + kasutaja=S.OPT["admin_kasutaja"], + kogus=S.OPT["admincoins_kogus"], + põhjus=S.OPT["admin_põhjus"], + ) + @app_commands.default_permissions(manage_guild=True) + async def cmd_admincoins(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_coins(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["coins_done"].format( + emoji=emoji, + name=kasutaja.display_name, + verb=verb, + coin=economy.COIN, + balance=f"{res['balance']:,}", + reason=põhjus, + ), + ephemeral=True, + ) + await _dm_user( + bot, + kasutaja.id, + S.ADMIN["coins_dm"].format( + emoji=emoji, + verb=verb, + coin=economy.COIN, + reason=põhjus, + balance=f"{res['balance']:,}", + ), + ) + log.info("ADMINCOINS %s → %s (%s) by %s", verb, kasutaja, põhjus, interaction.user) + + @tree.command(name="adminjail", description=S.CMD["adminjail"]) + @app_commands.guild_only() + @app_commands.describe( + kasutaja=S.OPT["admin_kasutaja"], + minutid=S.OPT["adminjail_minutid"], + põhjus=S.OPT["admin_põhjus"], + ) + @app_commands.default_permissions(manage_guild=True) + async def cmd_adminjail(interaction: discord.Interaction, kasutaja: discord.Member, minutid: int, põhjus: str): + if minutid <= 0: + await interaction.response.send_message(S.ERR["positive_duration"], ephemeral=True) + return + await economy.do_admin_jail(kasutaja.id, minutid, interaction.user.id, põhjus) + until_ts = cd_ts(datetime.timedelta(minutes=minutid)) + await interaction.response.send_message( + S.ADMIN["jail_done"].format(name=kasutaja.display_name, minutes=minutid, ts=until_ts, reason=põhjus), + ephemeral=True, + ) + await _dm_user( + bot, + kasutaja.id, + S.ADMIN["jail_dm"].format(minutes=minutid, reason=põhjus, ts=until_ts), + ) + log.info("ADMINJAIL %s %dmin (%s) by %s", kasutaja, minutid, põhjus, interaction.user) + + @tree.command(name="adminunjail", description=S.CMD["adminunjail"]) + @app_commands.guild_only() + @app_commands.describe(kasutaja=S.OPT["admin_kasutaja"]) + @app_commands.default_permissions(manage_guild=True) + async def cmd_adminunjail(interaction: discord.Interaction, kasutaja: discord.Member): + await economy.do_admin_unjail(kasutaja.id, interaction.user.id) + await interaction.response.send_message( + S.ADMIN["unjail_done"].format(name=kasutaja.display_name), ephemeral=True + ) + await _dm_user(bot, kasutaja.id, S.ADMIN["unjail_dm"]) + log.info("ADMINUNJAIL %s by %s", kasutaja, interaction.user) + + @tree.command(name="adminban", description=S.CMD["adminban"]) + @app_commands.guild_only() + @app_commands.describe( + kasutaja=S.OPT["admin_kasutaja"], + põhjus=S.OPT["admin_põhjus"], + ) + @app_commands.default_permissions(manage_guild=True) + async def cmd_adminban(interaction: discord.Interaction, kasutaja: discord.Member, põhjus: str): + if bot.user and kasutaja.id == bot.user.id: + await interaction.response.send_message(S.ERR["admin_ban_bot"], ephemeral=True) + return + await economy.do_admin_ban(kasutaja.id, interaction.user.id, põhjus) + await interaction.response.send_message( + S.ADMIN["ban_done"].format(name=kasutaja.display_name, reason=põhjus), + ephemeral=True, + ) + await _dm_user(bot, kasutaja.id, S.ADMIN["ban_dm"].format(reason=põhjus)) + log.info("ADMINBAN %s (%s) by %s", kasutaja, põhjus, interaction.user) + + @tree.command(name="adminunban", description=S.CMD["adminunban"]) + @app_commands.guild_only() + @app_commands.describe(kasutaja=S.OPT["admin_kasutaja"]) + @app_commands.default_permissions(manage_guild=True) + async def cmd_adminunban(interaction: discord.Interaction, kasutaja: discord.Member): + await economy.do_admin_unban(kasutaja.id, interaction.user.id) + await interaction.response.send_message( + S.ADMIN["unban_done"].format(name=kasutaja.display_name), ephemeral=True + ) + await _dm_user(bot, kasutaja.id, S.ADMIN["unban_dm"]) + log.info("ADMINUNBAN %s by %s", kasutaja, interaction.user) + + @tree.command(name="adminreset", description=S.CMD["adminreset"]) + @app_commands.guild_only() + @app_commands.describe( + kasutaja=S.OPT["admin_kasutaja"], + põhjus=S.OPT["admin_põhjus"], + ) + @app_commands.default_permissions(manage_guild=True) + async def cmd_adminreset(interaction: discord.Interaction, kasutaja: discord.Member, põhjus: str): + if bot.user and kasutaja.id == bot.user.id: + await interaction.response.send_message(S.ERR["admin_reset_bot"], ephemeral=True) + return + await economy.do_admin_reset(kasutaja.id, interaction.user.id) + await interaction.response.send_message( + S.ADMIN["reset_done"].format(name=kasutaja.display_name, reason=põhjus), + ephemeral=True, + ) + await _dm_user(bot, kasutaja.id, S.ADMIN["reset_dm"].format(reason=põhjus)) + log.info("ADMINRESET %s (%s) by %s", kasutaja, põhjus, interaction.user) + + @tree.command(name="adminview", description=S.CMD["adminview"]) + @app_commands.guild_only() + @app_commands.describe(kasutaja=S.OPT["admin_kasutaja"]) + @app_commands.default_permissions(manage_guild=True) + async def cmd_adminview(interaction: discord.Interaction, kasutaja: discord.Member): + res = await economy.do_admin_inspect(kasutaja.id) + data = res["data"] + items_str = ", ".join(data.get("items", [])) or "-" + uses = data.get("item_uses", {}) + uses_str = ", ".join(f"{k}:{v}" for k, v in uses.items()) if uses else "-" + jailed = data.get("jailed_until") or "-" + banned = S.ADMINVIEW_UI["banned_yes"] if data.get("eco_banned") else S.ADMINVIEW_UI["banned_no"] + exp = data.get("exp", 0) + level = economy.get_level(exp) + prestige_lvl = data.get("prestige_level", 0) + prestige_pp = data.get("prestige_points", 0) + total_fish = data.get("total_fish_caught", 0) + inv_fish = len(data.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"{data.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(data.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=data.get("last_daily") or "-", inline=True) + embed.add_field(name=S.ADMINVIEW_UI["f_last_work"], value=data.get("last_work") or "-", inline=True) + embed.add_field(name=S.ADMINVIEW_UI["f_last_crime"], value=data.get("last_crime") or "-", inline=True) + embed.add_field(name=S.ADMINVIEW_UI["f_last_fish"], value=data.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( + bot, + 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(bot, 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(bot, 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) diff --git a/economy_extra_commands.py b/economy_extra_commands.py new file mode 100644 index 0000000..f031e88 --- /dev/null +++ b/economy_extra_commands.py @@ -0,0 +1,883 @@ +from __future__ import annotations + +import asyncio +import datetime +import random +import time +from collections.abc import Awaitable, Callable, MutableSet + +import discord +from discord import app_commands + +import economy +import strings as S + + +def register_economy_extra_commands( + tree: app_commands.CommandTree, + bot: discord.Client, + coin: Callable[[int], str], + cd_ts: Callable[[datetime.timedelta], str], + parse_amount: Callable[[str, int], tuple[int | None, str | None]], + ensure_level_role: Callable[[discord.Member, int], Awaitable[None]], + active_games: MutableSet[int], +) -> None: + active_heist = None + + # ----------------------------------------------------------------------- + # /heist - multiplayer group robbery of the house + # ----------------------------------------------------------------------- + _HEIST_JOIN_WINDOW = 300 # seconds players have to join + _HEIST_MIN_PLAYERS = 2 + _HEIST_GLOBAL_CD = 14400 # seconds between heist events server-wide (4h) + _HEIST_MAX_PLAYERS = 8 + _HEIST_BASE_CHANCE = 0.35 # 35% solo + _HEIST_CHANCE_STEP = 0.05 # +5% per extra player + _HEIST_MAX_CHANCE = 0.65 # cap at 65% + + def _build_heist_story(participants: list[discord.Member], success: bool) -> list[str]: + """Return a list of story lines for the heist narrative reveal.""" + story = S.HEIST_STORY + leader = participants[0].display_name + if len(participants) == 1: + names = f"**{leader}**" + elif len(participants) == 2: + names = S.HEIST_UI["names_duo"].format( + a=participants[0].display_name, + b=participants[1].display_name, + ) + elif len(participants) <= 4: + names = S.HEIST_UI["names_sep"].join(f"**{p.display_name}**" for p in participants) + else: + names = S.HEIST_UI["names_crew"].format(leader=participants[0].display_name) + + vehicle = random.choice(story["vehicles"]) + approach = random.choice(["sneaky", "loud"]) + non_leaders = participants[1:] if len(participants) > 1 else participants + + def fill(tmpl: str) -> str: + picked = random.choice(non_leaders).display_name + return tmpl.format( + leader=f"**{leader}**", + member=f"**{picked}**", + names=names, + vehicle=vehicle, + ) + + getaway_pool = "getaway_success" if success else "getaway_fail" + + return [ + fill(random.choice(story["arrival"])), + fill(random.choice(story[f"entry_{approach}"])), + fill(random.choice(story["inside"])), + fill(random.choice(story["vault"])), + fill(random.choice(story["vault_open"])), + fill(random.choice(story["police_inbound"])), + fill(random.choice(story[getaway_pool])), + fill(random.choice(story["escape_success" if success else "escape_fail"])), + ] + + class HeistLobbyView(discord.ui.View): + 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) + 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) + desc = S.HEIST_UI["lobby_desc"].format( + n=len(self.participants), + max=_HEIST_MAX_PLAYERS, + names=names, + chance=int(self._chance() * 100), + ts=int(self._timeout_expiry()), + ) + return discord.Embed(title=S.TITLE["heist_lobby"], description=desc, color=0xE67E22) + + def _timeout_expiry(self) -> float: + return time.time() + (self.timeout or 0) + + @discord.ui.button(label=S.HEIST_UI["btn_join"], style=discord.ButtonStyle.danger) + async def join(self, interaction: discord.Interaction, _: discord.ui.Button): + if any(p.id == interaction.user.id for p in self.participants): + await interaction.response.send_message(S.HEIST_UI["already_joined"], ephemeral=True) + return + if len(self.participants) >= _HEIST_MAX_PLAYERS: + await interaction.response.send_message(S.ERR["heist_full"], ephemeral=True) + return + if interaction.user.id in active_games: + await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) + return + res = await economy.do_heist_check(interaction.user.id) + if not res["ok"]: + if res["reason"] == "banned": + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + elif res["reason"] == "jailed": + await interaction.response.send_message( + S.CD_MSG["jailed"].format(ts=cd_ts(res["remaining"])), ephemeral=True + ) + else: + await interaction.response.send_message( + S.CD_MSG["heist"].format(ts=cd_ts(res["remaining"])), ephemeral=True + ) + 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) + async def start_now(self, interaction: discord.Interaction, _: discord.ui.Button): + if interaction.user.id != self.organizer.id: + await interaction.response.send_message(S.HEIST_UI["only_organizer"], ephemeral=True) + return + if len(self.participants) < _HEIST_MIN_PLAYERS: + await interaction.response.send_message( + S.ERR["heist_min_players"].format(min=_HEIST_MIN_PLAYERS), ephemeral=True + ) + return + await self._resolve(interaction) + + async def _resolve(self, interaction: discord.Interaction | None = None) -> None: + nonlocal active_heist + if self.resolved: + return + self.resolved = True + active_heist = None + self.stop() + self.clear_items() + + for p in self.participants: + active_games.discard(p.id) + + n = len(self.participants) + channel = interaction.channel if interaction else self.message.channel if self.message else None + + if n < _HEIST_MIN_PLAYERS: + embed = discord.Embed( + title=S.TITLE["heist_cancel"], + description=S.HEIST_UI["cancel_desc"].format(min=_HEIST_MIN_PLAYERS), + color=0x99AAB5, + ) + if interaction and not interaction.response.is_done(): + await interaction.response.edit_message(embed=embed, view=self) + elif self.message: + try: + await self.message.edit(embed=embed, view=self) + except discord.HTTPException: + pass + return + + success = random.random() < self._chance() + story_lines = _build_heist_story(self.participants, success) + + lobby_done = discord.Embed( + title=S.HEIST_UI["started_title"], + description=S.HEIST_UI["started_desc"].format(n=n), + color=0x99AAB5, + ) + if interaction and not interaction.response.is_done(): + await interaction.response.edit_message(embed=lobby_done, view=self) + elif self.message: + try: + await self.message.edit(embed=lobby_done, view=self) + except discord.HTTPException: + pass + + if channel: + story_embed = discord.Embed(title=S.HEIST_UI["story_title"], description="", color=0xE67E22) + story_msg = await channel.send(embed=story_embed) + accumulated = "" + for i, line in enumerate(story_lines): + await asyncio.sleep(random.uniform(3.0, 4.5)) + accumulated += ("\n\n" if i > 0 else "") + line + story_embed.description = accumulated + try: + await story_msg.edit(embed=story_embed) + except discord.HTTPException: + pass + await asyncio.sleep(2.0) + + res = await economy.do_heist_resolve([p.id for p in self.participants], success) + payout_each = res["payout_each"] + names_str = "\n".join(f"• {p.display_name}" for p in self.participants) + guild = interaction.guild if interaction else self.message.guild if self.message else None + + if success: + result_desc = S.HEIST_UI["win_desc"].format(names=names_str, payout=coin(payout_each)) + result_embed = discord.Embed( + title=S.TITLE["heist_win"], + description=result_desc, + color=0x57F287, + ) + for p in self.participants: + exp_res = await economy.award_exp(p.id, economy.EXP_REWARDS["heist_win"]) + if exp_res["old_level"] != exp_res["new_level"] and guild: + gm = guild.get_member(p.id) + if gm: + asyncio.create_task(ensure_level_role(gm, exp_res["new_level"])) + else: + result_desc = S.HEIST_UI["fail_desc"].format(names=names_str) + result_embed = discord.Embed( + title=S.TITLE["heist_fail"], + description=result_desc, + color=0xED4245, + ) + + await economy.set_heist_global_cd(time.time() + _HEIST_GLOBAL_CD) + + if channel: + await channel.send(embed=result_embed) + elif self.message: + try: + await self.message.channel.send(embed=result_embed) + except discord.HTTPException: + pass + + async def on_timeout(self) -> None: + await self._resolve() + + @tree.command(name="heist", description=S.CMD["heist"]) + @app_commands.guild_only() + async def cmd_heist(interaction: discord.Interaction): + nonlocal active_heist + if active_heist is not None: + await interaction.response.send_message(S.ERR["heist_active"], ephemeral=True) + return + heist_cd = await economy.get_heist_global_cd() + if time.time() < heist_cd: + await interaction.response.send_message( + S.CD_MSG["heist_global"].format( + ts=cd_ts(datetime.timedelta(seconds=heist_cd - time.time())) + ), + ephemeral=True, + ) + return + if interaction.user.id in active_games: + await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) + return + res = await economy.do_heist_check(interaction.user.id) + if not res["ok"]: + if res["reason"] == "banned": + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + elif res["reason"] == "jailed": + await interaction.response.send_message( + S.CD_MSG["jailed"].format(ts=cd_ts(res["remaining"])), ephemeral=True + ) + return + + 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) + view.message = await interaction.original_response() + + # ----------------------------------------------------------------------- + # /jailbreak - Monopoly-style dice escape + # ----------------------------------------------------------------------- + _DICE_EMOJI = [ + "<:TipiYKS:1483103190491856916>", + "<:TipiKAKS:1483103215841972404>", + "<:TipiKOLM:1483103217846980781>", + "<:TipiNELI:1483103237585240114>", + "<:TipiVIIS:1483103239036469289>", + "<:TipiKUUS:1483103253163020348>", + ] + + class JailbreakView(discord.ui.View): + MAX_TRIES = 3 + + def __init__(self, user_id: int): + super().__init__(timeout=120) + self.user_id = user_id + self.tries = 0 + self._rolling = False + self._add_roll_btn() + + def _add_roll_btn(self): + self.clear_items() + 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) + + async def _on_roll(self, interaction: discord.Interaction): + 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 + + 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) + + 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_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.edit_original_response(embed=embed, view=self) + + class BailView(discord.ui.View): + def __init__(self, user_id: int): + super().__init__(timeout=60) + self.user_id = user_id + + @discord.ui.button(label=S.JAILBREAK_UI["bail_btn"], style=discord.ButtonStyle.danger) + async def pay_bail(self, interaction: discord.Interaction, _: discord.ui.Button): + if interaction.user.id != self.user_id: + await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) + return + res = await economy.do_bail(self.user_id) + self.clear_items() + self.stop() + if not res["ok"] and res.get("reason") == "broke": + embed = discord.Embed( + title=S.TITLE["jailbreak_bail"], + description=S.JAILBREAK_UI["bail_broke_desc"].format( + min=coin(economy.MIN_BAIL), + balance=coin(res["balance"]), + ), + color=0xED4245, + ) + else: + embed = discord.Embed( + title=S.TITLE["jailbreak_bail"], + description=S.JAILBREAK_UI["bail_paid_desc"].format( + fine=coin(res["fine"]), + balance=coin(res["balance"]), + ), + color=0x57F287, + ) + await interaction.response.edit_message(embed=embed, view=self) + + @tree.command(name="jailbreak", description=S.CMD["jailbreak"]) + async def cmd_jailbreak(interaction: discord.Interaction): + user_data = await economy.get_user(interaction.user.id) + remaining = economy._is_jailed(user_data) + if not remaining: + await interaction.response.send_message(S.ERR["not_jailed"], ephemeral=True) + return + + if user_data.get("jailbreak_used", False): + bal = user_data["balance"] + min_fine = max(economy.MIN_BAIL, int(bal * 0.20)) + max_fine = max(economy.MIN_BAIL, int(bal * 0.30)) + if bal >= economy.MIN_BAIL: + desc = S.JAILBREAK_UI["already_bail"].format( + min=coin(min_fine), + max=coin(max_fine), + bal=coin(bal), + ts=cd_ts(remaining), + ) + await interaction.response.send_message( + embed=discord.Embed( + title=S.TITLE["jailbreak_bail"], + description=desc, + color=0xED4245, + ), + view=BailView(interaction.user.id), + ephemeral=True, + ) + else: + desc = S.JAILBREAK_UI["already_broke"].format( + min=coin(economy.MIN_BAIL), + bal=coin(bal), + ts=cd_ts(remaining), + ) + await interaction.response.send_message( + embed=discord.Embed( + title=S.TITLE["jailbreak_bail"], + description=desc, + color=0xED4245, + ), + ephemeral=True, + ) + return + + await economy.set_jailbreak_used(interaction.user.id) + embed = discord.Embed( + title=S.TITLE["jailbreak"], + description=S.JAILBREAK_UI["intro_desc"].format( + ts=cd_ts(remaining), + tries=JailbreakView.MAX_TRIES, + ), + color=0xF4C430, + ) + await interaction.response.send_message( + embed=embed, + view=JailbreakView(interaction.user.id), + ephemeral=True, + ) + + @tree.command(name="give", description=S.CMD["give"]) + @app_commands.describe(kasutaja=S.OPT["give_kasutaja"], summa=S.OPT["give_summa"]) + async def cmd_give(interaction: discord.Interaction, kasutaja: discord.Member, summa: str): + data = await economy.get_user(interaction.user.id) + summa_int, err = parse_amount(summa, data["balance"]) + if err: + await interaction.response.send_message(err, ephemeral=True) + return + if summa_int is None or summa_int <= 0: + await interaction.response.send_message(S.ERR["positive_amount"], ephemeral=True) + return + if kasutaja.id == interaction.user.id: + await interaction.response.send_message(S.ERR["give_self"], ephemeral=True) + return + if kasutaja.bot: + await interaction.response.send_message(S.ERR["give_bot"], ephemeral=True) + return + + res = await economy.do_give(interaction.user.id, kasutaja.id, summa_int) + if not res["ok"]: + if res["reason"] == "banned": + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + elif res["reason"] == "jailed": + await interaction.response.send_message( + S.ERR["give_jailed"].format(ts=cd_ts(res["remaining"])), + ephemeral=True, + ) + else: + await interaction.response.send_message( + S.ERR["broke"].format(bal=coin(data["balance"])), + ephemeral=True, + ) + return + + embed = discord.Embed( + title=f"{economy.COIN} {S.TITLE['give']}", + description=S.GIVE_UI["desc"].format( + giver=interaction.user.display_name, + amount=coin(summa_int), + receiver=kasutaja.display_name, + ), + color=0xF4C430, + ) + await interaction.response.send_message(embed=embed) + + class LeaderboardView(discord.ui.View): + PER_PAGE = 10 + + def __init__(self, data: dict, guild: discord.Guild | None, bot_user: discord.ClientUser | None): + super().__init__(timeout=120) + self.data = data + self.guild = guild + self.bot_user = bot_user + self.page = 0 + self.mode = "coins" + self.max_page = 0 + self._update_buttons() + + 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 + 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: + 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.data.get("house_entry"): + _, bal = self.data["house_entry"] + house_name = self.bot_user.display_name if self.bot_user else S.LEADERBOARD_UI["house_default_name"] + lines.append(S.LEADERBOARD_UI["house_entry"].format(name=house_name, balance=coin(bal))) + lines.append("") + + start = self.page * self.PER_PAGE + medals = ["🥇", "🥈", "🥉"] + current = self._current_list() + slice_ = current[start : start + self.PER_PAGE] + + if not slice_: + lines.append(S.LEADERBOARD_UI["no_entries"]) + else: + for i, entry in enumerate(slice_): + rank = start + i + uid = entry[0] + prefix = medals[rank] if rank < 3 else f"**{rank + 1}.**" + name = self._name(uid, highlight_uid) + if self.mode == "coins": + lines.append(f"{prefix} {name} - {coin(entry[1])}") + 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, 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, 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_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, + ) + return + 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: + child.disabled = True + + @tree.command(name="leaderboard", description=S.CMD["leaderboard"]) + async def cmd_leaderboard(interaction: discord.Interaction): + await interaction.response.defer() + 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 = [] + 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)) + + 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) + + def _shop_embed(tier: int, user_data: dict) -> discord.Embed: + owned = set(user_data.get("items", [])) + item_uses = user_data.get("item_uses", {}) + tier_names = {1: S.SHOP_UI["tier_1"], 2: S.SHOP_UI["tier_2"], 3: S.SHOP_UI["tier_3"]} + embed = discord.Embed( + title=f"{economy.COIN} TipiBOTi pood · {tier_names[tier]}", + description=S.SHOP_UI["desc"].format(bal=coin(user_data["balance"])), + color=[0x57F287, 0xF4C430, 0xED4245][tier - 1], + ) + for item_id in sorted(economy.SHOP_TIERS[tier], key=lambda k: economy.SHOP[k]["cost"]): + item = economy.SHOP[item_id] + anticheat_uses = item_uses.get("anticheat", 0) if item_id == "anticheat" else 0 + min_lvl = economy.SHOP_LEVEL_REQ.get(item_id, 0) + user_lvl = economy.get_level(user_data.get("exp", 0)) + if item_id in owned and not (item_id == "anticheat" and anticheat_uses <= 0): + if item_id == "anticheat": + key = "owned_uses_1" if anticheat_uses == 1 else "owned_uses_n" + status = S.SHOP_UI[key].format(uses=anticheat_uses) + else: + status = S.SHOP_UI["owned"] + elif min_lvl > 0 and user_lvl < min_lvl: + status = S.SHOP_UI["locked"].format(min_lvl=min_lvl, user_lvl=user_lvl) + else: + status = f"{item['cost']} {economy.COIN}" + embed.add_field( + name=f"{item['emoji']} {item['name']} · {status}", + value=item["description"], + inline=False, + ) + return embed + + class ShopView(discord.ui.View): + def __init__(self, user_data: dict, tier: int = 1): + super().__init__(timeout=120) + self._user_data = user_data + self._tier = tier + self._update_buttons() + + def _update_buttons(self): + self.clear_items() + for t, label in [(1, S.SHOP_BTN[1]), (2, S.SHOP_BTN[2]), (3, S.SHOP_BTN[3])]: + btn = discord.ui.Button( + label=label, + style=discord.ButtonStyle.primary if t == self._tier else discord.ButtonStyle.secondary, + custom_id=f"shop_tier_{t}", + ) + btn.callback = self._make_callback(t) + self.add_item(btn) + + def _make_callback(self, tier: int): + async def callback(interaction: discord.Interaction): + self._tier = tier + self._update_buttons() + self._user_data = await economy.get_user(interaction.user.id) + await interaction.response.edit_message( + embed=_shop_embed(self._tier, self._user_data), + view=self, + ) + + return callback + + @tree.command(name="shop", description=S.CMD["shop"]) + async def cmd_shop(interaction: discord.Interaction): + data = await economy.get_user(interaction.user.id) + await interaction.response.send_message( + embed=_shop_embed(1, data), + view=ShopView(data, tier=1), + ephemeral=True, + ) + + @tree.command(name="buy", description=S.CMD["buy"]) + @app_commands.describe(ese=S.OPT["buy_ese"]) + @app_commands.choices( + ese=[ + app_commands.Choice(name=f"{v['name']} ({v['cost']} TipiCOINi)", value=k) + for k, v in economy.SHOP.items() + ] + ) + async def cmd_buy(interaction: discord.Interaction, ese: app_commands.Choice[str]): + res = await economy.do_buy(interaction.user.id, ese.value) + if not res["ok"]: + if res["reason"] == "banned": + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + elif res["reason"] == "owned": + await interaction.response.send_message(S.ERR["item_owned"], ephemeral=True) + elif res["reason"] == "level_required": + await interaction.response.send_message( + S.ERR["item_level_req"].format( + min_level=res["min_level"], + user_level=res["user_level"], + ), + ephemeral=True, + ) + elif res["reason"] == "insufficient": + await interaction.response.send_message( + S.ERR["broke_need"].format(need=coin(res["need"])), + ephemeral=True, + ) + else: + await interaction.response.send_message(S.ERR["item_not_found"], ephemeral=True) + return + + item = res["item"] + embed = discord.Embed( + title=S.BUY_UI["title"].format(emoji=item["emoji"], name=item["name"]), + description=S.BUY_UI["desc"].format( + description=item["description"], + balance=coin(res["balance"]), + ), + color=0x57F287, + ) + await interaction.response.send_message(embed=embed) diff --git a/economy_fish_commands.py b/economy_fish_commands.py new file mode 100644 index 0000000..9f96d4b --- /dev/null +++ b/economy_fish_commands.py @@ -0,0 +1,380 @@ +from __future__ import annotations + +import asyncio +import random +from collections.abc import Awaitable, Callable, MutableSet + +import discord +from discord import app_commands + +import economy +import strings as S + + +def register_economy_fish_commands( + tree: app_commands.CommandTree, + coin: Callable[[int], str], + cd_ts: Callable, + check_cmd_rate: Callable[[discord.Interaction], Awaitable[bool]], + maybe_remind: Callable[[int, str], Awaitable[None]], + award_exp: Callable[[discord.Interaction, int], Awaitable[None]], + active_games: MutableSet[int], +) -> None: + 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) + + @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) diff --git a/economy_games_commands.py b/economy_games_commands.py new file mode 100644 index 0000000..e970bfa --- /dev/null +++ b/economy_games_commands.py @@ -0,0 +1,1132 @@ +from __future__ import annotations + +import asyncio +import datetime +import random +from collections.abc import Awaitable, Callable, MutableSet + +import discord +from discord import app_commands + +import economy +import strings as S + + +def register_economy_games_commands( + tree: app_commands.CommandTree, + coin: Callable[[int], str], + cd_ts: Callable[[datetime.timedelta], str], + parse_amount: Callable[[str, int], tuple[int | None, str | None]], + gamble_cd: Callable[[int, bool], datetime.timedelta | None], + award_exp: Callable[[discord.Interaction, int], Awaitable[None]], + active_games: MutableSet[int], +) -> None: + # ----------------------------------------------------------------------- + # /roulette animation + # ----------------------------------------------------------------------- + _ROULETTE_R = "\U0001f534" # 🔴 + _ROULETTE_B = "\u26ab" # ⚫ + _ROULETTE_G = "\U0001f7e2" # 🟢 + # delays between frames, fast → slow (12 transitions = 13 total viewport positions) + _ROULETTE_WHEEL_DELAYS = [0.15, 0.15, 0.18, 0.20, 0.22, 0.25, 0.28, 0.35, 0.45, 0.60, 0.80, 1.00] + + def _build_roulette_strip(result_emoji: str) -> list[str]: + """Build a 17-symbol wheel strip obeying strict transition rules: + R -> B or G (R can go to either) + B -> R (B must go to R) + G -> B (G must go to B) + Result is at strip[14] = center of the final viewport. + Prefix (0-13) is generated backward from the result; + suffix (15-16) is generated forward from the result. + Greens appear randomly in the prefix as near-miss elements (up to 2). + """ + r, b, g = _ROULETTE_R, _ROULETTE_B, _ROULETTE_G + strip: list[str] = [None] * 17 # type: ignore[list-item] + + # Suffix: positions 15-16 (deterministic, no greens after result) + strip[14] = result_emoji + strip[15] = r if result_emoji == b else b # B->R, R->B, G->B + strip[16] = b if strip[15] == r else r # R->B, B->R + + # Prefix: positions 0-13 built backward from result + # Inverse transitions: pred(R)=B, pred(B)=R or G, pred(G)=R + # First pass: collect positions where a green is valid (cur == B, with room for pred). + # Green is only relevant when result is not green itself. + green_pos: int | None = None + if result_emoji != g: + candidates: list[int] = [] + cur = result_emoji + for i in range(13, -1, -1): + if cur == b and 2 <= i <= 11: + candidates.append(i) + cur = b if cur == r else (r if cur == g else r) + if candidates: + green_pos = random.choice(candidates) + + # Second pass: generate strip, inserting green at the chosen position. + cur = result_emoji + for i in range(13, -1, -1): + if cur == r: + strip[i] = b + elif cur == g: + strip[i] = r + else: # cur == B + strip[i] = g if i == green_pos else r + cur = strip[i] + + return strip + + def _roulette_frame_embed(symbols: list[str], stopped: bool = False) -> discord.Embed: + title = S.ROULETTE["spin_stop"] if stopped else S.ROULETTE["spin_title"] + desc = S.ROULETTE["spin_strip"].format( + s0=symbols[0], s1=symbols[1], s2=symbols[2], s3=symbols[3], s4=symbols[4] + ) + return discord.Embed(title=title, description=desc, color=0x99AAB5) + + @tree.command(name="roulette", description=S.CMD["roulette"]) + @app_commands.describe(panus=S.OPT["roulette_panus"], värv=S.OPT["roulette_värv"]) + @app_commands.choices( + värv=[ + app_commands.Choice(name="🔴 Punane", value="punane"), + app_commands.Choice(name="⚫ Must", value="must"), + app_commands.Choice(name="🟢 Roheline", value="roheline"), + ] + ) + async def cmd_roulette(interaction: discord.Interaction, panus: str, värv: app_commands.Choice[str]): + data = await economy.get_user(interaction.user.id) + panus_int, err = parse_amount(panus, data["balance"]) + if err: + await interaction.response.send_message(err, ephemeral=True) + return + if panus_int is None or 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 + active_games.add(interaction.user.id) + res = await economy.do_roulette(interaction.user.id, panus_int, värv.value) + if not res["ok"]: + active_games.discard(interaction.user.id) + if res["reason"] == "banned": + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + elif res["reason"] == "jailed": + await interaction.response.send_message( + S.CD_MSG["jailed"].format(ts=cd_ts(res["remaining"])), ephemeral=True + ) + else: + await interaction.response.send_message( + S.ERR["broke"].format(bal=coin(data["balance"])), ephemeral=True + ) + return + + result_emoji = S.ROULETTE["emoji"][res["result"]] + strip = _build_roulette_strip(result_emoji) + + try: + await interaction.response.send_message(embed=_roulette_frame_embed(strip[0:5])) + spin_msg = await interaction.original_response() + + for i, delay in enumerate(_ROULETTE_WHEEL_DELAYS, 1): + await asyncio.sleep(delay) + stopped = i == len(_ROULETTE_WHEEL_DELAYS) + await spin_msg.edit(embed=_roulette_frame_embed(strip[i : i + 5], stopped=stopped)) + + await asyncio.sleep(0.55) + + emoji = S.ROULETTE["emoji"].get(res["result"], "🎰") + genitive = S.ROULETTE["genitive"].get(res["result"], res["result"]) + if res["won"]: + mult_str = f" · **{res['mult']}x**" if res["mult"] > 1 else "" + embed = discord.Embed( + title=S.ROULETTE["win_title"].format(emoji=emoji), + description=S.ROULETTE["win_desc"].format( + genitive=genitive, + mult=mult_str, + change=coin(res["change"]), + balance=coin(res["balance"]), + ), + color=0x57F287, + ) + asyncio.create_task(award_exp(interaction, economy.gamble_exp(panus_int))) + else: + embed = discord.Embed( + title=S.ROULETTE["lose_title"].format(emoji=emoji), + description=S.ROULETTE["lose_desc"].format( + genitive=genitive, + change=coin(abs(res["change"])), + balance=coin(res["balance"]), + ), + color=0xED4245, + ) + await spin_msg.edit(embed=embed) + finally: + active_games.discard(interaction.user.id) + + # ----------------------------------------------------------------------- + # Rock Paper Scissors (vs Bot OR PvP) + # ----------------------------------------------------------------------- + _RPS_CHOICES = S.RPS_CHOICES + _RPS_BEATS = {"🪨": "✂️", "📄": "🪨", "✂️": "📄"} + + class RPSView(discord.ui.View): + def __init__(self, challenger: discord.User, bet: int = 0): + super().__init__(timeout=60) + self.challenger = challenger + self.bet = bet + + async def _resolve(self, interaction: discord.Interaction, player_pick: str): + if interaction.user.id != self.challenger.id: + await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) + return + self.stop() + active_games.discard(self.challenger.id) + bot_pick = random.choice(list(_RPS_CHOICES)) + p_name = _RPS_CHOICES[player_pick] + b_name = _RPS_CHOICES[bot_pick] + if player_pick == bot_pick: + outcome, result, color = "tie", S.RPS_UI["result_tie"], 0x99AAB5 + elif _RPS_BEATS[player_pick] == bot_pick: + outcome, result, color = "win", S.RPS_UI["result_win"], 0x57F287 + else: + outcome, result, color = "lose", S.RPS_UI["result_lose"], 0xED4245 + + bet_line = "" + if self.bet > 0: + res = await economy.do_game_bet(interaction.user.id, self.bet, outcome) + if outcome == "win": + bet_line = S.RPS_UI["bet_win"].format(amount=coin(self.bet), balance=coin(res["balance"])) + elif outcome == "lose": + bet_line = S.RPS_UI["bet_lose"].format(amount=coin(self.bet), balance=coin(res["balance"])) + else: + bet_line = S.RPS_UI["bet_tie"].format(balance=coin(res["balance"])) + + embed = discord.Embed( + title=S.TITLE["rps"], + description=S.RPS_UI["result_desc"].format( + player_pick=player_pick, + player_name=p_name, + bot_pick=bot_pick, + bot_name=b_name, + result=result, + bet_line=bet_line, + ), + color=color, + ) + await interaction.response.edit_message(embed=embed, view=None) + + @discord.ui.button(label=S.RPS_UI["btn_rock"], style=discord.ButtonStyle.secondary) + async def pick_rock(self, interaction: discord.Interaction, _: discord.ui.Button): + await self._resolve(interaction, "🪨") + + @discord.ui.button(label=S.RPS_UI["btn_paper"], style=discord.ButtonStyle.secondary) + async def pick_paper(self, interaction: discord.Interaction, _: discord.ui.Button): + await self._resolve(interaction, "📄") + + @discord.ui.button(label=S.RPS_UI["btn_scissors"], style=discord.ButtonStyle.secondary) + async def pick_scissors(self, interaction: discord.Interaction, _: discord.ui.Button): + await self._resolve(interaction, "✂️") + + async def on_timeout(self): + active_games.discard(self.challenger.id) + for item in self.children: + item.disabled = True + + class RpsGame: + """Shared mutable state for a PvP RPS match.""" + + def __init__(self, player_a: discord.Member, player_b: discord.Member, bet: int): + self.player_a = player_a + self.player_b = player_b + self.bet = bet + self.choice_a: str | None = None + self.choice_b: str | None = None + self.dm_msg_a: discord.Message | None = None + self.dm_msg_b: discord.Message | None = None + self.server_message: discord.Message | None = None + self._resolved = False + self._lock = asyncio.Lock() + + async def maybe_resolve(self) -> None: + async with self._lock: + if self._resolved or self.choice_a is None or self.choice_b is None: + return + self._resolved = True + + a, b = self.choice_a, self.choice_b + if a == b: + winner, color = None, 0x99AAB5 + result_a = result_b = S.RPS_UI["result_tie"] + elif _RPS_BEATS[a] == b: + winner, color = "a", 0x57F287 + result_a = S.RPS_UI["result_win"] + result_b = S.RPS_UI["duel_verdict_win"].format(name=self.player_a.display_name) + else: + winner, color = "b", 0xED4245 + result_a = S.RPS_UI["duel_verdict_win"].format(name=self.player_b.display_name) + result_b = S.RPS_UI["result_win"] + + bet_line_a = bet_line_b = "" + if self.bet > 0: + if winner == "a": + res = await economy.do_give(self.player_b.id, self.player_a.id, self.bet) + elif winner == "b": + res = await economy.do_give(self.player_a.id, self.player_b.id, self.bet) + else: + res = {"ok": True} + + if self.bet > 0 and winner is not None: + if res.get("ok"): + bet_line_a = f"\n{'+' if winner == 'a' else '-'}{coin(self.bet)}" + bet_line_b = f"\n{'+' if winner == 'b' else '-'}{coin(self.bet)}" + else: + bet_line_a = bet_line_b = S.RPS_UI["duel_broke"] + + data_a = await economy.get_user(self.player_a.id) + data_b = await economy.get_user(self.player_b.id) + bal_a, bal_b = data_a["balance"], data_b["balance"] + + if self.dm_msg_a: + await self.dm_msg_a.edit( + content=S.RPS_UI["duel_result_a"].format( + opponent=self.player_b.display_name, + pick_a=a, + name_a=_RPS_CHOICES[a], + pick_b=b, + name_b=_RPS_CHOICES[b], + result=result_a, + bet_line=bet_line_a, + balance=coin(bal_a), + ), + view=None, + ) + if self.dm_msg_b: + await self.dm_msg_b.edit( + content=S.RPS_UI["duel_result_a"].format( + opponent=self.player_a.display_name, + pick_a=b, + name_a=_RPS_CHOICES[b], + pick_b=a, + name_b=_RPS_CHOICES[a], + result=result_b, + bet_line=bet_line_b, + balance=coin(bal_b), + ), + view=None, + ) + + if self.server_message: + if winner == "a": + verdict = S.RPS_UI["duel_verdict_win"].format(name=self.player_a.display_name) + elif winner == "b": + verdict = S.RPS_UI["duel_verdict_win"].format(name=self.player_b.display_name) + else: + verdict = S.RPS_UI["duel_verdict_tie"] + embed = discord.Embed( + title=S.TITLE["rps_duel_done"], + description=S.RPS_UI["duel_done_desc"].format( + a=self.player_a.mention, + pick_a=a, + pick_b=b, + b=self.player_b.mention, + verdict=verdict, + name_a=self.player_a.display_name, + bal_a=coin(bal_a), + name_b=self.player_b.display_name, + bal_b=coin(bal_b), + ), + color=color, + ) + await self.server_message.edit(embed=embed, view=None) + active_games.discard(self.player_a.id) + active_games.discard(self.player_b.id) + + class RpsDmView(discord.ui.View): + """DM view for each player to make their pick in a PvP match.""" + + def __init__(self, game: RpsGame, side: str): + super().__init__(timeout=120) + self.game = game + self.side = side + + async def _pick(self, interaction: discord.Interaction, choice: str) -> None: + if self.side == "a": + self.game.choice_a = choice + else: + self.game.choice_b = choice + for item in self.children: + item.disabled = True + self.stop() + await interaction.response.edit_message( + content=S.RPS_UI["duel_waiting"].format(choice=choice, name=_RPS_CHOICES[choice]), + view=self, + ) + await self.game.maybe_resolve() + + async def on_timeout(self) -> None: + async with self.game._lock: + if self.game._resolved: + return + self.game._resolved = True + active_games.discard(self.game.player_a.id) + active_games.discard(self.game.player_b.id) + for item in self.children: + item.disabled = True + for player in (self.game.player_a, self.game.player_b): + try: + await player.send(S.RPS_UI["duel_expire_dm"]) + except discord.Forbidden: + pass + if self.game.server_message: + embed = discord.Embed( + title=S.TITLE["rps_duel_expire"], + description=S.RPS_UI["duel_expire_desc"].format( + a=self.game.player_a.mention, + b=self.game.player_b.mention, + ), + color=0x99AAB5, + ) + await self.game.server_message.edit(embed=embed, view=None) + + @discord.ui.button(label=S.RPS_UI["btn_rock"], style=discord.ButtonStyle.secondary) + async def pick_rock(self, interaction: discord.Interaction, _: discord.ui.Button): + await self._pick(interaction, "🪨") + + @discord.ui.button(label=S.RPS_UI["btn_paper"], style=discord.ButtonStyle.secondary) + async def pick_paper(self, interaction: discord.Interaction, _: discord.ui.Button): + await self._pick(interaction, "📄") + + @discord.ui.button(label=S.RPS_UI["btn_scissors"], style=discord.ButtonStyle.secondary) + async def pick_scissors(self, interaction: discord.Interaction, _: discord.ui.Button): + await self._pick(interaction, "✂️") + + class RpsChallengeView(discord.ui.View): + """Server-side accept/decline view for PvP RPS challenge.""" + + def __init__(self, game: RpsGame): + super().__init__(timeout=60) + self.game = game + + def _disable_all(self) -> None: + for item in self.children: + item.disabled = True + + @discord.ui.button(label=S.RPS_UI["btn_accept"], style=discord.ButtonStyle.success) + async def accept(self, interaction: discord.Interaction, _: discord.ui.Button): + if interaction.user.id != self.game.player_b.id: + await interaction.response.send_message(S.ERR["not_your_challenge"], ephemeral=True) + return + if self.game.player_b.id in active_games: + await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) + return + self.stop() + self._disable_all() + active_games.add(self.game.player_b.id) + + if self.game.bet > 0: + data_a = await economy.get_user(self.game.player_a.id) + data_b = await economy.get_user(self.game.player_b.id) + for player, data in ((self.game.player_a, data_a), (self.game.player_b, data_b)): + if data["balance"] < self.game.bet: + embed = discord.Embed( + title=S.TITLE["rps_duel_cancel"], + description=S.RPS_UI["duel_insufficient"].format(mention=player.mention), + color=0xED4245, + ) + await interaction.response.edit_message(embed=embed, view=None) + async with self.game._lock: + self.game._resolved = True + active_games.discard(self.game.player_a.id) + active_games.discard(self.game.player_b.id) + return + + bet_str = S.RPS_UI["duel_active_bet"].format(bet=coin(self.game.bet)) if self.game.bet > 0 else "" + embed = discord.Embed( + title=S.TITLE["rps_duel_active"], + description=S.RPS_UI["duel_active_desc"].format( + a=self.game.player_a.mention, + b=self.game.player_b.mention, + bet=bet_str, + ), + color=0x5865F2, + ) + await interaction.response.edit_message(embed=embed, view=self) + + bet_dm = S.RPS_UI["duel_dm_bet"].format(bet=coin(self.game.bet)) if self.game.bet > 0 else "" + dm_failed: list[str] = [] + for player, side in ((self.game.player_a, "a"), (self.game.player_b, "b")): + view = RpsDmView(self.game, side) + opponent = self.game.player_b if side == "a" else self.game.player_a + try: + msg = await player.send( + S.RPS_UI["duel_dm"].format(opponent=opponent.display_name, bet=bet_dm), + view=view, + ) + if side == "a": + self.game.dm_msg_a = msg + else: + self.game.dm_msg_b = msg + except discord.Forbidden: + dm_failed.append(player.display_name) + + if dm_failed: + async with self.game._lock: + self.game._resolved = True + active_games.discard(self.game.player_a.id) + active_games.discard(self.game.player_b.id) + embed = discord.Embed( + title=S.TITLE["rps_duel_cancel"], + description=S.RPS_UI["duel_dm_fail"].format(names=", ".join(dm_failed)), + color=0xED4245, + ) + await self.game.server_message.edit(embed=embed, view=None) + + @discord.ui.button(label=S.RPS_UI["btn_decline"], style=discord.ButtonStyle.danger) + async def decline(self, interaction: discord.Interaction, _: discord.ui.Button): + if interaction.user.id != self.game.player_b.id: + await interaction.response.send_message(S.ERR["not_your_challenge"], ephemeral=True) + return + self.stop() + self._disable_all() + active_games.discard(self.game.player_a.id) + embed = discord.Embed( + title=S.TITLE["rps_duel_decline"], + description=S.RPS_UI["duel_decline"].format(name=self.game.player_b.display_name), + color=0xED4245, + ) + await interaction.response.edit_message(embed=embed, view=self) + + async def on_timeout(self) -> None: + active_games.discard(self.game.player_a.id) + self._disable_all() + if self.game.server_message: + embed = discord.Embed( + title=S.TITLE["rps_duel_expire"], + description=S.RPS_UI["duel_no_answer"].format(name=self.game.player_b.display_name), + color=0x99AAB5, + ) + await self.game.server_message.edit(embed=embed, view=self) + + @tree.command(name="rps", description=S.CMD["rps"]) + @app_commands.describe(panus=S.OPT["rps_panus"], vastane=S.OPT["rps_vastane"]) + async def cmd_rps(interaction: discord.Interaction, panus: str = "0", vastane: discord.Member | None = None): + data = await economy.get_user(interaction.user.id) + panus_int, err = parse_amount(panus, data["balance"]) + if err: + await interaction.response.send_message(err, ephemeral=True) + return + if panus_int is None or panus_int < 0: + await interaction.response.send_message(S.ERR["positive_bet"], ephemeral=True) + return + if panus_int > 0: + if rem := economy.jailed_remaining(data): + await interaction.response.send_message( + S.CD_MSG["jailed"].format(ts=cd_ts(rem)), ephemeral=True + ) + return + + # PvP mode + if vastane is not None: + if interaction.user.id in active_games: + await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) + return + if vastane.id == interaction.user.id: + await interaction.response.send_message(S.ERR["rps_self"], ephemeral=True) + return + if vastane.bot: + await interaction.response.send_message(S.ERR["rps_bot"], ephemeral=True) + return + if panus_int > 0 and data["balance"] < panus_int: + await interaction.response.send_message( + S.ERR["broke"].format(bal=coin(data["balance"])), ephemeral=True + ) + return + + game = RpsGame(interaction.user, vastane, panus_int) + bet_challenge = S.RPS_UI["challenge_bet"].format(bet=coin(panus_int)) if panus_int > 0 else "" + embed = discord.Embed( + title=S.TITLE["rps_duel"], + description=S.RPS_UI["challenge_desc"].format( + challenger=interaction.user.mention, + opponent=vastane.mention, + bet=bet_challenge, + ), + color=0x5865F2, + ) + embed.set_footer(text=S.RPS_UI["challenge_footer"]) + challenge_view = RpsChallengeView(game) + await interaction.response.send_message(embed=embed, view=challenge_view) + active_games.add(interaction.user.id) + game.server_message = await interaction.original_response() + return + + # vs Bot mode + if panus_int > 0 and data["balance"] < panus_int: + await interaction.response.send_message( + S.ERR["broke"].format(bal=coin(data["balance"])), ephemeral=True + ) + return + 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( + title=S.TITLE["rps"], + description=S.RPS_UI["vs_bot_desc"] + bet_str, + color=0x5865F2, + ) + await interaction.response.send_message(embed=embed, view=RPSView(interaction.user, panus_int)) + + # ----------------------------------------------------------------------- + # /slots + # ----------------------------------------------------------------------- + _SLOTS_SPIN = "" + _SLOTS_DELAY = 0.7 + + def _slots_embed( + r1: str, + r2: str, + r3: str, + title: str = "", # set dynamically + color: int = 0x5865F2, + footer: str = "", + ) -> discord.Embed: + desc = f"{r1} | {r2} | {r3}" + if footer: + desc += f"\n\n{footer}" + return discord.Embed(title=title, description=desc, color=color) + + @tree.command(name="slots", description=S.CMD["slots"]) + @app_commands.describe(panus=S.OPT["slots_panus"]) + async def cmd_slots(interaction: discord.Interaction, panus: str): + data = await economy.get_user(interaction.user.id) + panus_int, err = parse_amount(panus, data["balance"]) + if err: + await interaction.response.send_message(err, ephemeral=True) + return + if panus_int is None or 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 + active_games.add(interaction.user.id) + res = await economy.do_slots(interaction.user.id, panus_int) + if not res["ok"]: + active_games.discard(interaction.user.id) + if res["reason"] == "banned": + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + return + if res["reason"] == "jailed": + await interaction.response.send_message( + S.CD_MSG["jailed"].format(ts=cd_ts(res["remaining"])), ephemeral=True + ) + return + await interaction.response.send_message( + S.ERR["broke"].format(bal=coin(data["balance"])), ephemeral=True + ) + return + + reels = res["reels"] + tier = res["tier"] + change = res["change"] + sp = _SLOTS_SPIN + + try: + await interaction.response.send_message(embed=_slots_embed(sp, sp, sp, title=S.SLOTS_UI["playing"])) + msg = await interaction.original_response() + + await asyncio.sleep(_SLOTS_DELAY) + await msg.edit(embed=_slots_embed(reels[0], sp, sp, title=S.SLOTS_UI["playing"])) + await asyncio.sleep(_SLOTS_DELAY) + await msg.edit(embed=_slots_embed(reels[0], reels[1], sp, title=S.SLOTS_UI["playing"])) + await asyncio.sleep(_SLOTS_DELAY) + await msg.edit(embed=_slots_embed(reels[0], reels[1], reels[2], title=S.SLOTS_UI["playing"])) + await asyncio.sleep(_SLOTS_DELAY * 0.6) + + tier_key = tier if tier in S.SLOTS_TIERS else "miss" + title, color = S.SLOTS_TIERS[tier_key] + if tier == "jackpot": + footer = S.SLOTS_UI["jackpot_footer"].format(change=coin(change)) + elif tier == "triple": + footer = S.SLOTS_UI["triple_footer"].format(change=coin(change)) + elif tier == "pair": + footer = S.SLOTS_UI["pair_footer"].format(change=coin(change)) + else: + footer = S.SLOTS_UI["miss_footer"].format(amount=coin(panus_int)) + footer += S.SLOTS_UI["balance_line"].format(balance=coin(res["balance"])) + + await msg.edit( + embed=_slots_embed( + reels[0], + reels[1], + reels[2], + title=title, + color=color, + footer=footer, + ) + ) + if tier in ("jackpot", "triple", "pair"): + asyncio.create_task(award_exp(interaction, economy.gamble_exp(panus_int))) + finally: + active_games.discard(interaction.user.id) + + # ----------------------------------------------------------------------- + # /blackjack + # ----------------------------------------------------------------------- + _BJ_RANKS = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"] + _BJ_SUITS = ["♠", "♥", "♦", "♣"] + _BJ_DEAL_DELAY = 0.65 + + def _bj_deck() -> list[tuple[str, str]]: + deck = [(r, s) for r in _BJ_RANKS for s in _BJ_SUITS] + random.shuffle(deck) + return deck + + def _bj_value(hand: list[tuple[str, str]]) -> int: + total, aces = 0, 0 + for rank, _ in hand: + if rank == "A": + total += 11 + aces += 1 + elif rank in ("J", "Q", "K", "10"): + total += 10 + else: + total += int(rank) + while total > 21 and aces: + total -= 10 + aces -= 1 + return total + + def _bj_hand_str(hand: list[tuple[str, str]], hide_second: bool = False) -> str: + if hide_second and len(hand) >= 2: + return f"`{hand[0][0]}{hand[0][1]}` `🂠`" + return " ".join(f"`{r}{s}`" for r, s in hand) + + def _bj_is_blackjack(hand: list[tuple[str, str]]) -> bool: + return len(hand) == 2 and _bj_value(hand) == 21 + + def _bj_embed( + player_hand: list, + dealer_hand: list, + title: str, + color: int, + *, + hide_dealer: bool = True, + doubled_total: int = 0, + result_field: tuple | None = None, + ) -> discord.Embed: + p_str = _bj_hand_str(player_hand) if player_hand else "-" + p_val = f" `{_bj_value(player_hand)}`" if player_hand else "" + if not dealer_hand: + d_str, d_val = "-", "" + elif hide_dealer: + d_str = _bj_hand_str(dealer_hand, hide_second=True) + d_val = f" `{_bj_value([dealer_hand[0]])}`" + else: + d_str = _bj_hand_str(dealer_hand) + d_val = f" `{_bj_value(dealer_hand)}`" + desc = f"**{S.BJ_UI['dealer']}:** {d_str}{d_val}\n**{S.BJ_UI['player']}:** {p_str}{p_val}" + if doubled_total: + desc += "\n" + S.BJ["doubled_label"].format(total=coin(doubled_total)) + embed = discord.Embed(title=title, description=desc, color=color) + if result_field: + embed.add_field(name=result_field[0], value=result_field[1], inline=False) + return embed + + class BlackjackView(discord.ui.View): + def __init__( + self, + user_id: int, + bet: int, + player_hand: list, + dealer_hand: list, + deck: list, + ): + super().__init__(timeout=120) + self.user_id = user_id + self.bet = bet + self.hands: list[list] = [player_hand] + self.bets: list[int] = [bet] + self.hand_idx: int = 0 + self.dealer_hand = dealer_hand + self.deck = deck + self._doubled_hands: set[int] = set() + self._split_aces: bool = False + self.message: discord.Message | None = None + self._refresh_buttons() + + @property + def _cur_hand(self) -> list: + return self.hands[self.hand_idx] + + def _can_split(self) -> bool: + return ( + len(self.hands) == 1 + and len(self._cur_hand) == 2 + and self._cur_hand[0][0] == self._cur_hand[1][0] + ) + + def _refresh_buttons(self) -> None: + self.clear_items() + is_split = len(self.hands) > 1 + can_double = not is_split and 0 not in self._doubled_hands and len(self._cur_hand) == 2 + hit_btn = discord.ui.Button(label=S.BJ["btn_hit"], style=discord.ButtonStyle.primary) + hit_btn.callback = self._hit + stand_btn = discord.ui.Button(label=S.BJ["btn_stand"], style=discord.ButtonStyle.secondary) + stand_btn.callback = self._stand + double_btn = discord.ui.Button( + label=S.BJ["btn_double"].format(bet=self.bet), + style=discord.ButtonStyle.success, + disabled=not can_double, + ) + double_btn.callback = self._double + self.add_item(hit_btn) + self.add_item(stand_btn) + self.add_item(double_btn) + if self._can_split(): + split_btn = discord.ui.Button( + label=S.BJ["btn_split"].format(bet=self.bet), + style=discord.ButtonStyle.danger, + ) + split_btn.callback = self._split_hand + self.add_item(split_btn) + + def _cur_embed(self, game_over: bool = False, hand_results: list | None = None) -> discord.Embed: + if not self.dealer_hand: + d_str, d_val = "-", "" + elif not game_over: + d_str = _bj_hand_str(self.dealer_hand, hide_second=True) + d_val = f" `{_bj_value([self.dealer_hand[0]])}`" + else: + d_str = _bj_hand_str(self.dealer_hand) + d_val = f" `{_bj_value(self.dealer_hand)}`" + desc = f"**{S.BJ_UI['dealer']}:** {d_str}{d_val}" + + if len(self.hands) == 1: + hand = self.hands[0] + pv = _bj_value(hand) + doubled_str = f" 💰 *{coin(self.bets[0])}*" if 0 in self._doubled_hands else "" + desc += f"\n**{S.BJ_UI['player']}:** {_bj_hand_str(hand)} `{pv}`{doubled_str}" + else: + for i, hand in enumerate(self.hands): + pv = _bj_value(hand) + if hand_results and i < len(hand_results): + icon = {"win": "✅", "push": "🤝", "lose": "❌"}[hand_results[i]] + label = f"{icon} " + S.BJ_UI["hand_n"].format(n=i + 1) + elif game_over or i < self.hand_idx: + label = S.BJ_UI["hand_n"].format(n=i + 1) + elif i == self.hand_idx: + label = S.BJ_UI["hand_active"].format(n=i + 1) + else: + label = S.BJ_UI["hand_pending"].format(n=i + 1) + bust_str = S.BJ_UI["bust"] if pv > 21 else "" + desc += f"\n**{label}:** {_bj_hand_str(hand)} `{pv}`{bust_str}" + + return discord.Embed(title=S.TITLE["blackjack"], description=desc, color=0x5865F2) + + async def _resolve_all(self, interaction: discord.Interaction) -> None: + active_games.discard(self.user_id) + self.clear_items() + self.stop() + dv = _bj_value(self.dealer_hand) + total_payout = 0 + hand_results: list[str] = [] + + for hand, bet in zip(self.hands, self.bets): + pv = _bj_value(hand) + if pv > 21: + hand_results.append("lose") + elif dv > 21 or pv > dv: + hand_results.append("win") + total_payout += bet * 2 + elif pv == dv: + hand_results.append("push") + total_payout += bet + else: + hand_results.append("lose") + + total_invested = sum(self.bets) + res = await economy.do_blackjack_payout(self.user_id, total_payout, total_invested) + net = total_payout - total_invested + result_str = ( + f"+{coin(total_payout)}" + if net > 0 + else (S.BJ["push_result"] if net == 0 else f"-{coin(total_invested)}") + ) + + if len(self.hands) == 1: + r = hand_results[0] + doubled = 0 in self._doubled_hands + if r == "win": + title_key, color = ("blackjack_dwin" if doubled else "blackjack_win"), 0x57F287 + elif r == "push": + title_key, color = "blackjack_push", 0x99AAB5 + else: + pv = _bj_value(self.hands[0]) + if pv > 21: + title_key = "blackjack_dbust" if doubled else "blackjack_bust" + else: + title_key = "blackjack_lose" + color = 0xED4245 + else: + if net > 0: + title_key, color = "blackjack_win", 0x57F287 + elif net == 0: + title_key, color = "blackjack_push", 0x99AAB5 + else: + title_key, color = "blackjack_lose", 0xED4245 + + embed = self._cur_embed(game_over=True, hand_results=hand_results) + embed.title = S.TITLE[title_key] + embed.color = color + embed.add_field( + name=S.BJ["result_field"], + value=result_str + S.BJ_UI["balance_line"].format(balance=coin(res["balance"])), + inline=False, + ) + await self.message.edit(embed=embed, view=self) + if total_payout > total_invested: + asyncio.create_task(award_exp(interaction, economy.gamble_exp(total_invested))) + + async def _do_dealer_reveal(self, interaction: discord.Interaction) -> None: + await self.message.edit(embed=self._cur_embed(game_over=True), view=None) + await asyncio.sleep(_BJ_DEAL_DELAY) + while _bj_value(self.dealer_hand) < 17: + self.dealer_hand.append(self.deck.pop()) + await self.message.edit(embed=self._cur_embed(game_over=True), view=None) + await asyncio.sleep(_BJ_DEAL_DELAY) + await self._resolve_all(interaction) + + async def _advance_or_finish(self, interaction: discord.Interaction) -> None: + self.hand_idx += 1 + if self.hand_idx < len(self.hands): + self._refresh_buttons() + await self.message.edit(embed=self._cur_embed(), view=self) + else: + self.hand_idx = len(self.hands) - 1 + await self._do_dealer_reveal(interaction) + + async def _hit(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 + await interaction.response.defer() + self._cur_hand.append(self.deck.pop()) + val = _bj_value(self._cur_hand) + if val > 21: + await self.message.edit(embed=self._cur_embed(), view=None) + await asyncio.sleep(_BJ_DEAL_DELAY) + if len(self.hands) > 1: + await self._advance_or_finish(interaction) + else: + await self._resolve_all(interaction) + elif val == 21: + await self.message.edit(embed=self._cur_embed(), view=None) + await asyncio.sleep(_BJ_DEAL_DELAY * 0.5) + if len(self.hands) > 1: + await self._advance_or_finish(interaction) + else: + await self._do_dealer_reveal(interaction) + else: + self._refresh_buttons() + await self.message.edit(embed=self._cur_embed(), view=self) + + async def _stand(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 + await interaction.response.defer() + if len(self.hands) > 1: + await self._advance_or_finish(interaction) + else: + await self._do_dealer_reveal(interaction) + + async def _double(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 + res = await economy.do_blackjack_bet(self.user_id, self.bet) + if not res["ok"]: + await interaction.response.send_message( + S.ERR["broke"].format(bal=coin(res.get("balance", 0))), ephemeral=True + ) + return + await interaction.response.defer() + self._doubled_hands.add(0) + self.bets[0] *= 2 + self._cur_hand.append(self.deck.pop()) + await self.message.edit(embed=self._cur_embed(), view=None) + await asyncio.sleep(_BJ_DEAL_DELAY) + await self._do_dealer_reveal(interaction) + + async def _split_hand(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 + res = await economy.do_blackjack_bet(self.user_id, self.bet) + if not res["ok"]: + await interaction.response.send_message( + S.ERR["broke"].format(bal=coin(res.get("balance", 0))), ephemeral=True + ) + return + await interaction.response.defer() + card1, card2 = self._cur_hand[0], self._cur_hand[1] + self._split_aces = card1[0] == "A" + self.hands = [[card1, self.deck.pop()], [card2, self.deck.pop()]] + self.bets = [self.bet, self.bet] + self.hand_idx = 0 + await self.message.edit(embed=self._cur_embed(), view=None) + await asyncio.sleep(_BJ_DEAL_DELAY) + if self._split_aces: + await self._do_dealer_reveal(interaction) + else: + self._refresh_buttons() + await self.message.edit(embed=self._cur_embed(), view=self) + + async def on_timeout(self) -> None: + active_games.discard(self.user_id) + try: + await economy.do_blackjack_payout(self.user_id, 0, sum(self.bets)) + except Exception: + pass + self.clear_items() + if self.message: + try: + await self.message.edit(view=self) + except discord.HTTPException: + pass + + @tree.command(name="blackjack", description=S.CMD["blackjack"]) + @app_commands.describe(panus=S.OPT["blackjack_panus"]) + async def cmd_blackjack(interaction: discord.Interaction, panus: str): + data = await economy.get_user(interaction.user.id) + bet, err = parse_amount(panus, data["balance"]) + if err: + await interaction.response.send_message(err, ephemeral=True) + return + if bet is None or 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 + + res = await economy.do_blackjack_bet(interaction.user.id, bet) + if not res["ok"]: + if res["reason"] == "banned": + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + elif res["reason"] == "jailed": + await interaction.response.send_message( + S.CD_MSG["jailed"].format(ts=cd_ts(res["remaining"])), ephemeral=True + ) + else: + await interaction.response.send_message( + S.ERR["broke"].format(bal=coin(data["balance"])), ephemeral=True + ) + return + active_games.add(interaction.user.id) + + deck = _bj_deck() + player_hand: list = [] + dealer_hand: list = [] + + await interaction.response.send_message( + embed=discord.Embed(title=S.TITLE["blackjack"], description=S.BJ["dealing"], color=0x5865F2) + ) + msg = await interaction.original_response() + + for target in ["player", "dealer", "player", "dealer"]: + if target == "player": + player_hand.append(deck.pop()) + else: + dealer_hand.append(deck.pop()) + await asyncio.sleep(_BJ_DEAL_DELAY) + await msg.edit( + embed=_bj_embed( + player_hand, + dealer_hand, + S.TITLE["blackjack"], + 0x5865F2, + hide_dealer=True, + ) + ) + + await asyncio.sleep(_BJ_DEAL_DELAY * 0.5) + + if _bj_is_blackjack(player_hand): + await msg.edit( + embed=_bj_embed( + player_hand, + dealer_hand, + S.TITLE["blackjack"], + 0x5865F2, + hide_dealer=False, + ) + ) + await asyncio.sleep(_BJ_DEAL_DELAY) + if _bj_is_blackjack(dealer_hand): + push_res = await economy.do_blackjack_payout(interaction.user.id, bet, bet) + embed = _bj_embed( + player_hand, + dealer_hand, + S.TITLE["blackjack_push"], + 0x99AAB5, + hide_dealer=False, + result_field=( + S.BJ["result_field"], + S.BJ["push_result"] + S.BJ_UI["balance_line"].format(balance=coin(push_res["balance"])), + ), + ) + else: + payout = bet + int(bet * 1.5) + bj_res = await economy.do_blackjack_payout(interaction.user.id, payout, bet) + embed = _bj_embed( + player_hand, + dealer_hand, + S.TITLE["blackjack_bj"], + 0xF4C430, + hide_dealer=False, + result_field=( + S.BJ["result_field"], + f"+{coin(payout)}" + S.BJ_UI["balance_line"].format(balance=coin(bj_res["balance"])), + ), + ) + asyncio.create_task(award_exp(interaction, economy.gamble_exp(bet))) + active_games.discard(interaction.user.id) + await msg.edit(embed=embed) + return + + view = BlackjackView(interaction.user.id, bet, player_hand, dealer_hand, deck) + view.message = msg + await msg.edit( + embed=_bj_embed(player_hand, dealer_hand, S.TITLE["blackjack"], 0x5865F2, hide_dealer=True), + view=view, + ) diff --git a/economy_income_commands.py b/economy_income_commands.py new file mode 100644 index 0000000..10613f8 --- /dev/null +++ b/economy_income_commands.py @@ -0,0 +1,257 @@ +from __future__ import annotations + +import asyncio +import datetime +from collections.abc import Awaitable, Callable + +import discord +from discord import app_commands + +import economy +import strings as S + + +def register_economy_income_commands( + tree: app_commands.CommandTree, + bot: discord.Client, + coin: Callable[[int], str], + cd_ts: Callable[[datetime.timedelta], str], + check_cmd_rate: Callable[[discord.Interaction], Awaitable[bool]], + maybe_remind: Callable[[int, str], Awaitable[None]], + award_exp: Callable[[discord.Interaction, int], Awaitable[None]], +) -> None: + @tree.command(name="daily", description=S.CMD["daily"]) + async def cmd_daily(interaction: discord.Interaction): + res = await economy.do_daily(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["daily"].format(ts=cd_ts(res["remaining"])), + ephemeral=True, + ) + return + + streak = res["streak"] + streak_str = f"🔥 {streak}p" + ( + " (+200%)" + if res["streak_mult"] >= 3.0 + else " (+100%)" + if res["streak_mult"] >= 2.0 + else " (+50%)" + if res["streak_mult"] >= 1.5 + else "" + ) + lines = [S.DAILY_UI["earned"].format(earned=coin(res["earned"]))] + if res["interest"]: + lines.append(S.DAILY_UI["interest"].format(interest=coin(res["interest"]))) + if res["vip"]: + lines.append(S.DAILY_UI["vip"]) + lines.append(S.DAILY_UI["footer"].format(streak_str=streak_str, balance=coin(res["balance"]))) + + embed = discord.Embed(title=S.TITLE["daily"], description="\n".join(lines), color=0xF4C430) + await interaction.response.send_message(embed=embed) + asyncio.create_task(maybe_remind(interaction.user.id, "daily")) + asyncio.create_task(award_exp(interaction, economy.EXP_REWARDS["daily"])) + + @tree.command(name="work", description=S.CMD["work"]) + async def cmd_work(interaction: discord.Interaction): + if await check_cmd_rate(interaction): + return + res = await economy.do_work(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["work"].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 + + desc = S.WORK_UI["desc"].format(job=res["job"], earned=coin(res["earned"])) + if res["lucky"]: + desc += S.WORK_UI["redbull"] + if res["hiir"]: + desc += S.WORK_UI["hiir"] + if res["laud"]: + desc += S.WORK_UI["laud"] + desc += S.WORK_UI["balance"].format(balance=coin(res["balance"])) + embed = discord.Embed(title=S.TITLE["work"], description=desc, color=0x57F287) + await interaction.response.send_message(embed=embed) + asyncio.create_task(maybe_remind(interaction.user.id, "work")) + asyncio.create_task(award_exp(interaction, economy.EXP_REWARDS["work"])) + + @tree.command(name="beg", description=S.CMD["beg"]) + async def cmd_beg(interaction: discord.Interaction): + if await check_cmd_rate(interaction): + return + res = await economy.do_beg(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["beg"].format(ts=cd_ts(res["remaining"])), + ephemeral=True, + ) + return + + if res["jailed"]: + title = "🔒 " + S.TITLE["beg"] + color = 0xE67E22 + else: + title = S.TITLE["beg"] + color = 0x99AAB5 + beg_lines = [S.BEG_UI["desc"].format(text=res["text"], earned=coin(res["earned"]))] + if res["klaviatuur"]: + beg_lines.append(S.BEG_UI["klaviatuur"]) + beg_lines.append(S.BEG_UI["balance"].format(balance=coin(res["balance"]))) + embed = discord.Embed(title=title, description="\n".join(beg_lines), color=color) + await interaction.response.send_message(embed=embed) + asyncio.create_task(maybe_remind(interaction.user.id, "beg")) + asyncio.create_task(award_exp(interaction, economy.EXP_REWARDS["beg"])) + + @tree.command(name="crime", description=S.CMD["crime"]) + async def cmd_crime(interaction: discord.Interaction): + if await check_cmd_rate(interaction): + return + res = await economy.do_crime(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["crime"].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 + + if res["success"]: + crime_lines = [S.CRIME_UI["win_desc"].format(text=res["text"], earned=coin(res["earned"]))] + if res["mikrofon"]: + crime_lines.append(S.CRIME_UI["mikrofon"].lstrip("\n")) + crime_lines.append(S.CRIME_UI["balance"].lstrip("\n").format(balance=coin(res["balance"]))) + embed = discord.Embed( + title=S.TITLE["crime_win"], + description="\n".join(crime_lines), + color=0x57F287, + ) + else: + jail_part = ( + S.CRIME_UI["fail_jailed"].format(ts=cd_ts(economy.JAIL_DURATION)) + if res.get("jailed") + else S.CRIME_UI["fail_shield"] + ) + embed = discord.Embed( + title=S.TITLE["crime_fail"], + description=S.CRIME_UI["fail_base"].format(text=res["text"], fine=coin(res["fine"])) + + jail_part + + S.CRIME_UI["balance"].format(balance=coin(res["balance"])), + color=0xED4245, + ) + await interaction.response.send_message(embed=embed) + asyncio.create_task(maybe_remind(interaction.user.id, "crime")) + if res["success"]: + asyncio.create_task(award_exp(interaction, economy.EXP_REWARDS["crime_win"])) + + @tree.command(name="rob", description=S.CMD["rob"]) + async def cmd_rob(interaction: discord.Interaction, sihtmärk: discord.Member): + if await check_cmd_rate(interaction): + return + if sihtmärk.id == interaction.user.id: + await interaction.response.send_message(S.ERR["rob_self"], ephemeral=True) + return + if sihtmärk.bot and (bot.user is None or sihtmärk.id != bot.user.id): + await interaction.response.send_message(S.ERR["rob_bot"], ephemeral=True) + return + if bot.user and sihtmärk.id == bot.user.id: + await interaction.response.send_message(S.ERR["rob_house_blocked"], ephemeral=True) + return + + res = await economy.do_rob(interaction.user.id, sihtmärk.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["rob"].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, + ) + elif res["reason"] == "broke": + await interaction.response.send_message( + S.ERR["rob_too_poor"].format(name=sihtmärk.display_name), + ephemeral=True, + ) + elif res["reason"] == "target_jailed": + await interaction.response.send_message( + S.ERR["rob_target_jailed"].format(name=sihtmärk.display_name), + ephemeral=True, + ) + return + + if res["success"]: + if res.get("jackpot"): + desc = S.ROB_UI["jackpot_desc"].format(stolen=coin(res["stolen"]), balance=coin(res["balance"])) + color = 0xF4C430 + else: + desc = S.ROB_UI["win_desc"].format( + stolen=coin(res["stolen"]), + name=sihtmärk.display_name, + balance=coin(res["balance"]), + ) + color = 0x57F287 + embed = discord.Embed(title=S.TITLE["rob_win"], description=desc, color=color) + elif res["reason"] == "valvur": + embed = discord.Embed( + title=S.TITLE["rob_anticheat"], + description=S.ROB_UI["anticheat_desc"].format( + name=sihtmärk.display_name, + fine=coin(res["fine"]), + ), + color=0xED4245, + ) + target_data = await economy.get_user(sihtmärk.id) + if "anticheat" not in target_data.get("items", []): + try: + await sihtmärk.send(S.ROB_UI["anticheat_worn"]) + except discord.Forbidden: + pass + else: + embed = discord.Embed( + title=S.TITLE["rob_fail"], + description=S.ROB_UI["fail_desc"].format( + fine=coin(res["fine"]), + balance=coin(res["balance"]), + ), + color=0xED4245, + ) + await interaction.response.send_message(embed=embed) + 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 diff --git a/economy_prestige_commands.py b/economy_prestige_commands.py new file mode 100644 index 0000000..d9da335 --- /dev/null +++ b/economy_prestige_commands.py @@ -0,0 +1,249 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable + +import discord +from discord import app_commands + +import economy +import strings as S + + +def register_prestige_commands( + tree: app_commands.CommandTree, + check_cmd_rate: Callable[[discord.Interaction], Awaitable[bool]], + ensure_level_role: Callable[[discord.Member, int], Awaitable[None]], +) -> None: + 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) + + 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) diff --git a/economy_profile_commands.py b/economy_profile_commands.py new file mode 100644 index 0000000..1934d49 --- /dev/null +++ b/economy_profile_commands.py @@ -0,0 +1,545 @@ +from __future__ import annotations + +import asyncio +import datetime +from collections.abc import Awaitable, Callable + +import discord +from discord import app_commands + +import economy +import strings as S + + +def register_economy_profile_commands( + tree: app_commands.CommandTree, + coin: Callable[[int], str], + cd_ts: Callable[[datetime.timedelta], str], + ensure_level_role: Callable[[discord.Member, int], Awaitable[None]], +) -> None: + 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 item_id in data.get("items", []): + if item_id not in economy.SHOP: + continue + line = f"{economy.SHOP[item_id]['emoji']} **{economy.SHOP[item_id]['name']}**" + if item_id in uses_map: + uses = uses_map[item_id] + line += ( + S.BALANCE_UI["uses_one"].format(uses=uses) + if uses == 1 + else S.BALANCE_UI["uses_many"].format(uses=uses) + ) + 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(entry.get("value", 0) for entry in inv) + sell_btn = discord.ui.Button( + label=( + f"{S.FISH_UI['btn_sell']} " + f"({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 + + def _balance_embed(user: discord.User | discord.Member, data: dict) -> discord.Embed: + embed = discord.Embed( + title=f"{economy.COIN} {user.display_name}", + color=0xF4C430, + ) + embed.add_field(name=S.BALANCE_UI["saldo"], value=coin(data["balance"]), inline=True) + streak = data.get("daily_streak", 0) + if streak: + embed.add_field( + name=S.BALANCE_UI["streak"], + value=S.BALANCE_UI["streak_val"].format(streak=streak), + inline=True, + ) + jail_remaining = economy._is_jailed(data) + if jail_remaining: + embed.add_field(name=S.BALANCE_UI["jailed_until"], value=cd_ts(jail_remaining), inline=True) + item_lines = [] + uses_map = data.get("item_uses", {}) + for item_id in data.get("items", []): + if item_id not in economy.SHOP: + continue + line = f"{economy.SHOP[item_id]['emoji']} {economy.SHOP[item_id]['name']}" + if item_id in uses_map: + uses = uses_map[item_id] + line += ( + S.BALANCE_UI["uses_one"].format(uses=uses) + if uses == 1 + else S.BALANCE_UI["uses_many"].format(uses=uses) + ) + item_lines.append(line) + if item_lines: + embed.add_field(name=S.BALANCE_UI["items"], value="\n".join(item_lines), inline=False) + return embed + + @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)))) + + @tree.command(name="balance", description=S.CMD["balance"]) + async def cmd_balance(interaction: discord.Interaction, kasutaja: discord.Member | None = None): + target = kasutaja or interaction.user + data = await economy.get_user(target.id) + await interaction.response.send_message(embed=_balance_embed(target, data)) + + @tree.command(name="cooldowns", description=S.CMD["cooldowns"]) + async def cmd_cooldowns(interaction: discord.Interaction): + data = await economy.get_user(interaction.user.id) + now = datetime.datetime.now(datetime.timezone.utc) + items = set(data.get("items", [])) + + def _status(last_key: str, cooldown: datetime.timedelta) -> str: + raw = data.get(last_key) + if not raw: + return S.COOLDOWNS_UI["ready"] + last = economy._parse_dt(raw) + if last is None: + return S.COOLDOWNS_UI["ready"] + expires = last + cooldown + if expires <= now: + return S.COOLDOWNS_UI["ready"] + ts = int(expires.timestamp()) + return f"⏳ " + + 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 "", + ), + S.COOLDOWNS_UI["work_line"].format( + status=_status("last_work", work_cd), + note=S.COOLDOWNS_UI["note_monitor"] if "monitor" in items else "", + ), + 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") + if jailed: + jail_dt = datetime.datetime.fromisoformat(jailed) + if jail_dt > now: + ts = int(jail_dt.timestamp()) + lines.append(S.COOLDOWNS_UI["jailed"].format(ts=ts)) + else: + lines.append(S.COOLDOWNS_UI["jail_expired"]) + + embed = discord.Embed( + title=S.TITLE["cooldowns"], + description="\n".join(lines), + color=0x5865F2, + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + + @tree.command(name="jailed", description=S.CMD["jailed"]) + @app_commands.guild_only() + async def cmd_jailed(interaction: discord.Interaction): + await interaction.response.defer() + jailed = await economy.do_get_jailed() + if not jailed: + embed = discord.Embed( + title=S.JAILED_UI["title"], + description=S.JAILED_UI["empty"], + color=0x57F287, + ) + await interaction.followup.send(embed=embed) + return + + now = datetime.datetime.now(datetime.timezone.utc) + lines = [] + for uid, remaining in jailed: + ts = int((now + remaining).timestamp()) + member = interaction.guild.get_member(uid) if interaction.guild else None + mention = member.mention if member else f"<@{uid}>" + lines.append(S.JAILED_UI["entry"].format(mention=mention, ts=ts)) + + plural = "" if len(jailed) == 1 else "i" + embed = discord.Embed( + title=S.JAILED_UI["title"], + description="\n".join(lines), + color=0xED4245, + ) + embed.set_footer(text=S.JAILED_UI["footer"].format(count=len(jailed), plural=plural)) + await interaction.followup.send(embed=embed) + + @tree.command(name="rank", description=S.CMD["rank"]) + @app_commands.describe(kasutaja=S.OPT["rank_kasutaja"]) + async def cmd_rank(interaction: discord.Interaction, kasutaja: discord.Member | None = None): + target = kasutaja or interaction.user + data = await economy.get_user(target.id) + 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.RANK_UI["title"].format(name=target.display_name, level=level), + color=0x5865F2, + ) + embed.add_field(name=S.RANK_UI["field_title"], value=f"**{role_name}**", inline=True) + embed.add_field(name=S.RANK_UI["field_exp"], value=str(exp), inline=True) + embed.add_field( + name=S.RANK_UI["field_progress"].format(next=next_level), + 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: + embed.set_footer(text=S.RANK_UI["footer_t2"]) + else: + embed.set_footer(text=S.RANK_UI["footer_t3"]) + await interaction.response.send_message(embed=embed, ephemeral=True) + if not kasutaja and interaction.guild: + member = interaction.guild.get_member(target.id) + if member: + asyncio.create_task(ensure_level_role(member, level)) + + @tree.command(name="stats", description=S.CMD["stats"]) + @app_commands.describe(kasutaja=S.OPT["stats_kasutaja"]) + async def cmd_stats(interaction: discord.Interaction, kasutaja: discord.Member | None = None): + target = kasutaja or interaction.user + data = await economy.get_user(target.id) + + def _s(key: str) -> int: + return data.get(key, 0) + + embed = discord.Embed( + title=f"{S.TITLE['stats']} - {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, + ) + await interaction.response.send_message(embed=embed, ephemeral=True) diff --git a/economy_support_commands.py b/economy_support_commands.py new file mode 100644 index 0000000..1a66800 --- /dev/null +++ b/economy_support_commands.py @@ -0,0 +1,216 @@ +from __future__ import annotations + +from collections.abc import Callable + +import discord +from discord import app_commands + +import economy +import strings as S + + +def register_economy_support_commands( + tree: app_commands.CommandTree, + parse_amount: Callable[[str, int], tuple[int | None, str | None]], + coin: Callable[[int], str], + cancel_reminder_task: Callable[[int, str], None], +) -> None: + class FundModal(discord.ui.Modal): + summa = discord.ui.TextInput( + label=S.REQUEST_UI["modal_label"], + min_length=1, + max_length=10, + ) + + def __init__(self, view: "RequestView"): + super().__init__(title=S.REQUEST_UI["modal_title"]) + self._view = view + self.summa.placeholder = f"1 - {view.remaining}" + + async def on_submit(self, interaction: discord.Interaction): + amount, err = parse_amount(self.summa.value, 0) + if err or amount is None: + await interaction.response.send_message(S.ERR["invalid_amount"], ephemeral=True) + return + if amount <= 0 or amount > self._view.remaining: + await interaction.response.send_message( + S.ERR["fund_range"].format(max=self._view.remaining), ephemeral=True + ) + return + + res = await economy.do_give(interaction.user.id, self._view.requester.id, amount) + if not res["ok"]: + data = await economy.get_user(interaction.user.id) + await interaction.response.send_message( + S.ERR["broke"].format(bal=coin(data["balance"])), ephemeral=True + ) + return + + self._view.remaining -= amount + funded_line = S.REQUEST_UI["funded_line"].format( + name=interaction.user.display_name, + amount=coin(amount), + ) + if self._view.remaining <= 0: + self._view.fund_btn.disabled = True + self._view.fund_btn.label = S.REQUEST_UI["btn_funded"] + self._view.fund_btn.style = discord.ButtonStyle.secondary + self._view.stop() + funded_line += S.REQUEST_UI["funded_full"] + else: + self._view.fund_btn.label = S.REQUEST_UI["btn_fund_remaining"].format( + remaining=self._view.remaining + ) + funded_line += S.REQUEST_UI["funded_partial"].format( + remaining=coin(self._view.remaining) + ) + + await interaction.response.send_message(funded_line) + if self._view.message: + await self._view.message.edit(view=self._view) + + class RequestView(discord.ui.View): + def __init__(self, requester: discord.Member, amount: int, target: discord.Member | None): + super().__init__(timeout=300) + self.requester = requester + self.remaining = amount + self.target = target + self.message: discord.Message | None = None + self.fund_btn = discord.ui.Button( + label=S.REQUEST_UI["btn_fund"], + style=discord.ButtonStyle.success, + ) + self.fund_btn.callback = self._fund + self.add_item(self.fund_btn) + + async def _fund(self, interaction: discord.Interaction): + if interaction.user.id == self.requester.id: + await interaction.response.send_message(S.ERR["request_self_fund"], ephemeral=True) + return + if self.target and interaction.user.id != self.target.id: + await interaction.response.send_message( + S.ERR["request_targeted"].format(name=self.target.display_name), + ephemeral=True, + ) + return + await interaction.response.send_modal(FundModal(self)) + + async def on_timeout(self): + for item in self.children: + item.disabled = True + + max_request = 1_000_000 + + @tree.command(name="request", description=S.CMD["request"]) + @app_commands.describe( + summa=S.OPT["request_summa"], + põhjus=S.OPT["request_põhjus"], + sihtmärk=S.OPT["request_sihtmärk"], + ) + async def cmd_request( + interaction: discord.Interaction, + summa: str, + põhjus: str, + sihtmärk: discord.Member | None = None, + ): + summa_int, err = parse_amount(summa, 0) + if err or summa_int is None: + await interaction.response.send_message(err or S.ERR["invalid_amount"], ephemeral=True) + return + if summa_int <= 0: + await interaction.response.send_message(S.ERR["positive_amount"], ephemeral=True) + return + if summa_int > max_request: + await interaction.response.send_message( + S.ERR["fund_range"].format(max=coin(max_request)), + ephemeral=True, + ) + return + summa = summa_int + if sihtmärk and sihtmärk.id == interaction.user.id: + await interaction.response.send_message(S.ERR["request_self"], ephemeral=True) + return + if sihtmärk and sihtmärk.bot: + await interaction.response.send_message(S.ERR["request_bot"], ephemeral=True) + return + + audience = ( + S.REQUEST_UI["audience_targeted"].format(name=sihtmärk.display_name) + if sihtmärk + else S.REQUEST_UI["audience_all"] + ) + embed = discord.Embed( + title=S.TITLE["request"], + description=S.REQUEST_UI["desc"].format( + requester=interaction.user.display_name, + amount=coin(summa), + reason=põhjus, + audience=audience, + ), + color=0xF4C430, + ) + embed.set_footer(text=S.REQUEST_UI["footer"]) + view = RequestView(interaction.user, summa, sihtmärk) + await interaction.response.send_message(embed=embed, view=view) + view.message = await interaction.original_response() + + class RemindersSelect(discord.ui.Select): + def __init__(self, user_id: int, current: list[str]): + self.user_id = user_id + options = [ + discord.SelectOption( + label=label, + description=desc, + value=cmd, + default=cmd in current, + ) + for cmd, label, desc in S.REMINDER_OPTS + ] + super().__init__( + placeholder=S.REMINDERS_UI["select_placeholder"], + options=options, + min_values=0, + max_values=len(S.REMINDER_OPTS), + ) + + async def callback(self, interaction: discord.Interaction): + if interaction.user.id != self.user_id: + await interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True) + return + await economy.do_set_reminders(self.user_id, self.values) + enabled = set(self.values) + for cmd in [opt[0] for opt in S.REMINDER_OPTS]: + if cmd not in enabled: + cancel_reminder_task(self.user_id, cmd) + if self.values: + names = " ".join(f"`/{v}`" for v in self.values) + msg = S.REMINDERS_UI["saved_on"].format(names=names) + else: + msg = S.REMINDERS_UI["saved_off"] + await interaction.response.send_message(msg, ephemeral=True) + + class RemindersView(discord.ui.View): + def __init__(self, user_id: int, current: list[str]): + super().__init__(timeout=60) + self.add_item(RemindersSelect(user_id, current)) + + @tree.command(name="reminders", description=S.CMD["reminders"]) + async def cmd_reminders(interaction: discord.Interaction): + user_data = await economy.get_user(interaction.user.id) + current = user_data.get("reminders", []) + if current: + status = " ".join(f"`/{c}`" for c in current) + desc = S.REMINDERS_UI["desc_active"].format(status=status) + else: + desc = S.REMINDERS_UI["desc_none"] + embed = discord.Embed( + title=S.TITLE["reminders"], + description=desc, + color=0x5865F2, + ) + embed.set_footer(text=S.REMINDERS_UI["footer"]) + await interaction.response.send_message( + embed=embed, + view=RemindersView(interaction.user.id, current), + ephemeral=True, + ) 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