forked from sass/tipibot
Compare commits
1 Commits
691f160a09
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb3a91fc71 |
@@ -1,14 +0,0 @@
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.*
|
||||
*.pyc
|
||||
__pycache__
|
||||
data/
|
||||
logs/
|
||||
*.log
|
||||
*.md
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
compose.yaml
|
||||
credentials.json
|
||||
48
.env.example
48
.env.example
@@ -1,36 +1,17 @@
|
||||
|
||||
# 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
|
||||
DISCORD_BOT_LAN=your-lan-bot-token-here
|
||||
|
||||
# Legacy fallback token (optional, backward compatibility)
|
||||
DISCORD_TOKEN=
|
||||
# Discord bot token (from https://discord.com/developers/applications)
|
||||
DISCORD_TOKEN=your-bot-token-here
|
||||
|
||||
# Google Sheets spreadsheet ID (the long string in the sheet URL)
|
||||
SHEET_ID=your-google-sheet-id-here
|
||||
SHEET_ID_DEV=
|
||||
SHEET_ID_LAN=1cYEI2EmQDMZdVOarbOkVw7IHsnfqtYbWhA-6zC_r-zw
|
||||
|
||||
# Path to Google service account credentials JSON
|
||||
GOOGLE_CREDS_PATH=credentials.json
|
||||
|
||||
# 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
|
||||
GUILD_ID_LAN=1301145356750426192
|
||||
# Guild (server) ID - right-click your server with dev mode on
|
||||
GUILD_ID=your-guild-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=
|
||||
# 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
|
||||
|
||||
# How many days before a birthday the on-join check counts as "coming up"
|
||||
BIRTHDAY_WINDOW_DAYS=7
|
||||
@@ -39,20 +20,3 @@ 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
|
||||
PB_ECONOMY_COLLECTION_LAN=economy_users_lan
|
||||
PB_FIENTA_COLLECTION_LAN=fienta_registrations_lan
|
||||
|
||||
# Legacy fallback collection name (optional, backward compatibility)
|
||||
PB_ECONOMY_COLLECTION=
|
||||
|
||||
# Fienta LAN registration sync
|
||||
# Fienta production URLs:
|
||||
# https://veebikonks.tipilan.ee/fienta/purchase
|
||||
# https://veebikonks.tipilan.ee/fienta/registration
|
||||
FIENTA_WEBHOOK_SECRET=optional-secret-for-/fienta/webhook
|
||||
FIENTA_WEBHOOK_PORT=8090
|
||||
FIENTA_ADMIN_ALERT_CHANNEL_ID=1478302279894302812
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: linux
|
||||
steps:
|
||||
- name: Deploy
|
||||
run: |
|
||||
cd ~/tipibot
|
||||
git pull
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
systemctl restart tipibot
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -4,9 +4,9 @@ __pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
data/
|
||||
data/restart_channel.json
|
||||
data/economy.json
|
||||
pocketbase.exe
|
||||
pocketbase
|
||||
pb_data/
|
||||
pb_migrations/
|
||||
logs/
|
||||
197
DEV_NOTES.md
Normal file
197
DEV_NOTES.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# TipiLAN Bot - Developer Reference
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `bot.py` | All Discord commands, views (UI components), event handlers, reminder system |
|
||||
| `economy.py` | All economy logic, data model, constants (SHOP, COOLDOWNS, LEVEL_ROLES, etc.) |
|
||||
| `pb_client.py` | Async PocketBase REST client - auth token cache, CRUD on `economy_users` collection |
|
||||
| `strings.py` | **Single source of truth for all user-facing text.** Edit here to change any message. |
|
||||
| `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 |
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Economy Command
|
||||
|
||||
Checklist - do all of these, in order:
|
||||
|
||||
1. **`economy.py`** - add the `do_<cmd>` async function with cooldown check, logic, `_commit`, and `_txn` logging. Decorate it with `@_serialized()` (add `house=True` if it calls `_credit_house`, `id_args=(0, 1)` if it mutates two users) - this holds the per-user lock that prevents concurrent commands from overwriting each other's PocketBase writes.
|
||||
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_<name>` 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["<cmd>"])` 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
|
||||
|
||||
> **Safety net:** `economy.validate_config()` runs at boot (called from `bot.py __main__`) and cross-checks COOLDOWNS↔CD_MSG, SHOP↔SHOP_TIERS↔SHOP_LEVEL_REQ↔ITEM_DESCRIPTIONS, the /help shop page (emoji, cost, tier tag), REMINDER_OPTS, and the slots tables. A forgotten checklist step crashes the bot at startup with a message naming the missing entry instead of failing at command time.
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Shop Item
|
||||
|
||||
Checklist:
|
||||
|
||||
1. **`economy.py` `SHOP`** - add the item dict `{name, emoji, cost, description: strings.ITEM_DESCRIPTIONS["key"]}`
|
||||
2. **`economy.py` `SHOP_TIERS`** - add the key to the correct tier list (1/2/3)
|
||||
3. **`economy.py` `SHOP_LEVEL_REQ`** - add minimum level if it is T2 (≥10) or T3 (≥20)
|
||||
4. **`strings.py` `ITEM_DESCRIPTIONS`** - add the item description (Estonian flavour + English effect)
|
||||
5. **`strings.py` `HELP_CATEGORIES["shop"]["fields"]`** - add display entry (sorted by cost)
|
||||
6. If the item modifies a cooldown:
|
||||
- **`economy.py`** - add the `if "item" in user["items"]` branch in the relevant `do_<cmd>` function
|
||||
- **`bot.py` `_maybe_remind`** - add `elif cmd == "<cmd>" and "<item>" in items:` branch with the new delay
|
||||
- **`bot.py` `cmd_cooldowns`** - add the item annotation to the relevant status line
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Level Role
|
||||
|
||||
1. **`economy.py` `LEVEL_ROLES`** - add `(min_level, "RoleName")` in descending level order (highest first)
|
||||
2. **`bot.py` `_ensure_level_role`** - no changes needed (uses `LEVEL_ROLES` dynamically)
|
||||
3. Run **`/economysetup`** in the server to create the role and set its position
|
||||
|
||||
---
|
||||
|
||||
## 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()`
|
||||
|
||||
---
|
||||
|
||||
## Economy System Design
|
||||
|
||||
### Storage
|
||||
|
||||
All economy state is stored in **PocketBase** (`economy_users` collection). `pb_client.py` owns all reads/writes. Each `do_*` function in `economy.py` calls `get_user()` → mutates the local dict → calls `_commit()`. `_commit` does a `PATCH` to PocketBase.
|
||||
|
||||
### Currency & Income Sources
|
||||
|
||||
| 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 |
|
||||
| `/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 |
|
||||
| `/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% |
|
||||
| `/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 |
|
||||
|
||||
### "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`.
|
||||
|
||||
### Daily Streak Multipliers
|
||||
- 1-2 days: ×1.0 (150⬡)
|
||||
- 3-6 days: ×1.5 (225⬡)
|
||||
- 7-13 days: ×2.0 (300⬡)
|
||||
- 14+ days: ×3.0 (450⬡)
|
||||
- `karikas` item: streak survives missed days
|
||||
|
||||
### Jail
|
||||
- Normal duration: 30 minutes (`JAIL_DURATION`)
|
||||
- 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.
|
||||
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
## Role Hierarchy (Discord)
|
||||
|
||||
Order top to bottom in server roles:
|
||||
|
||||
```
|
||||
[Bot managed role] ← bot's own role, always at top of our stack
|
||||
ECONOMY ← given to everyone who uses any economy command
|
||||
TipiLEGEND ← level 30+
|
||||
TipiCHAD ← level 20+
|
||||
TipiHUSTLER ← level 10+
|
||||
TipiGRINDER ← level 5+
|
||||
TipiNOOB ← level 1+
|
||||
```
|
||||
|
||||
Run `/economysetup` to auto-create all roles and set their positions. The command is idempotent - safe to run multiple times.
|
||||
|
||||
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`
|
||||
|
||||
---
|
||||
|
||||
## Shop Tiers & Level Requirements
|
||||
|
||||
| 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 |
|
||||
|
||||
Shop display is sorted by cost (ascending) within each tier.
|
||||
The `SHOP_LEVEL_REQ` dict in `economy.py` controls per-item lock thresholds.
|
||||
|
||||
---
|
||||
|
||||
## strings.py Organisation
|
||||
|
||||
| Section | Dict | Usage in bot.py |
|
||||
|---|---|---|
|
||||
| Flavour text | `WORK_JOBS`, `BEG_LINES`, `CRIME_WIN`, `CRIME_LOSE` | Randomised descriptions |
|
||||
| Command descriptions | `CMD["key"]` | `@tree.command(description=S.CMD["key"])` |
|
||||
| Parameter descriptions | `OPT["key"]` | `@app_commands.describe(param=S.OPT["key"])` |
|
||||
| Help embed | `HELP_CATEGORIES["cat"]` | `cmd_help` |
|
||||
| Banned message | `MSG_BANNED` | All banned checks |
|
||||
| Reminder options | `REMINDER_OPTS` | `RemindersSelect` dropdown |
|
||||
| Slots outcomes | `SLOTS_TIERS["tier"]` → `(title, color)` | `cmd_slots` |
|
||||
| Embed titles | `TITLE["key"]` | `discord.Embed(title=S.TITLE["key"])` |
|
||||
| Error messages | `ERR["key"]` | `send_message(S.ERR["key"])` - use `.format(**kwargs)` for dynamic parts |
|
||||
| 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"]` |
|
||||
|
||||
---
|
||||
|
||||
## Constants Location Quick-Reference
|
||||
|
||||
| Constant | File | Description |
|
||||
|---|---|---|
|
||||
| `SHOP` | `economy.py` | All shop items (name, emoji, cost, description) |
|
||||
| `SHOP_TIERS` | `economy.py` | Which items are in T1/T2/T3 |
|
||||
| `SHOP_LEVEL_REQ` | `economy.py` | Min level per item |
|
||||
| `COOLDOWNS` | `economy.py` | Base cooldown per command |
|
||||
| `JAIL_DURATION` | `economy.py` | How long jail lasts |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
---
|
||||
|
||||
## Balance Notes (as of current version)
|
||||
|
||||
- **Beg** is most efficient for active players (3min cooldown + 2× multiplier w/ `klaviatuur` = high ⬡/hr)
|
||||
- **Work** is best for passive players (1h cooldown, fire and forget)
|
||||
- **Crime** is high risk/reward - best with `cat6` + `mikrofon`
|
||||
- **`lan_pass`** (1200⬡) doubles daily - good long-term investment
|
||||
- **`gaming_laptop`** (1500⬡) 5% interest, capped 500⬡/day - snowballs with large balance
|
||||
- `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
|
||||
15
Dockerfile
15
Dockerfile
@@ -1,15 +0,0 @@
|
||||
FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Create data and logs directories
|
||||
RUN mkdir -p data logs
|
||||
|
||||
CMD ["python", "bot.py"]
|
||||
104
README.md
104
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 two collections for profile separation: `economy_users_dev` and `economy_users_prod` - see `docs/POCKETBASE_SETUP.md` for schema notes.
|
||||
4. Create a collection named `economy_users` - see `docs/POCKETBASE_SETUP.md` for the full schema.
|
||||
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,34 +88,15 @@ cp .env.example .env
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `BOT_PROFILE` | Runtime profile: `dev`, `economy`, or `lan` |
|
||||
| `DISCORD_TOKEN_DEV` | Dev bot token from Discord Developer Portal |
|
||||
| `DISCORD_TOKEN_ECONOMY` | Economy bot token from Discord Developer Portal |
|
||||
| `DISCORD_BOT_LAN` | LAN bot token from Discord Developer Portal |
|
||||
| `DISCORD_TOKEN` | Legacy fallback token (optional) |
|
||||
| `DISCORD_TOKEN` | Bot token from Discord Developer Portal |
|
||||
| `SHEET_ID` | ID from the Google Sheet URL |
|
||||
| `SHEET_ID_DEV` | Optional dev/member sheet override |
|
||||
| `SHEET_ID_LAN` | LAN public registration sheet ID |
|
||||
| `GOOGLE_CREDS_PATH` | Path to `credentials.json` (default: `credentials.json`) |
|
||||
| `GUILD_ID_DEV` | Dev bot guild ID |
|
||||
| `GUILD_ID_ECONOMY` | Economy bot guild ID |
|
||||
| `GUILD_ID_LAN` | LAN 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) |
|
||||
| `GUILD_ID` | Right-click server → Copy Server ID (Developer Mode on) |
|
||||
| `BIRTHDAY_CHANNEL_ID` | Channel for birthday `@here` pings |
|
||||
| `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_LAN` | PocketBase economy collection used by `BOT_PROFILE=lan` |
|
||||
| `PB_FIENTA_COLLECTION_LAN` | PocketBase collection for LAN Fienta registration records |
|
||||
| `PB_ECONOMY_COLLECTION` | Legacy fallback collection (optional) |
|
||||
| `FIENTA_WEBHOOK_SECRET` | Optional secret path token for `/fienta/webhook/<secret>` testing |
|
||||
| `FIENTA_WEBHOOK_PORT` | LAN Fienta webhook listen port (default: `8090`) |
|
||||
| `FIENTA_ADMIN_ALERT_CHANNEL_ID` | Discord channel for LAN Fienta sync alerts |
|
||||
|
||||
### 6. Install & Run
|
||||
|
||||
@@ -129,12 +110,7 @@ pip install -r requirements.txt
|
||||
# Terminal 1 - keep running
|
||||
.\pocketbase.exe serve
|
||||
|
||||
# Terminal 2 (dev bot)
|
||||
set BOT_PROFILE=dev
|
||||
python bot.py
|
||||
|
||||
# Terminal 3 (economy bot)
|
||||
set BOT_PROFILE=economy
|
||||
# Terminal 2
|
||||
python bot.py
|
||||
```
|
||||
|
||||
@@ -170,8 +146,6 @@ 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 |
|
||||
|---|---|---|
|
||||
@@ -180,18 +154,16 @@ 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 <kogus> <põhjus>` | Manage Guild | Give (positive) or take (negative) TipiCOINi. Balance floored at 0. User gets a DM with reason. |
|
||||
| `/adminexp @user <kogus> <põhjus>` | Manage Guild | Give (positive) or take (negative) EXP. Level roles auto-updated. User gets a DM. |
|
||||
| `/adminitem @user <ese> <anna\|eemalda>` | 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 <minutid> <põhjus>` | 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 <põhjus>` | Manage Guild | Ban a user from all economy commands. User gets a DM. |
|
||||
| `/adminunban @user` | Manage Guild | Lift an economy ban. |
|
||||
| `/adminreset @user <põhjus>` | Manage Guild | Wipe a user's balance, items, and streak to zero. User gets a DM. |
|
||||
| `/adminview @user` | Manage Guild | Full profile: balance, EXP/level, streak, prestige, fish stats, items, ban/jail, all timestamps. |
|
||||
| `/adminview @user` | Manage Guild | Inspect a user's full economy profile: balance, streak, items, jail status, ban status. |
|
||||
|
||||
### `/check` output example
|
||||
```
|
||||
@@ -254,11 +226,10 @@ 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). 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. |
|
||||
| `/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. |
|
||||
| `/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
|
||||
|
||||
@@ -283,12 +254,10 @@ 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))`
|
||||
|
||||
@@ -345,16 +314,10 @@ 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 leaderboard with 6 tabs: 🪙 Coins, 📊 EXP, 🏆 Season EXP, 🔥 Prestige, 🎲 Wagered, 🎣 Fish caught. House pinned at #0 on coins tab. |
|
||||
| `/leaderboard` | Paginated coin leaderboard (10/page). House pinned at #0. ◀/▶ to browse; 📍 **Mina** jumps to your page. Has a separate EXP/level tab. |
|
||||
| `/shop` | Browse all items by tier. Shows owned status, Anticheat charges remaining, and level lock for T2/T3. |
|
||||
| `/buy <item>` | Purchase an item by name (partial match accepted). |
|
||||
| `/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 <upgrade>` | Purchase a prestige upgrade using Prestige Points. |
|
||||
| `/reminders` | Toggle per-command DM notifications. **All reminders are on by default.** Bot DMs you the moment each cooldown expires. |
|
||||
|
||||
---
|
||||
|
||||
@@ -363,7 +326,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`
|
||||
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:
|
||||
Roll two dice - matching values (doubles) free you instantly. **3 attempts** per jail sentence. 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
|
||||
@@ -372,41 +335,6 @@ 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.
|
||||
@@ -434,12 +362,6 @@ 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 |
|
||||
@@ -447,8 +369,6 @@ 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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,314 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from typing import Callable
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
|
||||
from core import sheets
|
||||
import strings as S
|
||||
from core.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)
|
||||
@@ -1,93 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
|
||||
import discord
|
||||
|
||||
import config
|
||||
from core import sheets
|
||||
from core.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)
|
||||
@@ -1,310 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
|
||||
from core 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)
|
||||
@@ -1,883 +0,0 @@
|
||||
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
|
||||
|
||||
from core 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)
|
||||
@@ -1,380 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
from collections.abc import Awaitable, Callable, MutableSet
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
|
||||
from core 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)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,257 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
|
||||
from core import economy
|
||||
import strings as S
|
||||
|
||||
|
||||
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
|
||||
@@ -1,249 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
|
||||
from core 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)
|
||||
@@ -1,545 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
|
||||
from core import economy
|
||||
import strings as S
|
||||
|
||||
|
||||
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"⏳ <t:{ts}:R>"
|
||||
|
||||
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)
|
||||
@@ -1,216 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
|
||||
from core 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,
|
||||
)
|
||||
@@ -1,28 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
|
||||
from core import lan_fienta
|
||||
import strings as S
|
||||
|
||||
|
||||
def register_lan_fienta_commands(
|
||||
tree: app_commands.CommandTree,
|
||||
bot: discord.Client,
|
||||
log: logging.Logger,
|
||||
) -> None:
|
||||
@tree.command(name="fientasync", description=S.CMD["fientasync"])
|
||||
@app_commands.guild_only()
|
||||
@app_commands.default_permissions(manage_guild=True)
|
||||
async def cmd_fientasync(interaction: discord.Interaction):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
try:
|
||||
summary = await lan_fienta.resync_all(bot)
|
||||
except Exception as exc:
|
||||
log.exception("/fientasync failed")
|
||||
await interaction.followup.send(f"❌ Fienta sync failed: `{exc}`", ephemeral=True)
|
||||
return
|
||||
await interaction.followup.send(f"✅ Fienta sync done: `{summary.short()}`", ephemeral=True)
|
||||
@@ -1,134 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Awaitable, Callable
|
||||
from pathlib import Path
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
|
||||
import strings as S
|
||||
|
||||
|
||||
def register_ops_admin_commands(
|
||||
tree: app_commands.CommandTree,
|
||||
bot: discord.Client,
|
||||
log: logging.Logger,
|
||||
process,
|
||||
start_time: datetime.datetime,
|
||||
log_dir: Path,
|
||||
guild_obj: discord.Object,
|
||||
restart_file: Path,
|
||||
get_member_cache_size: Callable[[], int],
|
||||
get_paused: Callable[[], bool],
|
||||
set_paused: Callable[[bool], None],
|
||||
count_economy_users: Callable[[], Awaitable[int]],
|
||||
) -> None:
|
||||
@tree.command(name="status", description=S.CMD["status"])
|
||||
@app_commands.guild_only()
|
||||
@app_commands.default_permissions(manage_guild=True)
|
||||
async def cmd_status(interaction: discord.Interaction):
|
||||
mem = process.memory_info()
|
||||
cpu = process.cpu_percent(interval=0.1)
|
||||
uptime = datetime.datetime.now() - start_time
|
||||
hours, rem = divmod(int(uptime.total_seconds()), 3600)
|
||||
minutes, seconds = divmod(rem, 60)
|
||||
tasks_count = len(asyncio.all_tasks())
|
||||
latency_ms = round(bot.latency * 1000, 1)
|
||||
cache_size = get_member_cache_size()
|
||||
user_count = await count_economy_users()
|
||||
|
||||
embed = discord.Embed(title=S.STATUS_UI["title"], color=0x57F287)
|
||||
embed.add_field(
|
||||
name=S.STATUS_UI["uptime_field"],
|
||||
value=S.STATUS_UI["uptime_val"].format(hours=hours, minutes=minutes, seconds=seconds),
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(
|
||||
name=S.STATUS_UI["latency_field"],
|
||||
value=S.STATUS_UI["latency_val"].format(ms=latency_ms),
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(
|
||||
name=S.STATUS_UI["ram_field"],
|
||||
value=S.STATUS_UI["ram_val"].format(mb=f"{mem.rss / 1024**2:.1f}"),
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(
|
||||
name=S.STATUS_UI["cpu_field"],
|
||||
value=S.STATUS_UI["cpu_val"].format(percent=f"{cpu:.1f}"),
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(
|
||||
name=S.STATUS_UI["tasks_field"],
|
||||
value=str(tasks_count),
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(
|
||||
name=S.STATUS_UI["eco_players_field"],
|
||||
value=str(user_count),
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(
|
||||
name=S.STATUS_UI["members_cache_field"],
|
||||
value=str(cache_size),
|
||||
inline=True,
|
||||
)
|
||||
|
||||
log_lines = [
|
||||
S.STATUS_UI["log_line"].format(name=p.name, size_kb=f"{p.stat().st_size / 1024:.1f}")
|
||||
for p in sorted(log_dir.glob("*.log*"))
|
||||
if p.is_file()
|
||||
]
|
||||
embed.add_field(
|
||||
name=S.STATUS_UI["log_files_field"],
|
||||
value="\n".join(log_lines) or S.STATUS_UI["none"],
|
||||
inline=False,
|
||||
)
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
|
||||
@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="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):
|
||||
paused = not get_paused()
|
||||
set_paused(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)
|
||||
@@ -1,135 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
|
||||
from core import economy
|
||||
import strings as S
|
||||
|
||||
|
||||
def register_ops_channel_commands(
|
||||
tree: app_commands.CommandTree,
|
||||
bot: discord.Client,
|
||||
log: logging.Logger,
|
||||
get_allowed_channels: Callable[[], list[int]],
|
||||
set_allowed_channels: Callable[[list[int]], None],
|
||||
) -> None:
|
||||
@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"],
|
||||
)
|
||||
@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
|
||||
)
|
||||
|
||||
@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)
|
||||
|
||||
all_role_names = [economy.ECONOMY_ROLE] + [name for _, name in economy.LEVEL_ROLES]
|
||||
|
||||
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"• <#{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)
|
||||
35
compose.yaml
35
compose.yaml
@@ -1,35 +0,0 @@
|
||||
services:
|
||||
pocketbase:
|
||||
image: ghcr.io/muchobien/pocketbase:latest
|
||||
container_name: tipibot-pocketbase
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- pb_data:/pb_data
|
||||
ports:
|
||||
- "8090:8090"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8090/api/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
bot:
|
||||
build: .
|
||||
container_name: tipibot
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
pocketbase:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- PB_URL=http://pocketbase:8090
|
||||
expose:
|
||||
- "8090"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
- ./credentials.json:/app/credentials.json:ro
|
||||
|
||||
volumes:
|
||||
pb_data:
|
||||
81
config.py
81
config.py
@@ -3,87 +3,14 @@ from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
BOT_PROFILE = os.getenv("BOT_PROFILE", "dev").strip().lower() or "dev"
|
||||
if BOT_PROFILE not in {"dev", "economy", "lan"}:
|
||||
raise SystemExit("BOT_PROFILE must be either 'dev', 'economy', or 'lan'.")
|
||||
|
||||
|
||||
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_BOT_LAN = os.getenv("DISCORD_BOT_LAN", "")
|
||||
DISCORD_TOKEN = {
|
||||
"dev": DISCORD_TOKEN_DEV,
|
||||
"economy": DISCORD_TOKEN_ECONOMY,
|
||||
"lan": DISCORD_BOT_LAN,
|
||||
}[BOT_PROFILE] or _LEGACY_DISCORD_TOKEN
|
||||
|
||||
SHEET_ID_DEV = os.getenv("SHEET_ID_DEV", "").strip()
|
||||
SHEET_ID_LAN = os.getenv("SHEET_ID_LAN", "").strip()
|
||||
SHEET_ID = (
|
||||
SHEET_ID_LAN
|
||||
if BOT_PROFILE == "lan"
|
||||
else SHEET_ID_DEV or os.getenv("SHEET_ID")
|
||||
)
|
||||
DISCORD_TOKEN = os.getenv("DISCORD_TOKEN")
|
||||
SHEET_ID = os.getenv("SHEET_ID")
|
||||
GOOGLE_CREDS_PATH = os.getenv("GOOGLE_CREDS_PATH", "credentials.json")
|
||||
|
||||
_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_LAN = _env_int("GUILD_ID_LAN", 0)
|
||||
GUILD_ID = {
|
||||
"dev": GUILD_ID_DEV,
|
||||
"economy": GUILD_ID_ECONOMY,
|
||||
"lan": GUILD_ID_LAN,
|
||||
}[BOT_PROFILE]
|
||||
|
||||
_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
|
||||
)
|
||||
|
||||
GUILD_ID = int(os.getenv("GUILD_ID", "0"))
|
||||
BIRTHDAY_CHANNEL_ID = int(os.getenv("BIRTHDAY_CHANNEL_ID", "0"))
|
||||
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_LAN = (
|
||||
os.getenv("PB_ECONOMY_COLLECTION_LAN", "").strip()
|
||||
or (_LEGACY_PB_COLLECTION if _LEGACY_PB_COLLECTION else "economy_users_lan")
|
||||
)
|
||||
PB_ECONOMY_COLLECTION = {
|
||||
"dev": PB_ECONOMY_COLLECTION_DEV,
|
||||
"economy": PB_ECONOMY_COLLECTION_ECONOMY,
|
||||
"lan": PB_ECONOMY_COLLECTION_LAN,
|
||||
}[BOT_PROFILE]
|
||||
|
||||
PB_FIENTA_COLLECTION_LAN = (
|
||||
os.getenv("PB_FIENTA_COLLECTION_LAN", "").strip()
|
||||
or "fienta_registrations_lan"
|
||||
)
|
||||
|
||||
FIENTA_WEBHOOK_SECRET = os.getenv("FIENTA_WEBHOOK_SECRET", "").strip()
|
||||
FIENTA_WEBHOOK_PORT = _env_int("FIENTA_WEBHOOK_PORT", 8090)
|
||||
FIENTA_ADMIN_ALERT_CHANNEL_ID = _env_int("FIENTA_ADMIN_ALERT_CHANNEL_ID", 0)
|
||||
|
||||
@@ -1,868 +0,0 @@
|
||||
"""Fienta registration sync for the LAN bot profile.
|
||||
|
||||
The module keeps Fienta data in PocketBase, assigns Discord roles, and mirrors
|
||||
public tournament teams into the LAN live registration sheet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime as dt
|
||||
import logging
|
||||
import re
|
||||
import unicodedata
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import discord
|
||||
import gspread
|
||||
from google.oauth2.service_account import Credentials
|
||||
|
||||
import config
|
||||
from . import pb_client
|
||||
|
||||
log = logging.getLogger("tipilan.fienta")
|
||||
|
||||
SCOPES = [
|
||||
"https://www.googleapis.com/auth/spreadsheets",
|
||||
"https://www.googleapis.com/auth/drive",
|
||||
]
|
||||
|
||||
CS2_GENERAL_ROLE_ID = 1498736834656604251
|
||||
LOL_GENERAL_ROLE_ID = 1498736949706490017
|
||||
LANGUAGE_GENERAL_ROLE_ID = 1416417344984715366
|
||||
CS2_CAPTAIN_ROLE_ID = 1498738332316860426
|
||||
CS2_MANAGER_ROLE_ID = 1498738500558655679
|
||||
|
||||
EXISTING_LANGUAGE_ROLE_IDS = {
|
||||
"EE": 1425781026482950245,
|
||||
"LV": 1425781129528606740,
|
||||
"FI": 1425781429618348073,
|
||||
}
|
||||
|
||||
CONFIRMED_TEXT = "Kinnitatud"
|
||||
PENDING_TEXT = "Kinnitamisel"
|
||||
|
||||
CANCELLED_STATUSES = {"CANCELLED", "REFUNDED", "EXPIRED", "VOIDED"}
|
||||
BLOCKED_COUNTRY_CODES = {"BY", "RU"}
|
||||
BLOCKED_COUNTRY_NAMES = {
|
||||
"belarus",
|
||||
"russia",
|
||||
"russian federation",
|
||||
"valgevene",
|
||||
"venemaa",
|
||||
"vene föderatsioon",
|
||||
"vene foderatsioon",
|
||||
}
|
||||
|
||||
TICKET_TYPES: dict[str, dict[str, Any]] = {
|
||||
"595507": {
|
||||
"game": "cs2",
|
||||
"kind": "participant",
|
||||
"sheet_public": True,
|
||||
"main": True,
|
||||
},
|
||||
"595509": {
|
||||
"game": "cs2",
|
||||
"kind": "reserve",
|
||||
"sheet_public": False,
|
||||
"main": False,
|
||||
},
|
||||
"595510": {
|
||||
"game": "cs2",
|
||||
"kind": "manager",
|
||||
"sheet_public": False,
|
||||
"main": False,
|
||||
},
|
||||
"595912": {
|
||||
"game": "lol",
|
||||
"kind": "participant",
|
||||
"sheet_public": True,
|
||||
"main": True,
|
||||
},
|
||||
}
|
||||
|
||||
SHEET_CONFIG = {
|
||||
"cs2": {"worksheet": "CS2", "start_row": 6, "end_row": 37, "cols": 6},
|
||||
"lol": {"worksheet": "LoL", "start_row": 6, "end_row": 17, "cols": 5},
|
||||
}
|
||||
|
||||
COUNTRY_CODE_BY_NAME = {
|
||||
"afghanistan": "AF",
|
||||
"albaania": "AL",
|
||||
"albania": "AL",
|
||||
"andorra": "AD",
|
||||
"armeenia": "AM",
|
||||
"armenia": "AM",
|
||||
"austria": "AT",
|
||||
"austria vabariik": "AT",
|
||||
"azerbaijan": "AZ",
|
||||
"aserbaidžaan": "AZ",
|
||||
"belgia": "BE",
|
||||
"belgium": "BE",
|
||||
"bosnia ja hertsegoviina": "BA",
|
||||
"bosnia and herzegovina": "BA",
|
||||
"bulgaaria": "BG",
|
||||
"bulgaria": "BG",
|
||||
"canada": "CA",
|
||||
"kanada": "CA",
|
||||
"croatia": "HR",
|
||||
"eesti": "EE",
|
||||
"estonia": "EE",
|
||||
"est": "EE",
|
||||
"denmark": "DK",
|
||||
"taani": "DK",
|
||||
"finland": "FI",
|
||||
"soome": "FI",
|
||||
"france": "FR",
|
||||
"prantsusmaa": "FR",
|
||||
"georgia": "GE",
|
||||
"gruusia": "GE",
|
||||
"germany": "DE",
|
||||
"saksamaa": "DE",
|
||||
"greece": "GR",
|
||||
"kreeka": "GR",
|
||||
"hungary": "HU",
|
||||
"ungari": "HU",
|
||||
"iceland": "IS",
|
||||
"island": "IS",
|
||||
"ireland": "IE",
|
||||
"iirimaa": "IE",
|
||||
"italy": "IT",
|
||||
"itaalia": "IT",
|
||||
"japan": "JP",
|
||||
"jaapan": "JP",
|
||||
"kazakhstan": "KZ",
|
||||
"kasahstan": "KZ",
|
||||
"latvia": "LV",
|
||||
"läti": "LV",
|
||||
"lati": "LV",
|
||||
"liechtenstein": "LI",
|
||||
"lithuania": "LT",
|
||||
"leedu": "LT",
|
||||
"luxembourg": "LU",
|
||||
"luksemburg": "LU",
|
||||
"malta": "MT",
|
||||
"moldova": "MD",
|
||||
"montenegro": "ME",
|
||||
"netherlands": "NL",
|
||||
"holland": "NL",
|
||||
"madalmaad": "NL",
|
||||
"norway": "NO",
|
||||
"norra": "NO",
|
||||
"poland": "PL",
|
||||
"poola": "PL",
|
||||
"portugal": "PT",
|
||||
"romania": "RO",
|
||||
"rumeenia": "RO",
|
||||
"serbia": "RS",
|
||||
"slovakia": "SK",
|
||||
"slovakkia": "SK",
|
||||
"slovenia": "SI",
|
||||
"sloveenia": "SI",
|
||||
"spain": "ES",
|
||||
"hispaania": "ES",
|
||||
"sweden": "SE",
|
||||
"rootsi": "SE",
|
||||
"switzerland": "CH",
|
||||
"šveits": "CH",
|
||||
"sveits": "CH",
|
||||
"turkey": "TR",
|
||||
"türgi": "TR",
|
||||
"ukraine": "UA",
|
||||
"ukraina": "UA",
|
||||
"united kingdom": "GB",
|
||||
"suurbritannia": "GB",
|
||||
"great britain": "GB",
|
||||
"united states": "US",
|
||||
"usa": "US",
|
||||
"ameerika ühendriigid": "US",
|
||||
"ameerika uhendriigid": "US",
|
||||
"belarus": "BY",
|
||||
"valgevene": "BY",
|
||||
"russia": "RU",
|
||||
"russian federation": "RU",
|
||||
"venemaa": "RU",
|
||||
}
|
||||
|
||||
COUNTRY_ROLE_COLOURS = {
|
||||
"EE": 0x0072CE,
|
||||
"LV": 0x9E3039,
|
||||
"FI": 0x003580,
|
||||
"LT": 0xFDB913,
|
||||
"SE": 0x006AA7,
|
||||
"DE": 0xDD0000,
|
||||
"PL": 0xDC143C,
|
||||
"UA": 0x0057B7,
|
||||
"GB": 0x012169,
|
||||
"US": 0x3C3B6E,
|
||||
}
|
||||
|
||||
_client: gspread.Client | None = None
|
||||
_spreadsheet: gspread.Spreadsheet | None = None
|
||||
_sync_lock = asyncio.Lock()
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncSummary:
|
||||
saved: int = 0
|
||||
created: int = 0
|
||||
updated: int = 0
|
||||
roles_synced: int = 0
|
||||
unmatched: int = 0
|
||||
sheet_rows: int = 0
|
||||
alerts: list[str] = field(default_factory=list)
|
||||
|
||||
def short(self) -> str:
|
||||
return (
|
||||
f"saved={self.saved}, created={self.created}, updated={self.updated}, "
|
||||
f"roles={self.roles_synced}, unmatched={self.unmatched}, sheet_rows={self.sheet_rows}, "
|
||||
f"alerts={len(self.alerts)}"
|
||||
)
|
||||
|
||||
|
||||
def _text_field(name: str, required: bool = False) -> dict:
|
||||
return {
|
||||
"name": name,
|
||||
"type": "text",
|
||||
"required": required,
|
||||
"options": {"min": None, "max": None, "pattern": ""},
|
||||
}
|
||||
|
||||
|
||||
def _bool_field(name: str) -> dict:
|
||||
return {"name": name, "type": "bool", "required": False}
|
||||
|
||||
|
||||
def fienta_collection_payload() -> dict:
|
||||
fields = [
|
||||
_text_field("registration_key", required=True),
|
||||
_text_field("order_id"),
|
||||
_text_field("ticket_code"),
|
||||
_text_field("order_status"),
|
||||
_text_field("order_url"),
|
||||
_text_field("payment_time"),
|
||||
_text_field("game"),
|
||||
_text_field("kind"),
|
||||
_text_field("ticket_type_id"),
|
||||
_text_field("ticket_title"),
|
||||
_text_field("ticket_group_title"),
|
||||
_text_field("team_name"),
|
||||
_text_field("discord_username"),
|
||||
_text_field("nickname"),
|
||||
_text_field("country"),
|
||||
_text_field("country_code"),
|
||||
_text_field("riot_id"),
|
||||
_text_field("steam64_id"),
|
||||
_text_field("vrs_ranking"),
|
||||
_bool_field("is_main"),
|
||||
_bool_field("is_reserve"),
|
||||
_bool_field("is_manager"),
|
||||
_bool_field("is_captain"),
|
||||
_bool_field("sheet_public"),
|
||||
_bool_field("blocked_country"),
|
||||
_bool_field("active"),
|
||||
_bool_field("roles_synced"),
|
||||
_text_field("last_sync_error"),
|
||||
_text_field("updated_at"),
|
||||
]
|
||||
return {
|
||||
"name": config.PB_FIENTA_COLLECTION_LAN,
|
||||
"type": "base",
|
||||
"fields": fields,
|
||||
"listRule": None,
|
||||
"viewRule": None,
|
||||
"createRule": None,
|
||||
"updateRule": None,
|
||||
"deleteRule": None,
|
||||
}
|
||||
|
||||
|
||||
async def ensure_storage() -> bool:
|
||||
"""Create the LAN Fienta collection when it does not exist."""
|
||||
return await pb_client.ensure_collection(
|
||||
config.PB_FIENTA_COLLECTION_LAN,
|
||||
fienta_collection_payload(),
|
||||
)
|
||||
|
||||
|
||||
def _strip_accents(value: str) -> str:
|
||||
normalized = unicodedata.normalize("NFKD", value)
|
||||
return "".join(ch for ch in normalized if not unicodedata.combining(ch))
|
||||
|
||||
|
||||
def _norm(value: Any) -> str:
|
||||
return re.sub(r"\s+", " ", str(value or "")).strip()
|
||||
|
||||
|
||||
def _norm_key(value: Any) -> str:
|
||||
return _strip_accents(_norm(value)).casefold()
|
||||
|
||||
|
||||
def _discord_key(value: Any) -> str:
|
||||
return _norm(value).lstrip("@").casefold()
|
||||
|
||||
|
||||
def _country_code(country: str) -> str:
|
||||
raw = _norm(country)
|
||||
if not raw:
|
||||
return ""
|
||||
key = _norm_key(raw)
|
||||
if key in COUNTRY_CODE_BY_NAME:
|
||||
return COUNTRY_CODE_BY_NAME[key]
|
||||
for name, code in COUNTRY_CODE_BY_NAME.items():
|
||||
if _norm_key(name) == key:
|
||||
return code
|
||||
upper = raw.upper()
|
||||
if re.fullmatch(r"[A-Z]{2}", upper):
|
||||
return upper
|
||||
letters = re.sub(r"[^A-Z]", "", _strip_accents(upper))
|
||||
return (letters[:2] or "XX").upper()
|
||||
|
||||
|
||||
def _is_blocked_country(country: str, code: str) -> bool:
|
||||
country_key = _norm_key(country)
|
||||
return code in BLOCKED_COUNTRY_CODES or any(
|
||||
_norm_key(name) == country_key for name in BLOCKED_COUNTRY_NAMES
|
||||
)
|
||||
|
||||
|
||||
def _role_safe_name(name: str) -> str:
|
||||
cleaned = re.sub(r"\s+", " ", _norm(name)).strip("@# ")
|
||||
return cleaned[:90] or "Unknown"
|
||||
|
||||
|
||||
def _team_role_name(game: str, team_name: str) -> str:
|
||||
prefix = "CS2" if game == "cs2" else "LoL"
|
||||
return f"[{prefix}] {_role_safe_name(team_name)}"[:100]
|
||||
|
||||
|
||||
def _language_role_name(code: str) -> str:
|
||||
return f"[{code.upper()}]"
|
||||
|
||||
|
||||
def _role_colour_for_country(code: str) -> discord.Color:
|
||||
if code in COUNTRY_ROLE_COLOURS:
|
||||
return discord.Color(COUNTRY_ROLE_COLOURS[code])
|
||||
seed = sum(ord(ch) for ch in code)
|
||||
hue = seed % 6
|
||||
colours = [0x5865F2, 0x57F287, 0xFEE75C, 0xEB459E, 0xED4245, 0x00A8FC]
|
||||
return discord.Color(colours[hue])
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return dt.datetime.now(dt.timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _ticket_rows(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
order = payload.get("order") or {}
|
||||
order_id = _norm(order.get("id"))
|
||||
status = _norm(order.get("status")).upper()
|
||||
payment = order.get("payment") or {}
|
||||
payment_time = _norm(payment.get("time"))
|
||||
order_url = _norm(order.get("order_url"))
|
||||
results: list[dict[str, Any]] = []
|
||||
|
||||
for ticket in order.get("tickets") or []:
|
||||
ticket_code = _norm(ticket.get("code"))
|
||||
for idx, row in enumerate(ticket.get("rows") or []):
|
||||
ticket_type = row.get("ticket_type") or {}
|
||||
ticket_type_id = _norm(ticket_type.get("id"))
|
||||
mapping = TICKET_TYPES.get(ticket_type_id)
|
||||
if not mapping:
|
||||
continue
|
||||
|
||||
attendee = row.get("attendee") or {}
|
||||
country = _norm(attendee.get("country"))
|
||||
code = _country_code(country)
|
||||
kind = str(mapping["kind"])
|
||||
nickname = (
|
||||
_norm(attendee.get("nickname_134815"))
|
||||
or _norm(attendee.get("nickname_134816"))
|
||||
or _norm(attendee.get("full_name"))
|
||||
)
|
||||
captain_raw = _norm(attendee.get("tiimi_kapten_134872")).casefold()
|
||||
registration_key = f"{order_id}:{ticket_code}:{idx}"
|
||||
results.append(
|
||||
{
|
||||
"registration_key": registration_key,
|
||||
"order_id": order_id,
|
||||
"ticket_code": ticket_code,
|
||||
"order_status": status,
|
||||
"order_url": order_url,
|
||||
"payment_time": payment_time,
|
||||
"game": mapping["game"],
|
||||
"kind": kind,
|
||||
"ticket_type_id": ticket_type_id,
|
||||
"ticket_title": _norm(ticket_type.get("title")),
|
||||
"ticket_group_title": _norm((ticket_type.get("ticket_type_group") or {}).get("title")),
|
||||
"team_name": _norm(attendee.get("team_name_134821")),
|
||||
"discord_username": _norm(attendee.get("discord_username_134871")),
|
||||
"nickname": nickname,
|
||||
"country": country,
|
||||
"country_code": code,
|
||||
"riot_id": _norm(attendee.get("riot_id_134870")),
|
||||
"steam64_id": _norm(attendee.get("steam64_id_134819")),
|
||||
"vrs_ranking": _norm(attendee.get("team_vrs_ranking_134825")),
|
||||
"is_main": bool(mapping["main"]),
|
||||
"is_reserve": kind == "reserve",
|
||||
"is_manager": kind == "manager",
|
||||
"is_captain": captain_raw in {"jah", "yes", "true", "1"},
|
||||
"sheet_public": bool(mapping["sheet_public"]),
|
||||
"blocked_country": _is_blocked_country(country, code),
|
||||
"active": status == "COMPLETED",
|
||||
"roles_synced": False,
|
||||
"last_sync_error": "",
|
||||
"updated_at": _now_iso(),
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
async def process_payload(bot: discord.Client, payload: dict[str, Any]) -> SyncSummary:
|
||||
"""Store a Fienta webhook payload and resync LAN roles/sheets."""
|
||||
async with _sync_lock:
|
||||
summary = SyncSummary()
|
||||
await ensure_storage()
|
||||
rows = _ticket_rows(payload)
|
||||
if not rows:
|
||||
summary.alerts.append("Fienta webhook did not contain any known tournament ticket rows.")
|
||||
await _send_alerts(bot, summary.alerts)
|
||||
return summary
|
||||
|
||||
for row in rows:
|
||||
_, created = await pb_client.upsert_record_by_field(
|
||||
config.PB_FIENTA_COLLECTION_LAN,
|
||||
"registration_key",
|
||||
row["registration_key"],
|
||||
row,
|
||||
)
|
||||
summary.saved += 1
|
||||
if created:
|
||||
summary.created += 1
|
||||
else:
|
||||
summary.updated += 1
|
||||
|
||||
resync = await resync_all(bot, send_alerts=False)
|
||||
summary.roles_synced += resync.roles_synced
|
||||
summary.unmatched += resync.unmatched
|
||||
summary.sheet_rows += resync.sheet_rows
|
||||
summary.alerts.extend(resync.alerts)
|
||||
await _send_alerts(bot, summary.alerts)
|
||||
return summary
|
||||
|
||||
|
||||
async def resync_all(bot: discord.Client, send_alerts: bool = True) -> SyncSummary:
|
||||
"""Re-apply all stored Fienta registrations to Discord and Sheets."""
|
||||
summary = SyncSummary()
|
||||
await ensure_storage()
|
||||
records = await _all_registration_records()
|
||||
await _sync_roles(bot, records, summary)
|
||||
sheet_rows, sheet_alerts = await asyncio.to_thread(_sync_public_sheets, records)
|
||||
summary.sheet_rows += sheet_rows
|
||||
summary.alerts.extend(sheet_alerts)
|
||||
if send_alerts:
|
||||
await _send_alerts(bot, summary.alerts)
|
||||
return summary
|
||||
|
||||
|
||||
async def sync_member_join(bot: discord.Client, member: discord.Member) -> SyncSummary:
|
||||
"""Apply any stored registrations that match a newly joined member."""
|
||||
if member.guild.id != config.GUILD_ID:
|
||||
return SyncSummary()
|
||||
summary = SyncSummary()
|
||||
await ensure_storage()
|
||||
target = _discord_key(member.name)
|
||||
records = [
|
||||
record
|
||||
for record in await _all_registration_records()
|
||||
if _discord_key(record.get("discord_username")) == target
|
||||
]
|
||||
if not records:
|
||||
return summary
|
||||
await _sync_roles(bot, records, summary, preloaded_member=member)
|
||||
await _send_alerts(bot, summary.alerts)
|
||||
return summary
|
||||
|
||||
|
||||
async def count_records() -> int:
|
||||
await ensure_storage()
|
||||
return await pb_client.count_records_in(config.PB_FIENTA_COLLECTION_LAN)
|
||||
|
||||
|
||||
async def _all_registration_records() -> list[dict[str, Any]]:
|
||||
return await pb_client.list_all_records_in(config.PB_FIENTA_COLLECTION_LAN)
|
||||
|
||||
|
||||
async def _sync_roles(
|
||||
bot: discord.Client,
|
||||
records: list[dict[str, Any]],
|
||||
summary: SyncSummary,
|
||||
preloaded_member: discord.Member | None = None,
|
||||
) -> None:
|
||||
guild = bot.get_guild(config.GUILD_ID)
|
||||
if guild is None:
|
||||
summary.alerts.append(f"LAN guild {config.GUILD_ID} is not available to the bot.")
|
||||
return
|
||||
if preloaded_member is None:
|
||||
await _ensure_member_cache(guild)
|
||||
|
||||
captain_counts: dict[tuple[str, str], int] = {}
|
||||
for record in records:
|
||||
if (
|
||||
record.get("game") == "cs2"
|
||||
and record.get("is_main")
|
||||
and record.get("is_captain")
|
||||
and record.get("active")
|
||||
):
|
||||
key = ("cs2", _norm_key(record.get("team_name")))
|
||||
captain_counts[key] = captain_counts.get(key, 0) + 1
|
||||
for (_, team_key), count in captain_counts.items():
|
||||
if count > 1:
|
||||
team = next(
|
||||
(_norm(r.get("team_name")) for r in records if _norm_key(r.get("team_name")) == team_key),
|
||||
team_key,
|
||||
)
|
||||
summary.alerts.append(f"Multiple CS2 captains marked for team `{team}` ({count}).")
|
||||
|
||||
for record in records:
|
||||
error = await _sync_record_roles(guild, record, summary, preloaded_member)
|
||||
try:
|
||||
await pb_client.update_record_in(
|
||||
config.PB_FIENTA_COLLECTION_LAN,
|
||||
record["id"],
|
||||
{
|
||||
"roles_synced": not bool(error),
|
||||
"last_sync_error": error,
|
||||
"updated_at": _now_iso(),
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
summary.alerts.append(
|
||||
f"Could not update sync state for `{record.get('registration_key')}`: {exc}"
|
||||
)
|
||||
|
||||
|
||||
async def _sync_record_roles(
|
||||
guild: discord.Guild,
|
||||
record: dict[str, Any],
|
||||
summary: SyncSummary,
|
||||
preloaded_member: discord.Member | None = None,
|
||||
) -> str:
|
||||
team_name = _norm(record.get("team_name"))
|
||||
username = _norm(record.get("discord_username"))
|
||||
game = _norm(record.get("game"))
|
||||
status = _norm(record.get("order_status")).upper()
|
||||
country = _norm(record.get("country"))
|
||||
country_code = _norm(record.get("country_code"))
|
||||
|
||||
if status in CANCELLED_STATUSES:
|
||||
summary.alerts.append(
|
||||
f"Registration `{record.get('registration_key')}` is {status}; no automatic role removal was done."
|
||||
)
|
||||
return "inactive order"
|
||||
if not record.get("active"):
|
||||
summary.alerts.append(
|
||||
f"Registration `{record.get('registration_key')}` is `{status or 'UNKNOWN'}`; roles not assigned yet."
|
||||
)
|
||||
return "order not completed"
|
||||
if record.get("blocked_country"):
|
||||
summary.alerts.append(
|
||||
f"Blocked country registration skipped: `{username}` / `{team_name}` / `{country}`."
|
||||
)
|
||||
return "blocked country"
|
||||
if not username:
|
||||
summary.unmatched += 1
|
||||
summary.alerts.append(f"Registration `{record.get('registration_key')}` has no Discord username.")
|
||||
return "missing Discord username"
|
||||
if not team_name:
|
||||
summary.alerts.append(f"Registration `{record.get('registration_key')}` has no team name.")
|
||||
return "missing team name"
|
||||
|
||||
member = preloaded_member or _find_member_by_username(guild, username)
|
||||
if member is None:
|
||||
summary.unmatched += 1
|
||||
summary.alerts.append(f"No Discord member found for `{username}` ({game.upper()} `{team_name}`).")
|
||||
return "Discord member not found"
|
||||
|
||||
roles: list[discord.Role] = []
|
||||
general_role_id = CS2_GENERAL_ROLE_ID if game == "cs2" else LOL_GENERAL_ROLE_ID
|
||||
general_role = guild.get_role(general_role_id)
|
||||
if general_role is None:
|
||||
return f"general role {general_role_id} not found"
|
||||
roles.append(general_role)
|
||||
|
||||
team_role = await _get_or_create_role(
|
||||
guild,
|
||||
_team_role_name(game, team_name),
|
||||
anchor=general_role,
|
||||
colour=general_role.color if general_role.color.value else discord.Color.default(),
|
||||
)
|
||||
roles.append(team_role)
|
||||
|
||||
language_general = guild.get_role(LANGUAGE_GENERAL_ROLE_ID)
|
||||
if language_general:
|
||||
roles.append(language_general)
|
||||
if country_code:
|
||||
country_role = await _get_or_create_country_role(guild, country_code, language_general)
|
||||
roles.append(country_role)
|
||||
else:
|
||||
summary.alerts.append(f"Missing country for `{username}` ({game.upper()} `{team_name}`).")
|
||||
else:
|
||||
summary.alerts.append(f"Language general role {LANGUAGE_GENERAL_ROLE_ID} not found.")
|
||||
|
||||
if game == "cs2" and record.get("is_main") and record.get("is_captain"):
|
||||
captain_role = guild.get_role(CS2_CAPTAIN_ROLE_ID)
|
||||
if captain_role:
|
||||
roles.append(captain_role)
|
||||
else:
|
||||
summary.alerts.append(f"CS2 Captain role {CS2_CAPTAIN_ROLE_ID} not found.")
|
||||
if game == "cs2" and record.get("is_manager"):
|
||||
manager_role = guild.get_role(CS2_MANAGER_ROLE_ID)
|
||||
if manager_role:
|
||||
roles.append(manager_role)
|
||||
else:
|
||||
summary.alerts.append(f"CS2 Manager role {CS2_MANAGER_ROLE_ID} not found.")
|
||||
|
||||
missing = [role for role in _unique_roles(roles) if role not in member.roles]
|
||||
if missing:
|
||||
try:
|
||||
await member.add_roles(*missing, reason="Fienta LAN registration sync")
|
||||
except discord.Forbidden:
|
||||
return "bot lacks permission to add roles"
|
||||
except discord.HTTPException as exc:
|
||||
return f"Discord role add failed: {exc}"
|
||||
summary.roles_synced += 1
|
||||
return ""
|
||||
|
||||
|
||||
async def _ensure_member_cache(guild: discord.Guild) -> None:
|
||||
try:
|
||||
if not guild.chunked:
|
||||
await guild.chunk(cache=True)
|
||||
except Exception as exc:
|
||||
log.warning("Could not chunk guild members for Fienta sync: %s", exc)
|
||||
|
||||
|
||||
def _find_member_by_username(guild: discord.Guild, username: str) -> discord.Member | None:
|
||||
target = _discord_key(username)
|
||||
if not target:
|
||||
return None
|
||||
for member in guild.members:
|
||||
candidates = [member.name, getattr(member, "global_name", None), member.display_name]
|
||||
if any(_discord_key(candidate) == target for candidate in candidates if candidate):
|
||||
return member
|
||||
return None
|
||||
|
||||
|
||||
def _unique_roles(roles: list[discord.Role]) -> list[discord.Role]:
|
||||
seen: set[int] = set()
|
||||
result: list[discord.Role] = []
|
||||
for role in roles:
|
||||
if role.id not in seen:
|
||||
seen.add(role.id)
|
||||
result.append(role)
|
||||
return result
|
||||
|
||||
|
||||
async def _get_or_create_country_role(
|
||||
guild: discord.Guild,
|
||||
country_code: str,
|
||||
anchor: discord.Role,
|
||||
) -> discord.Role:
|
||||
code = country_code.upper()
|
||||
existing_id = EXISTING_LANGUAGE_ROLE_IDS.get(code)
|
||||
if existing_id:
|
||||
role = guild.get_role(existing_id)
|
||||
if role:
|
||||
return role
|
||||
return await _get_or_create_role(
|
||||
guild,
|
||||
_language_role_name(code),
|
||||
anchor=anchor,
|
||||
colour=_role_colour_for_country(code),
|
||||
)
|
||||
|
||||
|
||||
async def _get_or_create_role(
|
||||
guild: discord.Guild,
|
||||
name: str,
|
||||
anchor: discord.Role,
|
||||
colour: discord.Color,
|
||||
) -> discord.Role:
|
||||
role = discord.utils.get(guild.roles, name=name)
|
||||
if role is None:
|
||||
role = await guild.create_role(name=name, color=colour, reason="Fienta LAN registration sync")
|
||||
await _move_role_under(guild, role, anchor)
|
||||
return role
|
||||
|
||||
|
||||
async def _move_role_under(guild: discord.Guild, role: discord.Role, anchor: discord.Role) -> None:
|
||||
if role.position == max(anchor.position - 1, 1):
|
||||
return
|
||||
try:
|
||||
await guild.edit_role_positions(positions={role: max(anchor.position - 1, 1)})
|
||||
except discord.Forbidden:
|
||||
log.warning("No permission to move role %s under %s", role.name, anchor.name)
|
||||
except discord.HTTPException as exc:
|
||||
log.warning("Could not move role %s under %s: %s", role.name, anchor.name, exc)
|
||||
|
||||
|
||||
def _get_spreadsheet() -> gspread.Spreadsheet:
|
||||
global _client, _spreadsheet
|
||||
if _spreadsheet is not None:
|
||||
return _spreadsheet
|
||||
creds = Credentials.from_service_account_file(config.GOOGLE_CREDS_PATH, scopes=SCOPES)
|
||||
_client = gspread.authorize(creds)
|
||||
_spreadsheet = _client.open_by_key(config.SHEET_ID)
|
||||
return _spreadsheet
|
||||
|
||||
|
||||
def _sync_public_sheets(records: list[dict[str, Any]]) -> tuple[int, list[str]]:
|
||||
alerts: list[str] = []
|
||||
rows_written = 0
|
||||
spreadsheet = _get_spreadsheet()
|
||||
for game in ("cs2", "lol"):
|
||||
cfg = SHEET_CONFIG[game]
|
||||
try:
|
||||
worksheet = spreadsheet.worksheet(cfg["worksheet"])
|
||||
except gspread.WorksheetNotFound:
|
||||
alerts.append(f"Worksheet `{cfg['worksheet']}` not found in LAN live sheet.")
|
||||
continue
|
||||
teams = _public_teams(records, game)
|
||||
rows_written += _write_game_sheet(worksheet, game, teams, alerts)
|
||||
return rows_written, alerts
|
||||
|
||||
|
||||
def _public_teams(records: list[dict[str, Any]], game: str) -> list[dict[str, Any]]:
|
||||
by_team: dict[str, dict[str, Any]] = {}
|
||||
for record in records:
|
||||
status = _norm(record.get("order_status")).upper()
|
||||
if record.get("game") != game or not record.get("sheet_public"):
|
||||
continue
|
||||
if record.get("blocked_country") or status in CANCELLED_STATUSES:
|
||||
continue
|
||||
team_name = _norm(record.get("team_name"))
|
||||
if not team_name:
|
||||
continue
|
||||
key = _norm_key(team_name)
|
||||
team = by_team.setdefault(
|
||||
key,
|
||||
{
|
||||
"team_name": team_name,
|
||||
"lineup": [],
|
||||
"vrs": "",
|
||||
"payment_time": _norm(record.get("payment_time")),
|
||||
"confirmed": True,
|
||||
},
|
||||
)
|
||||
team["lineup"].append(
|
||||
{
|
||||
"nickname": _norm(record.get("nickname")),
|
||||
"country": _norm(record.get("country")),
|
||||
"country_code": _norm(record.get("country_code")),
|
||||
}
|
||||
)
|
||||
if not team["vrs"] and record.get("vrs_ranking"):
|
||||
team["vrs"] = _norm(record.get("vrs_ranking"))
|
||||
if _norm(record.get("payment_time")) < team["payment_time"]:
|
||||
team["payment_time"] = _norm(record.get("payment_time"))
|
||||
if status != "COMPLETED":
|
||||
team["confirmed"] = False
|
||||
return sorted(by_team.values(), key=lambda t: (t["payment_time"], _norm_key(t["team_name"])))
|
||||
|
||||
|
||||
def _write_game_sheet(
|
||||
worksheet: gspread.Worksheet,
|
||||
game: str,
|
||||
teams: list[dict[str, Any]],
|
||||
alerts: list[str],
|
||||
) -> int:
|
||||
cfg = SHEET_CONFIG[game]
|
||||
start_row = int(cfg["start_row"])
|
||||
end_row = int(cfg["end_row"])
|
||||
capacity = end_row - start_row + 1
|
||||
existing = worksheet.get(f"B{start_row}:B{end_row}")
|
||||
by_name: dict[str, int] = {}
|
||||
empty_rows: list[int] = []
|
||||
for offset in range(capacity):
|
||||
row_num = start_row + offset
|
||||
value = ""
|
||||
if offset < len(existing) and existing[offset]:
|
||||
value = _norm(existing[offset][0])
|
||||
if value:
|
||||
by_name[_norm_key(value)] = row_num
|
||||
else:
|
||||
empty_rows.append(row_num)
|
||||
|
||||
rows_written = 0
|
||||
for team in teams:
|
||||
key = _norm_key(team["team_name"])
|
||||
row_num = by_name.get(key)
|
||||
if row_num is None:
|
||||
if not empty_rows:
|
||||
alerts.append(f"{game.upper()} live sheet is full; `{team['team_name']}` was not added.")
|
||||
continue
|
||||
row_num = empty_rows.pop(0)
|
||||
by_name[key] = row_num
|
||||
|
||||
no = row_num - start_row + 1
|
||||
lineup = "\n".join(_lineup_entry(player) for player in team["lineup"])
|
||||
timestamp = _format_sheet_time(team["payment_time"])
|
||||
status = CONFIRMED_TEXT if team["confirmed"] else PENDING_TEXT
|
||||
if game == "cs2":
|
||||
values = [[no, team["team_name"], lineup, team["vrs"], timestamp, status]]
|
||||
worksheet.update(values, f"A{row_num}:F{row_num}", value_input_option="USER_ENTERED")
|
||||
else:
|
||||
values = [[no, team["team_name"], lineup, timestamp, status]]
|
||||
worksheet.update(values, f"A{row_num}:E{row_num}", value_input_option="USER_ENTERED")
|
||||
rows_written += 1
|
||||
return rows_written
|
||||
|
||||
|
||||
def _lineup_entry(player: dict[str, str]) -> str:
|
||||
nickname = player.get("nickname") or "?"
|
||||
country = player.get("country") or player.get("country_code") or "?"
|
||||
return f"{nickname}, {country}"
|
||||
|
||||
|
||||
def _format_sheet_time(raw: str) -> str:
|
||||
if not raw:
|
||||
return ""
|
||||
try:
|
||||
parsed = dt.datetime.fromisoformat(raw)
|
||||
except ValueError:
|
||||
return raw
|
||||
return parsed.strftime("%d.%m.%Y %H:%M")
|
||||
|
||||
|
||||
async def _send_alerts(bot: discord.Client, alerts: list[str]) -> None:
|
||||
if not alerts or not config.FIENTA_ADMIN_ALERT_CHANNEL_ID:
|
||||
return
|
||||
try:
|
||||
channel = bot.get_channel(config.FIENTA_ADMIN_ALERT_CHANNEL_ID)
|
||||
if channel is None:
|
||||
channel = await bot.fetch_channel(config.FIENTA_ADMIN_ALERT_CHANNEL_ID)
|
||||
except Exception as exc:
|
||||
log.warning("Could not fetch Fienta alert channel: %s", exc)
|
||||
return
|
||||
if not hasattr(channel, "send"):
|
||||
return
|
||||
|
||||
header = "**Fienta LAN sync alerts**"
|
||||
chunks: list[str] = []
|
||||
current = header
|
||||
for alert in alerts:
|
||||
line = f"\n- {alert}"
|
||||
if len(current) + len(line) > 1900:
|
||||
chunks.append(current)
|
||||
current = header + line
|
||||
else:
|
||||
current += line
|
||||
chunks.append(current)
|
||||
for chunk in chunks:
|
||||
try:
|
||||
await channel.send(chunk)
|
||||
except Exception as exc:
|
||||
log.warning("Could not send Fienta alert: %s", exc)
|
||||
break
|
||||
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
5
data/birthday_sent.json
Normal file
5
data/birthday_sent.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"2026-03-14": [
|
||||
"650046190972305409"
|
||||
]
|
||||
}
|
||||
5
data/bot_config.json
Normal file
5
data/bot_config.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"allowed_channels": [
|
||||
"1482398641699291357"
|
||||
]
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
# LAN Fienta Setup
|
||||
|
||||
This profile runs the same codebase as a separate Discord bot process:
|
||||
|
||||
```powershell
|
||||
$env:BOT_PROFILE="lan"
|
||||
python bot.py
|
||||
```
|
||||
|
||||
## Environment
|
||||
|
||||
Required `.env` values:
|
||||
|
||||
```env
|
||||
BOT_PROFILE=lan
|
||||
DISCORD_BOT_LAN=...
|
||||
GUILD_ID_LAN=1301145356750426192
|
||||
SHEET_ID_LAN=1cYEI2EmQDMZdVOarbOkVw7IHsnfqtYbWhA-6zC_r-zw
|
||||
PB_ECONOMY_COLLECTION_LAN=economy_users_lan
|
||||
PB_FIENTA_COLLECTION_LAN=fienta_registrations_lan
|
||||
FIENTA_WEBHOOK_SECRET=<optional-long-random-secret>
|
||||
FIENTA_WEBHOOK_PORT=8090
|
||||
FIENTA_ADMIN_ALERT_CHANNEL_ID=1478302279894302812
|
||||
```
|
||||
|
||||
The LAN bot must be invited to the LAN server and to the dev server that owns
|
||||
the alert channel.
|
||||
|
||||
## Fienta Webhooks
|
||||
|
||||
Use these URLs in Fienta:
|
||||
|
||||
```text
|
||||
Ostu sooritamisel:
|
||||
https://veebikonks.tipilan.ee/fienta/purchase
|
||||
|
||||
Registreerimisvormi täitmisel peale ostu:
|
||||
https://veebikonks.tipilan.ee/fienta/registration
|
||||
```
|
||||
|
||||
Leave `Pileti valideerimisel` empty for now.
|
||||
|
||||
The old secret-token endpoint is still supported for testing:
|
||||
|
||||
```text
|
||||
https://veebikonks.tipilan.ee/fienta/webhook/<FIENTA_WEBHOOK_SECRET>
|
||||
```
|
||||
|
||||
## Caddy
|
||||
|
||||
If Caddy is on the same Docker network as the bot service:
|
||||
|
||||
```caddyfile
|
||||
veebikonks.tipilan.ee {
|
||||
reverse_proxy /fienta/* bot:8090
|
||||
}
|
||||
```
|
||||
|
||||
If Caddy runs on the host, make sure `localhost:8090` points to the bot webhook,
|
||||
not PocketBase. The current compose file publishes PocketBase on host port
|
||||
`8090`, so host-level Caddy cannot also proxy that same host port to the bot
|
||||
unless PocketBase is moved or the bot is exposed through a different host route.
|
||||
|
||||
## Ticket Mapping
|
||||
|
||||
- `595507` - CS2 participant, public sheet row, CS2/team/language roles
|
||||
- `595509` - CS2 reserve, roles only
|
||||
- `595510` - CS2 manager, CS2/team/language/Manager roles
|
||||
- `595912` - LoL participant, public sheet row, LoL/team/language roles
|
||||
|
||||
Public sheet rows:
|
||||
|
||||
- `CS2`: rows `6` through `37`
|
||||
- `LoL`: rows `6` through `17`
|
||||
|
||||
## Admin Command
|
||||
|
||||
Use `/fientasync` in the LAN server to re-apply stored Fienta registrations to
|
||||
Discord roles and the public sheet.
|
||||
@@ -13,14 +13,9 @@ Create your admin account on first launch via the Admin UI.
|
||||
|
||||
---
|
||||
|
||||
## 2. Create economy collections for both bot profiles
|
||||
## 2. Create the `economy_users` collection
|
||||
|
||||
In the Admin UI → **Collections** → **New collection** and create:
|
||||
|
||||
- `economy_users_dev`
|
||||
- `economy_users_prod`
|
||||
|
||||
Use the same schema for both collections.
|
||||
In the Admin UI → **Collections** → **New collection** → name it exactly `economy_users`.
|
||||
|
||||
Add the following fields:
|
||||
|
||||
@@ -56,8 +51,6 @@ 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1519
logs/bot.log
Normal file
1519
logs/bot.log
Normal file
File diff suppressed because it is too large
Load Diff
714
logs/transactions.log
Normal file
714
logs/transactions.log
Normal file
@@ -0,0 +1,714 @@
|
||||
2026-03-20 01:34:58 | WORK user=340451525799182357 earned=+123 lucky=False bal=27988
|
||||
2026-03-20 01:35:02 | BEG user=340451525799182357 earned=+24 jailed=False bal=28012
|
||||
2026-03-20 01:35:39 | ROB_FAIL robber=340451525799182357 victim=218972931701735424 fine=-120 robber_bal=27892
|
||||
2026-03-20 01:35:56 | SLOTS_MISS user=340451525799182357 bet=100 change=-100 bal=27792
|
||||
2026-03-20 01:36:04 | SLOTS_PAIR user=340451525799182357 bet=100 change=50 bal=27842
|
||||
2026-03-20 01:36:11 | SLOTS_MISS user=340451525799182357 bet=100 change=-100 bal=27742
|
||||
2026-03-20 01:36:19 | SLOTS_PAIR user=340451525799182357 bet=100 change=50 bal=27792
|
||||
2026-03-20 01:37:38 | BLACKJACK user=340451525799182357 payout=+400 net=+200 bal=27992
|
||||
2026-03-20 01:38:07 | BEG user=340451525799182357 earned=+30 jailed=False bal=28022
|
||||
2026-03-20 01:38:23 | BLACKJACK user=340451525799182357 payout=+0 net=-100 bal=27922
|
||||
2026-03-20 01:38:43 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=26922
|
||||
2026-03-20 01:39:15 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=27922
|
||||
2026-03-20 01:39:36 | BLACKJACK user=340451525799182357 payout=+4000 net=+2000 bal=29922
|
||||
2026-03-20 01:39:49 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=30922
|
||||
2026-03-20 01:40:02 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=31922
|
||||
2026-03-20 01:40:12 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=30922
|
||||
2026-03-20 01:40:26 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=29922
|
||||
2026-03-20 01:40:40 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=30922
|
||||
2026-03-20 01:40:54 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=31922
|
||||
2026-03-20 01:41:05 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=30922
|
||||
2026-03-20 01:41:26 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=29922
|
||||
2026-03-20 01:41:35 | BLACKJACK user=340451525799182357 payout=+2500 net=+1500 bal=31422
|
||||
2026-03-20 01:42:32 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=32422
|
||||
2026-03-20 01:42:46 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=31422
|
||||
2026-03-20 01:43:34 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=30422
|
||||
2026-03-20 01:43:48 | BLACKJACK user=340451525799182357 payout=+4000 net=+2000 bal=32422
|
||||
2026-03-20 01:44:00 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=31422
|
||||
2026-03-20 01:44:11 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=32422
|
||||
2026-03-20 01:44:22 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=33422
|
||||
2026-03-20 01:44:47 | BLACKJACK user=340451525799182357 payout=+4000 net=+2000 bal=35422
|
||||
2026-03-20 01:45:02 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=36422
|
||||
2026-03-20 01:45:17 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=35422
|
||||
2026-03-20 01:45:28 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=36422
|
||||
2026-03-20 01:45:42 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=37422
|
||||
2026-03-20 01:45:55 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=38422
|
||||
2026-03-20 01:46:15 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=39422
|
||||
2026-03-20 01:46:27 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=38422
|
||||
2026-03-20 01:46:38 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=37422
|
||||
2026-03-20 01:46:54 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=38422
|
||||
2026-03-20 01:47:05 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=37422
|
||||
2026-03-20 01:47:15 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=36422
|
||||
2026-03-20 01:47:46 | BLACKJACK user=340451525799182357 payout=+4000 net=+2000 bal=38422
|
||||
2026-03-20 01:47:59 | BLACKJACK user=340451525799182357 payout=+4000 net=+2000 bal=40422
|
||||
2026-03-20 01:48:11 | BLACKJACK user=340451525799182357 payout=+2000 net=+0 bal=40422
|
||||
2026-03-20 01:48:23 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=41422
|
||||
2026-03-20 01:48:39 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=40422
|
||||
2026-03-20 01:48:54 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=41422
|
||||
2026-03-20 01:49:12 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=42422
|
||||
2026-03-20 01:49:25 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=43422
|
||||
2026-03-20 01:49:36 | BLACKJACK user=340451525799182357 payout=+1000 net=+0 bal=43422
|
||||
2026-03-20 01:49:51 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=44422
|
||||
2026-03-20 01:50:06 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=45422
|
||||
2026-03-20 01:50:16 | BLACKJACK user=340451525799182357 payout=+1000 net=+0 bal=45422
|
||||
2026-03-20 01:50:28 | BLACKJACK user=340451525799182357 payout=+0 net=-2000 bal=43422
|
||||
2026-03-20 01:50:38 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=42422
|
||||
2026-03-20 01:50:53 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=41422
|
||||
2026-03-20 01:51:04 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=40422
|
||||
2026-03-20 01:51:20 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=39422
|
||||
2026-03-20 01:51:31 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=40422
|
||||
2026-03-20 01:51:44 | BLACKJACK user=340451525799182357 payout=+1000 net=+0 bal=40422
|
||||
2026-03-20 01:51:54 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=39422
|
||||
2026-03-20 01:52:07 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=38422
|
||||
2026-03-20 01:52:20 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=39422
|
||||
2026-03-20 01:52:33 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=38422
|
||||
2026-03-20 01:52:43 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=39422
|
||||
2026-03-20 01:52:58 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=40422
|
||||
2026-03-20 01:54:04 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=41422
|
||||
2026-03-20 01:54:20 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=42422
|
||||
2026-03-20 01:55:01 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=41422
|
||||
2026-03-20 01:55:12 | BLACKJACK user=340451525799182357 payout=+1000 net=+0 bal=41422
|
||||
2026-03-20 01:55:20 | BLACKJACK user=340451525799182357 payout=+2500 net=+1500 bal=42922
|
||||
2026-03-20 01:55:42 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=43922
|
||||
2026-03-20 01:55:54 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=44922
|
||||
2026-03-20 01:56:02 | BLACKJACK user=340451525799182357 payout=+2500 net=+1500 bal=46422
|
||||
2026-03-20 01:56:16 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=45422
|
||||
2026-03-20 01:56:27 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=44422
|
||||
2026-03-20 01:56:39 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=45422
|
||||
2026-03-20 01:56:49 | BLACKJACK user=340451525799182357 payout=+1000 net=+0 bal=45422
|
||||
2026-03-20 01:57:01 | BLACKJACK user=340451525799182357 payout=+1000 net=+0 bal=45422
|
||||
2026-03-20 01:58:03 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=44422
|
||||
2026-03-20 01:58:19 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=43422
|
||||
2026-03-20 01:59:08 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=42422
|
||||
2026-03-20 01:59:22 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=43422
|
||||
2026-03-20 01:59:37 | BLACKJACK user=340451525799182357 payout=+2500 net=+1500 bal=44922
|
||||
2026-03-20 01:59:56 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=43922
|
||||
2026-03-20 02:00:14 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=42922
|
||||
2026-03-20 02:00:37 | BLACKJACK user=340451525799182357 payout=+2500 net=+1500 bal=44422
|
||||
2026-03-20 02:00:57 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=45422
|
||||
2026-03-20 02:01:08 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=44422
|
||||
2026-03-20 02:01:38 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=43422
|
||||
2026-03-20 02:01:56 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=42422
|
||||
2026-03-20 02:02:15 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=43422
|
||||
2026-03-20 02:02:30 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=44422
|
||||
2026-03-20 02:02:43 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=45422
|
||||
2026-03-20 02:02:55 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=44422
|
||||
2026-03-20 02:03:07 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=43422
|
||||
2026-03-20 02:03:32 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=44422
|
||||
2026-03-20 02:03:48 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=45422
|
||||
2026-03-20 02:04:01 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=44422
|
||||
2026-03-20 02:04:20 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=43422
|
||||
2026-03-20 02:04:32 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=44422
|
||||
2026-03-20 02:04:45 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=43422
|
||||
2026-03-20 02:04:57 | BLACKJACK user=340451525799182357 payout=+4000 net=+2000 bal=45422
|
||||
2026-03-20 02:05:17 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=46422
|
||||
2026-03-20 02:09:33 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=47422
|
||||
2026-03-20 02:09:46 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=48422
|
||||
2026-03-20 02:10:00 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=49422
|
||||
2026-03-20 02:10:17 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=48422
|
||||
2026-03-20 02:10:27 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=47422
|
||||
2026-03-20 02:10:41 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=48422
|
||||
2026-03-20 02:11:43 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=49422
|
||||
2026-03-20 02:20:03 | ROB_BLOCKED robber=178852380018868224 victim=340451525799182357 fine=-198 robber_bal=237 ac_uses_left=1
|
||||
2026-03-20 02:50:48 | BEG user=178852380018868224 earned=+64 jailed=False bal=301
|
||||
2026-03-20 02:50:50 | WORK user=178852380018868224 earned=+92 lucky=False bal=393
|
||||
2026-03-20 02:50:53 | CRIME_WIN user=178852380018868224 earned=+414 bal=807
|
||||
2026-03-20 03:11:24 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=48422
|
||||
2026-03-20 04:25:19 | HEIST_HOUSE change=-79803 house_bal=123830
|
||||
2026-03-20 04:25:19 | HEIST_WIN user=340451525799182357 change=+39901 bal=88323
|
||||
2026-03-20 04:25:19 | HEIST_WIN user=178852380018868224 change=+39901 bal=40708
|
||||
2026-03-20 04:26:00 | DAILY user=178852380018868224 earned=+950 streak=5 bal=41658
|
||||
2026-03-20 04:28:21 | WORK user=340451525799182357 earned=+114 lucky=False bal=88437
|
||||
2026-03-20 04:28:26 | BEG user=340451525799182357 earned=+72 jailed=False bal=88509
|
||||
2026-03-20 04:29:11 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=89509
|
||||
2026-03-20 04:30:44 | BLACKJACK user=340451525799182357 payout=+2500 net=+1500 bal=91009
|
||||
2026-03-20 04:31:09 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=90009
|
||||
2026-03-20 04:31:28 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=91009
|
||||
2026-03-20 04:31:45 | BLACKJACK user=340451525799182357 payout=+2000 net=+1000 bal=92009
|
||||
2026-03-20 04:32:10 | BLACKJACK user=340451525799182357 payout=+1000 net=+0 bal=92009
|
||||
2026-03-20 04:32:30 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=91009
|
||||
2026-03-20 04:32:52 | BLACKJACK user=340451525799182357 payout=+4000 net=+2000 bal=93009
|
||||
2026-03-20 04:33:11 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=92009
|
||||
2026-03-20 04:33:32 | BLACKJACK user=340451525799182357 payout=+1000 net=+0 bal=92009
|
||||
2026-03-20 04:33:48 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=91009
|
||||
2026-03-20 04:34:07 | BLACKJACK user=340451525799182357 payout=+0 net=-1000 bal=90009
|
||||
2026-03-20 04:34:26 | BLACKJACK user=340451525799182357 payout=+0 net=-3000 bal=87009
|
||||
2026-03-20 04:34:51 | BLACKJACK user=340451525799182357 payout=+0 net=-10000 bal=77009
|
||||
2026-03-20 04:35:14 | BLACKJACK user=340451525799182357 payout=+0 net=-20000 bal=57009
|
||||
2026-03-20 04:35:33 | BLACKJACK user=340451525799182357 payout=+0 net=-57009 bal=0
|
||||
2026-03-20 06:12:01 | DAILY user=272518654715887618 earned=+225 streak=5 bal=930
|
||||
2026-03-20 06:43:11 | BEG user=338622999127261185 earned=+78 jailed=False bal=751
|
||||
2026-03-20 06:43:13 | WORK user=338622999127261185 earned=+102 lucky=False bal=853
|
||||
2026-03-20 06:43:17 | CRIME_WIN user=338622999127261185 earned=+331 bal=1184
|
||||
2026-03-20 06:44:17 | ROB_FAIL robber=338622999127261185 victim=218972931701735424 fine=-111 robber_bal=1073
|
||||
2026-03-20 06:46:27 | BEG user=338622999127261185 earned=+30 jailed=False bal=1103
|
||||
2026-03-20 07:00:53 | DAILY user=824516445382901800 earned=+491 streak=5 bal=1328
|
||||
2026-03-20 07:03:25 | BEG user=338622999127261185 earned=+24 jailed=False bal=1127
|
||||
2026-03-20 07:03:29 | DAILY user=338622999127261185 earned=+450 streak=5 bal=1577
|
||||
2026-03-20 07:03:37 | ROULETTE_LOSE user=338622999127261185 bet=1577 colour=punane result=roheline mult=1 bal=0
|
||||
2026-03-20 07:06:42 | BEG user=338622999127261185 earned=+58 jailed=False bal=58
|
||||
2026-03-20 07:17:30 | CRIME_WIN user=401373976431165449 earned=+461 bal=10256
|
||||
2026-03-20 07:17:46 | ROB_WIN robber=401373976431165449 victim=218972931701735424 stolen=+703 jackpot=False robber_bal=10959 victim_bal=2816
|
||||
2026-03-20 07:17:52 | WORK user=401373976431165449 earned=+174 lucky=True bal=11133
|
||||
2026-03-20 07:17:54 | BEG user=401373976431165449 earned=+58 jailed=False bal=11191
|
||||
2026-03-20 07:18:00 | DAILY user=401373976431165449 earned=+950 streak=5 bal=12141
|
||||
2026-03-20 07:25:09 | WORK user=338622999127261185 earned=+36 lucky=False bal=94
|
||||
2026-03-20 07:25:11 | BEG user=338622999127261185 earned=+38 jailed=False bal=132
|
||||
2026-03-20 07:28:30 | BEG user=401373976431165449 earned=+32 jailed=False bal=12173
|
||||
2026-03-20 07:41:15 | BEG user=401373976431165449 earned=+38 jailed=False bal=12211
|
||||
2026-03-20 07:51:21 | DAILY user=344531774518591498 earned=+500 streak=5 bal=1518
|
||||
2026-03-20 07:51:27 | WORK user=344531774518591498 earned=+275 lucky=True bal=1793
|
||||
2026-03-20 07:51:29 | BEG user=344531774518591498 earned=+42 jailed=False bal=1835
|
||||
2026-03-20 07:51:39 | ROB_BLOCKED robber=344531774518591498 victim=178852380018868224 fine=-130 robber_bal=1705 ac_uses_left=0
|
||||
2026-03-20 07:51:47 | CRIME_WIN user=344531774518591498 earned=+456 bal=2161
|
||||
2026-03-20 07:55:18 | BLACKJACK user=344531774518591498 payout=+2000 net=+1000 bal=3161
|
||||
2026-03-20 07:55:34 | BLACKJACK user=344531774518591498 payout=+0 net=-1000 bal=2161
|
||||
2026-03-20 07:55:48 | BLACKJACK user=344531774518591498 payout=+0 net=-1000 bal=1161
|
||||
2026-03-20 07:56:02 | BLACKJACK user=344531774518591498 payout=+1161 net=+0 bal=1161
|
||||
2026-03-20 07:56:15 | BLACKJACK user=344531774518591498 payout=+0 net=-1161 bal=0
|
||||
2026-03-20 08:08:09 | BEG user=401373976431165449 earned=+26 jailed=False bal=12237
|
||||
2026-03-20 08:08:19 | WORK user=401373976431165449 earned=+205 lucky=False bal=12442
|
||||
2026-03-20 08:25:18 | BEG user=401373976431165449 earned=+44 jailed=False bal=12486
|
||||
2026-03-20 08:30:56 | HEIST_FAIL user=401373976431165449 fine=-1000 jailed_until=2026-03-20T08:00:56.238832+00:00 bal=11486
|
||||
2026-03-20 08:30:56 | HEIST_FAIL user=824516445382901800 fine=-199 jailed_until=2026-03-20T08:00:56.238832+00:00 bal=1129
|
||||
2026-03-20 08:31:23 | JAIL_FREE user=824516445382901800 method=doubles
|
||||
2026-03-20 08:31:46 | JAIL_FREE user=401373976431165449 method=doubles
|
||||
2026-03-20 08:32:25 | BEG user=401373976431165449 earned=+64 jailed=False bal=11550
|
||||
2026-03-20 08:32:31 | BEG user=338622999127261185 earned=+52 jailed=False bal=184
|
||||
2026-03-20 08:34:19 | WORK user=344531774518591498 earned=+258 lucky=True bal=258
|
||||
2026-03-20 08:35:58 | BEG user=401373976431165449 earned=+72 jailed=False bal=11622
|
||||
2026-03-20 08:46:55 | BEG user=401373976431165449 earned=+42 jailed=False bal=11664
|
||||
2026-03-20 09:04:17 | WORK user=272518654715887618 earned=+83 lucky=False bal=1013
|
||||
2026-03-20 09:06:05 | BEG user=401373976431165449 earned=+72 jailed=False bal=11736
|
||||
2026-03-20 09:06:07 | WORK user=401373976431165449 earned=+54 lucky=False bal=11790
|
||||
2026-03-20 09:17:49 | BEG user=401373976431165449 earned=+64 jailed=False bal=11854
|
||||
2026-03-20 09:17:50 | CRIME_WIN user=401373976431165449 earned=+456 bal=12310
|
||||
2026-03-20 09:18:22 | ROB_WIN robber=401373976431165449 victim=272518654715887618 stolen=+214 jackpot=False robber_bal=12524 victim_bal=799
|
||||
2026-03-20 09:18:25 | WORK user=344531774518591498 earned=+61 lucky=False bal=319
|
||||
2026-03-20 09:18:27 | BEG user=344531774518591498 earned=+28 jailed=False bal=347
|
||||
2026-03-20 09:21:07 | BUY user=178852380018868224 item=anticheat cost=-1000 bal=40658
|
||||
2026-03-20 09:28:45 | BEG user=401373976431165449 earned=+56 jailed=False bal=12580
|
||||
2026-03-20 09:31:49 | BEG user=824516445382901800 earned=+80 jailed=False bal=1209
|
||||
2026-03-20 09:32:13 | WORK user=824516445382901800 earned=+116 lucky=False bal=1325
|
||||
2026-03-20 09:32:17 | CRIME_WIN user=824516445382901800 earned=+412 bal=1737
|
||||
2026-03-20 09:32:24 | ROB_BLOCKED robber=824516445382901800 victim=323906492073771019 fine=-200 robber_bal=1537 ac_uses_left=1
|
||||
2026-03-20 09:49:18 | BEG user=401373976431165449 earned=+68 jailed=False bal=12648
|
||||
2026-03-20 09:49:23 | WORK user=401373976431165449 earned=+123 lucky=True bal=12771
|
||||
2026-03-20 10:00:53 | BEG user=344531774518591498 earned=+54 jailed=False bal=401
|
||||
2026-03-20 10:01:11 | CRIME_WIN user=344531774518591498 earned=+492 bal=893
|
||||
2026-03-20 10:01:31 | ROB_BLOCKED robber=344531774518591498 victim=323906492073771019 fine=-110 robber_bal=783 ac_uses_left=0
|
||||
2026-03-20 10:01:44 | WORK user=344531774518591498 earned=+168 lucky=False bal=951
|
||||
2026-03-20 10:05:58 | BEG user=344531774518591498 earned=+54 jailed=False bal=1005
|
||||
2026-03-20 10:10:28 | BEG user=344531774518591498 earned=+42 jailed=False bal=1047
|
||||
2026-03-20 10:12:31 | BUY user=323906492073771019 item=anticheat cost=-1000 bal=42624
|
||||
2026-03-20 10:14:15 | BEG user=323906492073771019 earned=+36 jailed=False bal=42660
|
||||
2026-03-20 10:14:23 | WORK user=323906492073771019 earned=+101 lucky=False bal=42761
|
||||
2026-03-20 10:17:56 | BEG user=338622999127261185 earned=+78 jailed=False bal=262
|
||||
2026-03-20 10:17:58 | WORK user=338622999127261185 earned=+83 lucky=False bal=345
|
||||
2026-03-20 10:18:01 | CRIME_WIN user=338622999127261185 earned=+290 bal=635
|
||||
2026-03-20 10:18:44 | ROB_FAIL robber=338622999127261185 victim=209554152584380420 fine=-136 robber_bal=499
|
||||
2026-03-20 10:24:36 | BEG user=344531774518591498 earned=+58 jailed=False bal=1105
|
||||
2026-03-20 10:27:33 | BEG user=401373976431165449 earned=+32 jailed=False bal=12803
|
||||
2026-03-20 10:28:15 | BLACKJACK user=401373976431165449 payout=+0 net=-100 bal=12703
|
||||
2026-03-20 10:28:27 | BEG user=344531774518591498 earned=+78 jailed=False bal=1183
|
||||
2026-03-20 10:28:38 | BLACKJACK user=401373976431165449 payout=+200 net=+100 bal=12803
|
||||
2026-03-20 10:28:56 | GIVE from_=401373976431165449 to=344531774518591498 amount=500 from_bal=12203 to_bal=1683
|
||||
2026-03-20 10:29:04 | BLACKJACK user=401373976431165449 payout=+200 net=+100 bal=12403
|
||||
2026-03-20 10:29:15 | BLACKJACK user=401373976431165449 payout=+250 net=+150 bal=12553
|
||||
2026-03-20 10:29:36 | BLACKJACK user=401373976431165449 payout=+400 net=+200 bal=12753
|
||||
2026-03-20 10:29:58 | BLACKJACK user=401373976431165449 payout=+100 net=+0 bal=12753
|
||||
2026-03-20 10:30:11 | BLACKJACK user=401373976431165449 payout=+1250 net=+750 bal=13503
|
||||
2026-03-20 10:30:28 | BLACKJACK user=401373976431165449 payout=+0 net=-100 bal=13403
|
||||
2026-03-20 10:30:37 | WORK user=401373976431165449 earned=+371 lucky=True bal=13674
|
||||
2026-03-20 10:31:05 | BLACKJACK user=401373976431165449 payout=+0 net=-200 bal=13574
|
||||
2026-03-20 10:31:20 | BLACKJACK user=401373976431165449 payout=+0 net=-100 bal=13474
|
||||
2026-03-20 10:31:35 | BLACKJACK user=401373976431165449 payout=+200 net=+100 bal=13574
|
||||
2026-03-20 10:31:47 | BLACKJACK user=401373976431165449 payout=+200 net=+100 bal=13674
|
||||
2026-03-20 10:32:03 | BLACKJACK user=401373976431165449 payout=+0 net=-100 bal=13574
|
||||
2026-03-20 10:32:13 | BLACKJACK user=401373976431165449 payout=+250 net=+150 bal=13724
|
||||
2026-03-20 10:38:41 | BEG user=401373976431165449 earned=+28 jailed=False bal=13752
|
||||
2026-03-20 10:39:06 | BEG user=344531774518591498 earned=+28 jailed=False bal=1711
|
||||
2026-03-20 10:39:57 | BLACKJACK user=401373976431165449 payout=+400 net=+200 bal=13952
|
||||
2026-03-20 10:40:24 | BLACKJACK user=401373976431165449 payout=+200 net=+100 bal=14052
|
||||
2026-03-20 10:40:41 | BLACKJACK user=401373976431165449 payout=+0 net=-100 bal=13952
|
||||
2026-03-20 10:40:52 | BLACKJACK user=401373976431165449 payout=+0 net=-100 bal=13852
|
||||
2026-03-20 10:41:06 | BLACKJACK user=401373976431165449 payout=+200 net=+100 bal=13952
|
||||
2026-03-20 10:41:21 | BLACKJACK user=401373976431165449 payout=+0 net=-100 bal=13852
|
||||
2026-03-20 10:41:36 | BLACKJACK user=401373976431165449 payout=+0 net=-100 bal=13752
|
||||
2026-03-20 10:41:49 | BLACKJACK user=401373976431165449 payout=+200 net=+100 bal=13852
|
||||
2026-03-20 10:41:50 | BEG user=401373976431165449 earned=+62 jailed=False bal=13914
|
||||
2026-03-20 10:41:58 | SLOTS_MISS user=401373976431165449 bet=100 change=-100 bal=13814
|
||||
2026-03-20 10:42:22 | SLOTS_MISS user=401373976431165449 bet=100 change=-100 bal=13714
|
||||
2026-03-20 10:43:45 | BLACKJACK user=401373976431165449 payout=+400 net=+200 bal=13914
|
||||
2026-03-20 10:44:21 | BEG user=344531774518591498 earned=+36 jailed=False bal=1747
|
||||
2026-03-20 10:44:22 | WORK user=344531774518591498 earned=+84 lucky=False bal=1831
|
||||
2026-03-20 10:44:34 | BLACKJACK user=344531774518591498 payout=+3662 net=+1831 bal=3662
|
||||
2026-03-20 10:44:46 | BLACKJACK user=344531774518591498 payout=+0 net=-3662 bal=0
|
||||
2026-03-20 10:45:43 | BEG user=401373976431165449 earned=+56 jailed=False bal=13970
|
||||
2026-03-20 10:47:55 | BLACKJACK user=401373976431165449 payout=+200 net=+100 bal=14070
|
||||
2026-03-20 10:48:06 | BLACKJACK user=401373976431165449 payout=+0 net=-100 bal=13970
|
||||
2026-03-20 10:48:14 | BLACKJACK user=401373976431165449 payout=+250 net=+150 bal=14120
|
||||
2026-03-20 10:48:25 | BLACKJACK user=401373976431165449 payout=+0 net=-100 bal=14020
|
||||
2026-03-20 10:48:49 | BEG user=401373976431165449 earned=+22 jailed=False bal=14042
|
||||
2026-03-20 10:56:47 | BEG user=272518654715887618 earned=+13 jailed=False bal=812
|
||||
2026-03-20 10:56:49 | WORK user=272518654715887618 earned=+105 lucky=False bal=917
|
||||
2026-03-20 10:56:59 | CRIME_WIN user=272518654715887618 earned=+237 bal=1154
|
||||
2026-03-20 10:57:12 | BEG user=344531774518591498 earned=+36 jailed=False bal=36
|
||||
2026-03-20 10:58:57 | WORK user=338622999127261185 earned=+117 lucky=True bal=616
|
||||
2026-03-20 10:59:29 | BEG user=401373976431165449 earned=+58 jailed=False bal=14100
|
||||
2026-03-20 11:02:04 | BLACKJACK user=401373976431165449 payout=+0 net=-1000 bal=13100
|
||||
2026-03-20 11:02:27 | BLACKJACK user=401373976431165449 payout=+200 net=+100 bal=13200
|
||||
2026-03-20 11:02:43 | BEG user=344531774518591498 earned=+36 jailed=False bal=72
|
||||
2026-03-20 11:05:34 | BEG user=401373976431165449 earned=+52 jailed=False bal=13252
|
||||
2026-03-20 11:12:14 | WORK user=401373976431165449 earned=+230 lucky=True bal=13482
|
||||
2026-03-20 11:12:25 | BEG user=401373976431165449 earned=+62 jailed=False bal=13544
|
||||
2026-03-20 11:16:03 | BEG user=401373976431165449 earned=+48 jailed=False bal=13592
|
||||
2026-03-20 11:16:45 | BEG user=824516445382901800 earned=+72 jailed=False bal=1609
|
||||
2026-03-20 11:16:49 | WORK user=824516445382901800 earned=+71 lucky=False bal=1680
|
||||
2026-03-20 11:17:03 | ROULETTE_LOSE user=824516445382901800 bet=1680 colour=punane result=must mult=1 bal=0
|
||||
2026-03-20 11:20:45 | GIVE from_=401373976431165449 to=824516445382901800 amount=1000 from_bal=12592 to_bal=1000
|
||||
2026-03-20 11:20:50 | BEG user=344531774518591498 earned=+80 jailed=False bal=152
|
||||
2026-03-20 11:21:33 | ROULETTE_LOSE user=824516445382901800 bet=1000 colour=punane result=must mult=1 bal=0
|
||||
2026-03-20 11:22:53 | BLACKJACK user=344531774518591498 payout=+304 net=+152 bal=304
|
||||
2026-03-20 11:23:06 | BLACKJACK user=344531774518591498 payout=+0 net=-304 bal=0
|
||||
2026-03-20 11:23:18 | BEG user=401373976431165449 earned=+38 jailed=False bal=12630
|
||||
2026-03-20 11:23:28 | CRIME_WIN user=401373976431165449 earned=+347 bal=12977
|
||||
2026-03-20 11:23:42 | ROB_FAIL robber=401373976431165449 victim=218972931701735424 fine=-205 robber_bal=12772
|
||||
2026-03-20 11:24:13 | BEG user=344531774518591498 earned=+70 jailed=False bal=70
|
||||
2026-03-20 11:24:43 | WORK user=344531774518591498 earned=+118 lucky=False bal=188
|
||||
2026-03-20 11:24:57 | BLACKJACK user=344531774518591498 payout=+0 net=-188 bal=0
|
||||
2026-03-20 11:38:51 | BEG user=272518654715887618 earned=+40 jailed=False bal=1194
|
||||
2026-03-20 11:38:58 | BEG user=344531774518591498 earned=+80 jailed=False bal=80
|
||||
2026-03-20 11:42:01 | WORK user=338622999127261185 earned=+27 lucky=False bal=643
|
||||
2026-03-20 11:42:38 | BEG user=272518654715887618 earned=+21 jailed=False bal=1215
|
||||
2026-03-20 11:42:41 | BEG user=344531774518591498 earned=+24 jailed=False bal=104
|
||||
2026-03-20 11:47:54 | BEG user=338622999127261185 earned=+44 jailed=False bal=687
|
||||
2026-03-20 11:50:32 | BEG user=272518654715887618 earned=+23 jailed=False bal=1238
|
||||
2026-03-20 11:54:20 | WORK user=401373976431165449 earned=+56 lucky=False bal=12828
|
||||
2026-03-20 11:54:26 | BEG user=401373976431165449 earned=+40 jailed=False bal=12868
|
||||
2026-03-20 12:01:29 | BEG user=401373976431165449 earned=+56 jailed=False bal=12924
|
||||
2026-03-20 12:04:36 | BEG user=401373976431165449 earned=+50 jailed=False bal=12974
|
||||
2026-03-20 12:07:25 | ROB_BLOCKED robber=344531774518591498 victim=401373976431165449 fine=-169 robber_bal=0 ac_uses_left=1
|
||||
2026-03-20 12:07:28 | BEG user=344531774518591498 earned=+34 jailed=False bal=34
|
||||
2026-03-20 12:07:31 | CRIME_WIN user=344531774518591498 earned=+399 bal=433
|
||||
2026-03-20 12:07:33 | WORK user=344531774518591498 earned=+90 lucky=True bal=523
|
||||
2026-03-20 12:08:33 | BEG user=401373976431165449 earned=+34 jailed=False bal=13008
|
||||
2026-03-20 12:11:44 | BEG user=401373976431165449 earned=+28 jailed=False bal=13036
|
||||
2026-03-20 12:16:46 | BEG user=401373976431165449 earned=+66 jailed=False bal=13102
|
||||
2026-03-20 12:17:10 | WORK user=824516445382901800 earned=+84 lucky=False bal=84
|
||||
2026-03-20 12:17:11 | BEG user=824516445382901800 earned=+32 jailed=False bal=116
|
||||
2026-03-20 12:17:13 | CRIME_WIN user=824516445382901800 earned=+501 bal=617
|
||||
2026-03-20 12:17:22 | ROB_BLOCKED robber=824516445382901800 victim=344531774518591498 fine=-170 robber_bal=447 ac_uses_left=1
|
||||
2026-03-20 12:17:37 | BEG user=344531774518591498 earned=+26 jailed=False bal=549
|
||||
2026-03-20 12:19:11 | WORK user=178852380018868224 earned=+41 lucky=False bal=40699
|
||||
2026-03-20 12:19:15 | BEG user=178852380018868224 earned=+32 jailed=False bal=40731
|
||||
2026-03-20 12:19:18 | CRIME_WIN user=178852380018868224 earned=+460 bal=41191
|
||||
2026-03-20 12:19:37 | ROULETTE_LOSE user=824516445382901800 bet=447 colour=punane result=must mult=1 bal=0
|
||||
2026-03-20 12:23:18 | SLOTS_PAIR user=178852380018868224 bet=10000 change=5000 bal=46191
|
||||
2026-03-20 12:23:28 | SLOTS_MISS user=178852380018868224 bet=10000 change=-10000 bal=36191
|
||||
2026-03-20 12:26:00 | BEG user=344531774518591498 earned=+40 jailed=False bal=589
|
||||
2026-03-20 12:34:38 | BEG user=401373976431165449 earned=+74 jailed=False bal=13176
|
||||
2026-03-20 12:34:41 | WORK user=401373976431165449 earned=+105 lucky=False bal=13281
|
||||
2026-03-20 12:35:44 | HEIST_HOUSE change=-479646 house_bal=529905
|
||||
2026-03-20 12:35:44 | HEIST_WIN user=824516445382901800 change=+59955 bal=59955
|
||||
2026-03-20 12:35:44 | HEIST_WIN user=401373976431165449 change=+59955 bal=73236
|
||||
2026-03-20 12:35:44 | HEIST_WIN user=178852380018868224 change=+59955 bal=96146
|
||||
2026-03-20 12:35:44 | HEIST_WIN user=338622999127261185 change=+59955 bal=60642
|
||||
2026-03-20 12:35:44 | HEIST_WIN user=340451525799182357 change=+59955 bal=59955
|
||||
2026-03-20 12:35:44 | HEIST_WIN user=218972931701735424 change=+59955 bal=62771
|
||||
2026-03-20 12:35:44 | HEIST_WIN user=344531774518591498 change=+59955 bal=60544
|
||||
2026-03-20 12:35:44 | HEIST_WIN user=272518654715887618 change=+59955 bal=61193
|
||||
2026-03-20 12:36:08 | ROULETTE_WIN user=178852380018868224 bet=96146 colour=punane result=punane mult=1 bal=192292
|
||||
2026-03-20 12:36:25 | ROULETTE_WIN user=178852380018868224 bet=192292 colour=punane result=punane mult=1 bal=384584
|
||||
2026-03-20 12:36:26 | ROULETTE_WIN user=824516445382901800 bet=59955 colour=punane result=punane mult=1 bal=119910
|
||||
2026-03-20 12:36:33 | BUY user=344531774518591498 item=karikas cost=-6000 bal=54544
|
||||
2026-03-20 12:36:37 | BUY user=344531774518591498 item=monitor_360 cost=-7500 bal=47044
|
||||
2026-03-20 12:36:50 | BLACKJACK user=344531774518591498 payout=+0 net=-47044 bal=0
|
||||
2026-03-20 12:37:18 | ROULETTE_WIN user=178852380018868224 bet=384584 colour=punane result=punane mult=1 bal=769168
|
||||
2026-03-20 12:37:48 | ROULETTE_WIN user=178852380018868224 bet=769168 colour=punane result=punane mult=1 bal=1538336
|
||||
2026-03-20 12:37:52 | ROULETTE_LOSE user=824516445382901800 bet=119910 colour=punane result=must mult=1 bal=0
|
||||
2026-03-20 12:39:40 | BUY user=272518654715887618 item=gaming_laptop cost=-1500 bal=59693
|
||||
2026-03-20 12:39:51 | ROB_BLOCKED robber=824516445382901800 victim=178852380018868224 fine=-167 robber_bal=0 ac_uses_left=1
|
||||
2026-03-20 12:39:51 | BUY user=272518654715887618 item=cat6 cost=-3500 bal=56193
|
||||
2026-03-20 12:39:55 | ROB_BLOCKED robber=824516445382901800 victim=178852380018868224 fine=-100 robber_bal=0 ac_uses_left=0
|
||||
2026-03-20 12:40:01 | ROB_WIN robber=401373976431165449 victim=178852380018868224 stolen=+162541 jackpot=False robber_bal=235777 victim_bal=1375795
|
||||
2026-03-20 12:40:02 | ROB_WIN robber=824516445382901800 victim=178852380018868224 stolen=+340819 jackpot=False robber_bal=340819 victim_bal=1034976
|
||||
2026-03-20 12:40:03 | BUY user=178852380018868224 item=anticheat cost=-1000 bal=1033976
|
||||
2026-03-20 12:40:07 | ROB_BLOCKED robber=824516445382901800 victim=178852380018868224 fine=-109 robber_bal=340710 ac_uses_left=1
|
||||
2026-03-20 12:40:09 | ROB_BLOCKED robber=824516445382901800 victim=178852380018868224 fine=-128 robber_bal=340582 ac_uses_left=0
|
||||
2026-03-20 12:40:11 | ROB_FAIL robber=824516445382901800 victim=178852380018868224 fine=-188 robber_bal=340394
|
||||
2026-03-20 12:40:12 | ROB_FAIL robber=824516445382901800 victim=178852380018868224 fine=-232 robber_bal=340162
|
||||
2026-03-20 12:40:12 | BUY user=178852380018868224 item=anticheat cost=-1000 bal=1032976
|
||||
2026-03-20 12:40:15 | ROB_BLOCKED robber=824516445382901800 victim=178852380018868224 fine=-103 robber_bal=340059 ac_uses_left=1
|
||||
2026-03-20 12:40:17 | BUY user=272518654715887618 item=lan_pass cost=-1200 bal=54993
|
||||
2026-03-20 12:40:18 | ROB_BLOCKED robber=824516445382901800 victim=178852380018868224 fine=-199 robber_bal=339860 ac_uses_left=0
|
||||
2026-03-20 12:40:19 | BUY user=178852380018868224 item=anticheat cost=-1000 bal=1031976
|
||||
2026-03-20 12:40:20 | ROB_BLOCKED robber=824516445382901800 victim=178852380018868224 fine=-100 robber_bal=339760 ac_uses_left=1
|
||||
2026-03-20 12:40:23 | ROULETTE_LOSE user=178852380018868224 bet=1031976 colour=punane result=must mult=1 bal=0
|
||||
2026-03-20 12:40:27 | BUY user=272518654715887618 item=anticheat cost=-1000 bal=53993
|
||||
2026-03-20 12:40:34 | BUY user=272518654715887618 item=reguleeritav_laud cost=-3500 bal=50493
|
||||
2026-03-20 12:40:42 | BUY user=272518654715887618 item=jellyfin cost=-4000 bal=46493
|
||||
2026-03-20 12:40:55 | BUY user=272518654715887618 item=monitor cost=-2500 bal=43993
|
||||
2026-03-20 12:41:00 | ROB_BLOCKED robber=824516445382901800 victim=401373976431165449 fine=-139 robber_bal=339621 ac_uses_left=0
|
||||
2026-03-20 12:41:02 | ROB_FAIL robber=824516445382901800 victim=401373976431165449 fine=-131 robber_bal=339490
|
||||
2026-03-20 12:41:02 | WORK user=272518654715887618 earned=+97 lucky=False bal=44090
|
||||
2026-03-20 12:41:04 | ROB_WIN robber=824516445382901800 victim=401373976431165449 stolen=+51444 jackpot=False robber_bal=390934 victim_bal=184333
|
||||
2026-03-20 12:41:05 | ROB_BLOCKED robber=178852380018868224 victim=824516445382901800 fine=-179 robber_bal=0 ac_uses_left=0
|
||||
2026-03-20 12:41:05 | ROB_WIN robber=824516445382901800 victim=401373976431165449 stolen=+41097 jackpot=False robber_bal=432031 victim_bal=143236
|
||||
2026-03-20 12:41:05 | ROB_WIN robber=401373976431165449 victim=824516445382901800 stolen=+67489 jackpot=False robber_bal=210725 victim_bal=364542
|
||||
2026-03-20 12:41:07 | ROB_WIN robber=178852380018868224 victim=824516445382901800 stolen=+76500 jackpot=False robber_bal=76500 victim_bal=288042
|
||||
2026-03-20 12:41:08 | ROB_FAIL robber=178852380018868224 victim=824516445382901800 fine=-136 robber_bal=76364
|
||||
2026-03-20 12:41:08 | ROB_WIN robber=824516445382901800 victim=401373976431165449 stolen=+50641 jackpot=False robber_bal=338683 victim_bal=160084
|
||||
2026-03-20 12:41:10 | BUY user=401373976431165449 item=anticheat cost=-1000 bal=159084
|
||||
2026-03-20 12:41:11 | ROB_WIN robber=178852380018868224 victim=824516445382901800 stolen=+41788 jackpot=False robber_bal=118152 victim_bal=296895
|
||||
2026-03-20 12:41:11 | ROB_BLOCKED robber=824516445382901800 victim=401373976431165449 fine=-103 robber_bal=296792 ac_uses_left=1
|
||||
2026-03-20 12:41:13 | ROULETTE_WIN user=178852380018868224 bet=118152 colour=punane result=punane mult=1 bal=236304
|
||||
2026-03-20 12:41:13 | ROB_BLOCKED robber=824516445382901800 victim=401373976431165449 fine=-125 robber_bal=296667 ac_uses_left=0
|
||||
2026-03-20 12:41:24 | BUY user=401373976431165449 item=anticheat cost=-1000 bal=158084
|
||||
2026-03-20 12:41:24 | BEG user=272518654715887618 earned=+38 jailed=False bal=44128
|
||||
2026-03-20 12:41:35 | BEG user=401373976431165449 earned=+74 jailed=False bal=158158
|
||||
2026-03-20 12:41:54 | ROULETTE_WIN user=178852380018868224 bet=236304 colour=punane result=punane mult=1 bal=472608
|
||||
2026-03-20 12:41:59 | BLACKJACK user=272518654715887618 payout=+40000 net=+20000 bal=64128
|
||||
2026-03-20 12:42:01 | SLOTS_PAIR user=824516445382901800 bet=296667 change=148333 bal=445000
|
||||
2026-03-20 12:42:43 | ROULETTE_LOSE user=178852380018868224 bet=472608 colour=punane result=must mult=1 bal=0
|
||||
2026-03-20 12:43:11 | SLOTS_PAIR user=338622999127261185 bet=60642 change=30321 bal=90963
|
||||
2026-03-20 12:43:21 | BUY user=824516445382901800 item=anticheat cost=-1000 bal=444000
|
||||
2026-03-20 12:43:28 | ROULETTE_WIN user=338622999127261185 bet=90963 colour=must result=must mult=1 bal=181926
|
||||
2026-03-20 12:43:43 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=449000
|
||||
2026-03-20 12:43:49 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=439000
|
||||
2026-03-20 12:43:53 | WORK user=338622999127261185 earned=+61 lucky=False bal=181987
|
||||
2026-03-20 12:43:54 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=444000
|
||||
2026-03-20 12:43:55 | CRIME_FAIL user=338622999127261185 fine=-111 jailed=True bal=181876
|
||||
2026-03-20 12:43:59 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=434000
|
||||
2026-03-20 12:44:05 | SLOTS_TRIPLE user=824516445382901800 bet=10000 change=30000 bal=464000
|
||||
2026-03-20 12:44:10 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=469000
|
||||
2026-03-20 12:44:17 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=459000
|
||||
2026-03-20 12:44:17 | BAIL_PAID user=338622999127261185 fine=-46087 pct=25% bal=135789
|
||||
2026-03-20 12:44:22 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=449000
|
||||
2026-03-20 12:44:25 | SLOTS_MISS user=338622999127261185 bet=135789 change=-135789 bal=0
|
||||
2026-03-20 12:44:27 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=454000
|
||||
2026-03-20 12:44:33 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=444000
|
||||
2026-03-20 12:44:49 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=434000
|
||||
2026-03-20 12:44:55 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=424000
|
||||
2026-03-20 12:44:56 | BEG user=401373976431165449 earned=+20 jailed=False bal=158178
|
||||
2026-03-20 12:45:02 | SLOTS_TRIPLE user=824516445382901800 bet=10000 change=30000 bal=454000
|
||||
2026-03-20 12:45:08 | SLOTS_TRIPLE user=824516445382901800 bet=10000 change=60000 bal=514000
|
||||
2026-03-20 12:45:13 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=519000
|
||||
2026-03-20 12:45:20 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=509000
|
||||
2026-03-20 12:45:25 | SLOTS_TRIPLE user=824516445382901800 bet=10000 change=30000 bal=539000
|
||||
2026-03-20 12:45:30 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=544000
|
||||
2026-03-20 12:45:35 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=534000
|
||||
2026-03-20 12:45:42 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=524000
|
||||
2026-03-20 12:45:47 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=514000
|
||||
2026-03-20 12:45:57 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=504000
|
||||
2026-03-20 12:46:02 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=494000
|
||||
2026-03-20 12:46:07 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=499000
|
||||
2026-03-20 12:46:12 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=504000
|
||||
2026-03-20 12:46:18 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=509000
|
||||
2026-03-20 12:46:22 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=499000
|
||||
2026-03-20 12:46:28 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=489000
|
||||
2026-03-20 12:46:33 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=494000
|
||||
2026-03-20 12:46:40 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=484000
|
||||
2026-03-20 12:46:45 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=489000
|
||||
2026-03-20 12:46:50 | SLOTS_TRIPLE user=824516445382901800 bet=10000 change=30000 bal=519000
|
||||
2026-03-20 12:46:56 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=509000
|
||||
2026-03-20 12:47:02 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=514000
|
||||
2026-03-20 12:47:07 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=519000
|
||||
2026-03-20 12:47:13 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=509000
|
||||
2026-03-20 12:47:21 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=514000
|
||||
2026-03-20 12:47:27 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=504000
|
||||
2026-03-20 12:47:33 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=494000
|
||||
2026-03-20 12:47:38 | SLOTS_TRIPLE user=824516445382901800 bet=10000 change=90000 bal=584000
|
||||
2026-03-20 12:47:43 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=589000
|
||||
2026-03-20 12:47:48 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=594000
|
||||
2026-03-20 12:47:55 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=584000
|
||||
2026-03-20 12:48:00 | SLOTS_TRIPLE user=824516445382901800 bet=10000 change=40000 bal=624000
|
||||
2026-03-20 12:48:06 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=614000
|
||||
2026-03-20 12:48:12 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=619000
|
||||
2026-03-20 12:48:18 | SLOTS_TRIPLE user=824516445382901800 bet=10000 change=40000 bal=659000
|
||||
2026-03-20 12:48:20 | BEG user=401373976431165449 earned=+68 jailed=False bal=158246
|
||||
2026-03-20 12:48:25 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=664000
|
||||
2026-03-20 12:48:30 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=669000
|
||||
2026-03-20 12:48:36 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=674000
|
||||
2026-03-20 12:48:42 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=679000
|
||||
2026-03-20 12:48:48 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=669000
|
||||
2026-03-20 12:49:37 | ADMIN_JAIL admin=272518654715887618 target=401373976431165449 minutes=100 reason=Omavoliliselt
|
||||
2026-03-20 12:51:31 | ADMIN_UNJAIL admin=272518654715887618 target=401373976431165449
|
||||
2026-03-20 13:01:40 | BEG user=272518654715887618 earned=+10 jailed=False bal=64138
|
||||
2026-03-20 13:02:01 | BLACKJACK user=272518654715887618 payout=+40000 net=+20000 bal=84138
|
||||
2026-03-20 13:03:15 | BEG user=344531774518591498 earned=+78 jailed=False bal=78
|
||||
2026-03-20 13:03:16 | WORK user=344531774518591498 earned=+120 lucky=False bal=198
|
||||
2026-03-20 13:14:55 | BEG user=401373976431165449 earned=+70 jailed=False bal=158316
|
||||
2026-03-20 13:14:58 | WORK user=401373976431165449 earned=+56 lucky=False bal=158372
|
||||
2026-03-20 13:24:03 | BEG user=272518654715887618 earned=+40 jailed=False bal=84178
|
||||
2026-03-20 13:24:12 | WORK user=272518654715887618 earned=+121 lucky=False bal=84299
|
||||
2026-03-20 13:24:24 | BUY user=272518654715887618 item=energiajook cost=-800 bal=83499
|
||||
2026-03-20 13:24:46 | BUY user=272518654715887618 item=mikrofon cost=-2800 bal=80699
|
||||
2026-03-20 13:24:56 | BUY user=272518654715887618 item=klaviatuur cost=-1800 bal=78899
|
||||
2026-03-20 13:25:12 | CRIME_FAIL user=272518654715887618 fine=-101 jailed=True bal=78798
|
||||
2026-03-20 13:30:43 | BEG user=338622999127261185 earned=+70 jailed=False bal=70
|
||||
2026-03-20 13:30:45 | WORK user=338622999127261185 earned=+109 lucky=False bal=179
|
||||
2026-03-20 13:42:46 | CRIME_WIN user=401373976431165449 earned=+453 bal=158825
|
||||
2026-03-20 13:44:12 | WORK user=344531774518591498 earned=+33 lucky=False bal=231
|
||||
2026-03-20 13:44:14 | BEG user=344531774518591498 earned=+26 jailed=False bal=257
|
||||
2026-03-20 14:45:12 | ROB_WIN robber=401373976431165449 victim=218972931701735424 stolen=+14535 jackpot=False robber_bal=173360 victim_bal=48236
|
||||
2026-03-20 14:45:19 | WORK user=344531774518591498 earned=+88 lucky=False bal=345
|
||||
2026-03-20 14:46:36 | ROB_BLOCKED robber=178852380018868224 victim=824516445382901800 fine=-142 robber_bal=0 ac_uses_left=1
|
||||
2026-03-20 14:52:26 | WORK user=272518654715887618 earned=+88 lucky=False bal=78886
|
||||
2026-03-20 14:52:30 | BEG user=272518654715887618 earned=+52 jailed=False bal=78938
|
||||
2026-03-20 14:57:12 | BEG user=272518654715887618 earned=+80 jailed=False bal=79018
|
||||
2026-03-20 15:17:52 | SLOTS_PAIR user=824516445382901800 bet=20000 change=10000 bal=679000
|
||||
2026-03-20 15:17:58 | SLOTS_MISS user=824516445382901800 bet=20000 change=-20000 bal=659000
|
||||
2026-03-20 15:18:05 | SLOTS_MISS user=824516445382901800 bet=20000 change=-20000 bal=639000
|
||||
2026-03-20 15:18:10 | SLOTS_PAIR user=824516445382901800 bet=20000 change=10000 bal=649000
|
||||
2026-03-20 15:18:15 | SLOTS_PAIR user=824516445382901800 bet=20000 change=10000 bal=659000
|
||||
2026-03-20 15:18:21 | SLOTS_MISS user=824516445382901800 bet=20000 change=-20000 bal=639000
|
||||
2026-03-20 15:18:27 | SLOTS_PAIR user=824516445382901800 bet=20000 change=10000 bal=649000
|
||||
2026-03-20 15:18:33 | SLOTS_PAIR user=824516445382901800 bet=20000 change=10000 bal=659000
|
||||
2026-03-20 15:18:38 | SLOTS_PAIR user=824516445382901800 bet=20000 change=10000 bal=669000
|
||||
2026-03-20 15:18:45 | SLOTS_MISS user=824516445382901800 bet=20000 change=-20000 bal=649000
|
||||
2026-03-20 15:18:50 | SLOTS_PAIR user=824516445382901800 bet=20000 change=10000 bal=659000
|
||||
2026-03-20 15:18:55 | SLOTS_PAIR user=824516445382901800 bet=20000 change=10000 bal=669000
|
||||
2026-03-20 15:19:03 | SLOTS_MISS user=824516445382901800 bet=20000 change=-20000 bal=649000
|
||||
2026-03-20 15:19:08 | SLOTS_TRIPLE user=824516445382901800 bet=20000 change=60000 bal=709000
|
||||
2026-03-20 15:19:13 | SLOTS_MISS user=824516445382901800 bet=20000 change=-20000 bal=689000
|
||||
2026-03-20 15:19:18 | SLOTS_MISS user=824516445382901800 bet=20000 change=-20000 bal=669000
|
||||
2026-03-20 15:19:23 | SLOTS_PAIR user=824516445382901800 bet=20000 change=10000 bal=679000
|
||||
2026-03-20 15:19:30 | SLOTS_PAIR user=824516445382901800 bet=20000 change=10000 bal=689000
|
||||
2026-03-20 15:19:47 | SLOTS_MISS user=824516445382901800 bet=20000 change=-20000 bal=669000
|
||||
2026-03-20 15:19:53 | SLOTS_MISS user=824516445382901800 bet=20000 change=-20000 bal=649000
|
||||
2026-03-20 15:19:58 | SLOTS_MISS user=824516445382901800 bet=20000 change=-20000 bal=629000
|
||||
2026-03-20 15:20:03 | SLOTS_PAIR user=824516445382901800 bet=20000 change=10000 bal=639000
|
||||
2026-03-20 15:20:08 | SLOTS_TRIPLE user=824516445382901800 bet=20000 change=80000 bal=719000
|
||||
2026-03-20 15:20:13 | SLOTS_MISS user=824516445382901800 bet=20000 change=-20000 bal=699000
|
||||
2026-03-20 15:20:20 | SLOTS_MISS user=824516445382901800 bet=20000 change=-20000 bal=679000
|
||||
2026-03-20 15:20:25 | SLOTS_MISS user=824516445382901800 bet=20000 change=-20000 bal=659000
|
||||
2026-03-20 15:20:30 | SLOTS_PAIR user=824516445382901800 bet=20000 change=10000 bal=669000
|
||||
2026-03-20 15:20:35 | SLOTS_MISS user=824516445382901800 bet=20000 change=-20000 bal=649000
|
||||
2026-03-20 15:20:40 | SLOTS_PAIR user=824516445382901800 bet=20000 change=10000 bal=659000
|
||||
2026-03-20 15:20:47 | SLOTS_PAIR user=824516445382901800 bet=20000 change=10000 bal=669000
|
||||
2026-03-20 15:20:53 | SLOTS_PAIR user=824516445382901800 bet=20000 change=10000 bal=679000
|
||||
2026-03-20 15:20:58 | SLOTS_PAIR user=824516445382901800 bet=20000 change=10000 bal=689000
|
||||
2026-03-20 15:21:03 | SLOTS_MISS user=824516445382901800 bet=20000 change=-20000 bal=669000
|
||||
2026-03-20 15:21:10 | SLOTS_MISS user=824516445382901800 bet=20000 change=-20000 bal=649000
|
||||
2026-03-20 15:21:16 | SLOTS_PAIR user=824516445382901800 bet=20000 change=10000 bal=659000
|
||||
2026-03-20 15:21:23 | SLOTS_PAIR user=824516445382901800 bet=20000 change=10000 bal=669000
|
||||
2026-03-20 15:21:32 | SLOTS_PAIR user=824516445382901800 bet=20000 change=10000 bal=679000
|
||||
2026-03-20 15:21:37 | SLOTS_PAIR user=824516445382901800 bet=20000 change=10000 bal=689000
|
||||
2026-03-20 15:21:43 | SLOTS_MISS user=824516445382901800 bet=20000 change=-20000 bal=669000
|
||||
2026-03-20 15:21:48 | SLOTS_MISS user=824516445382901800 bet=20000 change=-20000 bal=649000
|
||||
2026-03-20 15:21:53 | SLOTS_PAIR user=824516445382901800 bet=20000 change=10000 bal=659000
|
||||
2026-03-20 15:22:00 | SLOTS_PAIR user=824516445382901800 bet=20000 change=10000 bal=669000
|
||||
2026-03-20 15:22:05 | SLOTS_MISS user=824516445382901800 bet=20000 change=-20000 bal=649000
|
||||
2026-03-20 15:22:10 | SLOTS_MISS user=824516445382901800 bet=20000 change=-20000 bal=629000
|
||||
2026-03-20 15:22:15 | SLOTS_MISS user=824516445382901800 bet=20000 change=-20000 bal=609000
|
||||
2026-03-20 15:22:20 | SLOTS_MISS user=824516445382901800 bet=20000 change=-20000 bal=589000
|
||||
2026-03-20 15:22:25 | SLOTS_MISS user=824516445382901800 bet=20000 change=-20000 bal=569000
|
||||
2026-03-20 15:22:31 | SLOTS_MISS user=824516445382901800 bet=20000 change=-20000 bal=549000
|
||||
2026-03-20 15:22:36 | SLOTS_PAIR user=824516445382901800 bet=20000 change=10000 bal=559000
|
||||
2026-03-20 15:22:41 | SLOTS_MISS user=824516445382901800 bet=20000 change=-20000 bal=539000
|
||||
2026-03-20 15:23:15 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=529000
|
||||
2026-03-20 15:23:20 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=519000
|
||||
2026-03-20 15:23:26 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=524000
|
||||
2026-03-20 15:23:31 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=514000
|
||||
2026-03-20 15:23:36 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=519000
|
||||
2026-03-20 15:23:41 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=509000
|
||||
2026-03-20 15:23:48 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=514000
|
||||
2026-03-20 15:23:53 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=519000
|
||||
2026-03-20 15:23:58 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=524000
|
||||
2026-03-20 15:24:03 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=514000
|
||||
2026-03-20 15:24:10 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=504000
|
||||
2026-03-20 15:24:18 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=494000
|
||||
2026-03-20 15:24:23 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=484000
|
||||
2026-03-20 15:24:30 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=489000
|
||||
2026-03-20 15:24:35 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=479000
|
||||
2026-03-20 15:24:40 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=469000
|
||||
2026-03-20 15:24:46 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=459000
|
||||
2026-03-20 15:24:51 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=464000
|
||||
2026-03-20 15:24:56 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=454000
|
||||
2026-03-20 15:25:03 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=444000
|
||||
2026-03-20 15:25:09 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=434000
|
||||
2026-03-20 15:25:16 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=424000
|
||||
2026-03-20 15:25:21 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=414000
|
||||
2026-03-20 15:25:28 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=419000
|
||||
2026-03-20 15:25:35 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=409000
|
||||
2026-03-20 15:25:40 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=414000
|
||||
2026-03-20 15:25:46 | SLOTS_JACKPOT user=824516445382901800 bet=10000 change=240000 bal=654000
|
||||
2026-03-20 15:25:53 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=659000
|
||||
2026-03-20 15:25:58 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=664000
|
||||
2026-03-20 15:26:03 | SLOTS_TRIPLE user=824516445382901800 bet=10000 change=140000 bal=804000
|
||||
2026-03-20 15:28:09 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=809000
|
||||
2026-03-20 15:28:14 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=799000
|
||||
2026-03-20 15:28:19 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=804000
|
||||
2026-03-20 15:28:25 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=794000
|
||||
2026-03-20 15:28:32 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=799000
|
||||
2026-03-20 15:29:08 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=804000
|
||||
2026-03-20 15:29:14 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=794000
|
||||
2026-03-20 15:29:21 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=799000
|
||||
2026-03-20 15:29:26 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=789000
|
||||
2026-03-20 15:29:31 | SLOTS_TRIPLE user=824516445382901800 bet=10000 change=40000 bal=829000
|
||||
2026-03-20 15:29:36 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=819000
|
||||
2026-03-20 15:29:41 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=824000
|
||||
2026-03-20 15:29:47 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=829000
|
||||
2026-03-20 15:29:56 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=834000
|
||||
2026-03-20 15:30:02 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=839000
|
||||
2026-03-20 15:30:07 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=844000
|
||||
2026-03-20 15:30:15 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=834000
|
||||
2026-03-20 15:30:32 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=839000
|
||||
2026-03-20 15:30:38 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=829000
|
||||
2026-03-20 15:30:43 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=834000
|
||||
2026-03-20 15:31:35 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=839000
|
||||
2026-03-20 15:31:40 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=844000
|
||||
2026-03-20 15:31:46 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=849000
|
||||
2026-03-20 15:31:53 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=839000
|
||||
2026-03-20 15:31:58 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=844000
|
||||
2026-03-20 15:32:03 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=834000
|
||||
2026-03-20 15:32:09 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=824000
|
||||
2026-03-20 15:32:14 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=814000
|
||||
2026-03-20 15:32:20 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=804000
|
||||
2026-03-20 15:32:26 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=794000
|
||||
2026-03-20 15:32:31 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=799000
|
||||
2026-03-20 15:32:36 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=804000
|
||||
2026-03-20 15:32:41 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=794000
|
||||
2026-03-20 15:32:48 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=784000
|
||||
2026-03-20 15:32:53 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=774000
|
||||
2026-03-20 15:33:09 | BUY user=824516445382901800 item=karikas cost=-6000 bal=768000
|
||||
2026-03-20 15:33:13 | BUY user=824516445382901800 item=monitor_360 cost=-7500 bal=760500
|
||||
2026-03-20 15:33:16 | BUY user=824516445382901800 item=gaming_tool cost=-9000 bal=751500
|
||||
2026-03-20 15:35:01 | SLOTS_PAIR user=824516445382901800 bet=20000 change=10000 bal=761500
|
||||
2026-03-20 15:35:08 | SLOTS_PAIR user=824516445382901800 bet=20000 change=10000 bal=771500
|
||||
2026-03-20 15:35:14 | SLOTS_PAIR user=824516445382901800 bet=20000 change=10000 bal=781500
|
||||
2026-03-20 15:35:20 | SLOTS_MISS user=824516445382901800 bet=20000 change=-20000 bal=761500
|
||||
2026-03-20 15:35:25 | SLOTS_PAIR user=824516445382901800 bet=20000 change=10000 bal=771500
|
||||
2026-03-20 15:35:30 | SLOTS_MISS user=824516445382901800 bet=20000 change=-20000 bal=751500
|
||||
2026-03-20 15:35:35 | SLOTS_PAIR user=824516445382901800 bet=20000 change=10000 bal=761500
|
||||
2026-03-20 15:35:42 | SLOTS_MISS user=824516445382901800 bet=20000 change=-20000 bal=741500
|
||||
2026-03-20 15:35:47 | SLOTS_MISS user=824516445382901800 bet=20000 change=-20000 bal=721500
|
||||
2026-03-20 15:35:52 | SLOTS_MISS user=824516445382901800 bet=20000 change=-20000 bal=701500
|
||||
2026-03-20 15:35:57 | SLOTS_MISS user=824516445382901800 bet=20000 change=-20000 bal=681500
|
||||
2026-03-20 15:36:02 | SLOTS_MISS user=824516445382901800 bet=20000 change=-20000 bal=661500
|
||||
2026-03-20 15:36:07 | SLOTS_MISS user=824516445382901800 bet=20000 change=-20000 bal=641500
|
||||
2026-03-20 15:36:12 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=646500
|
||||
2026-03-20 15:36:17 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=651500
|
||||
2026-03-20 15:36:22 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=641500
|
||||
2026-03-20 15:36:28 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=631500
|
||||
2026-03-20 15:36:33 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=621500
|
||||
2026-03-20 15:36:40 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=626500
|
||||
2026-03-20 15:36:45 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=616500
|
||||
2026-03-20 15:36:53 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=621500
|
||||
2026-03-20 15:36:58 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=611500
|
||||
2026-03-20 15:37:03 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=601500
|
||||
2026-03-20 15:37:09 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=606500
|
||||
2026-03-20 15:37:14 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=611500
|
||||
2026-03-20 15:37:20 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=601500
|
||||
2026-03-20 15:37:27 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=606500
|
||||
2026-03-20 15:37:32 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=611500
|
||||
2026-03-20 15:37:40 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=601500
|
||||
2026-03-20 15:37:47 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=591500
|
||||
2026-03-20 15:37:52 | SLOTS_TRIPLE user=824516445382901800 bet=10000 change=60000 bal=651500
|
||||
2026-03-20 15:37:57 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=641500
|
||||
2026-03-20 15:38:06 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=631500
|
||||
2026-03-20 15:38:12 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=636500
|
||||
2026-03-20 15:38:18 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=626500
|
||||
2026-03-20 15:38:24 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=616500
|
||||
2026-03-20 15:38:29 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=621500
|
||||
2026-03-20 15:38:35 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=611500
|
||||
2026-03-20 15:38:40 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=616500
|
||||
2026-03-20 15:42:05 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=606500
|
||||
2026-03-20 15:42:12 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=596500
|
||||
2026-03-20 15:42:18 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=586500
|
||||
2026-03-20 15:42:24 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=576500
|
||||
2026-03-20 15:42:29 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=566500
|
||||
2026-03-20 15:42:36 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=556500
|
||||
2026-03-20 15:42:41 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=546500
|
||||
2026-03-20 15:42:46 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=551500
|
||||
2026-03-20 15:42:51 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=556500
|
||||
2026-03-20 15:42:57 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=561500
|
||||
2026-03-20 15:46:36 | CRIME_WIN user=401373976431165449 earned=+304 bal=173664
|
||||
2026-03-20 15:46:48 | WORK user=401373976431165449 earned=+410 lucky=True bal=174074
|
||||
2026-03-20 15:46:50 | BEG user=401373976431165449 earned=+52 jailed=False bal=174126
|
||||
2026-03-20 15:48:44 | WORK user=272518654715887618 earned=+93 lucky=False bal=79111
|
||||
2026-03-20 15:49:06 | BLACKJACK user=272518654715887618 payout=+80000 net=+40000 bal=119111
|
||||
2026-03-20 16:24:32 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=551500
|
||||
2026-03-20 16:24:38 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=541500
|
||||
2026-03-20 16:24:42 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=546500
|
||||
2026-03-20 16:24:47 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=536500
|
||||
2026-03-20 16:24:55 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=541500
|
||||
2026-03-20 16:25:02 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=546500
|
||||
2026-03-20 16:25:08 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=551500
|
||||
2026-03-20 16:25:13 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=541500
|
||||
2026-03-20 16:25:20 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=531500
|
||||
2026-03-20 16:25:28 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=521500
|
||||
2026-03-20 16:25:35 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=526500
|
||||
2026-03-20 16:25:41 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=531500
|
||||
2026-03-20 16:25:47 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=536500
|
||||
2026-03-20 16:25:53 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=541500
|
||||
2026-03-20 16:26:00 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=546500
|
||||
2026-03-20 16:26:05 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=536500
|
||||
2026-03-20 16:26:10 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=541500
|
||||
2026-03-20 16:28:22 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=546500
|
||||
2026-03-20 16:28:28 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=536500
|
||||
2026-03-20 16:28:35 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=541500
|
||||
2026-03-20 16:28:40 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=531500
|
||||
2026-03-20 16:28:45 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=536500
|
||||
2026-03-20 16:28:50 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=541500
|
||||
2026-03-20 16:28:57 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=531500
|
||||
2026-03-20 16:29:05 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=521500
|
||||
2026-03-20 16:29:10 | SLOTS_MISS user=824516445382901800 bet=10000 change=-10000 bal=511500
|
||||
2026-03-20 16:29:15 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=516500
|
||||
2026-03-20 16:29:21 | SLOTS_PAIR user=824516445382901800 bet=10000 change=5000 bal=521500
|
||||
2026-03-20 16:30:49 | WORK user=824516445382901800 earned=+140 lucky=False bal=521640
|
||||
2026-03-20 16:30:50 | BEG user=824516445382901800 earned=+60 jailed=False bal=521700
|
||||
2026-03-20 16:30:53 | CRIME_WIN user=824516445382901800 earned=+260 bal=521960
|
||||
2026-03-20 16:31:06 | ROB_BLOCKED robber=824516445382901800 victim=272518654715887618 fine=-174 robber_bal=521786 ac_uses_left=1
|
||||
2026-03-20 16:34:38 | BEG user=367347301322326016 earned=+15 jailed=False bal=217
|
||||
2026-03-20 16:34:40 | WORK user=367347301322326016 earned=+43 lucky=False bal=260
|
||||
2026-03-20 16:34:43 | CRIME_WIN user=367347301322326016 earned=+392 bal=652
|
||||
2026-03-20 16:46:10 | BEG user=401373976431165449 earned=+62 jailed=False bal=174188
|
||||
2026-03-20 16:46:12 | WORK user=401373976431165449 earned=+95 lucky=False bal=174283
|
||||
2026-03-20 16:46:25 | ROB_BLOCKED robber=401373976431165449 victim=824516445382901800 fine=-197 robber_bal=174086 ac_uses_left=0
|
||||
2026-03-20 16:46:35 | BUY user=824516445382901800 item=anticheat cost=-1000 bal=520786
|
||||
2026-03-20 16:47:16 | GIVE from_=824516445382901800 to=450392724169031680 amount=520786 from_bal=0 to_bal=520786
|
||||
2026-03-20 16:48:04 | HEIST_FAIL user=401373976431165449 fine=-1000 jailed_until=2026-03-20T16:18:04.449345+00:00 bal=173086
|
||||
2026-03-20 16:48:04 | HEIST_FAIL user=824516445382901800 fine=-150 jailed_until=2026-03-20T16:18:04.449345+00:00 bal=0
|
||||
2026-03-20 16:49:17 | BEG user=401373976431165449 earned=+26 jailed=True bal=173112
|
||||
2026-03-20 16:49:59 | GIVE from_=450392724169031680 to=824516445382901800 amount=520786 from_bal=0 to_bal=520786
|
||||
2026-03-20 16:52:08 | GIVE from_=824516445382901800 to=450392724169031680 amount=519786 from_bal=1000 to_bal=519786
|
||||
2026-03-20 16:52:19 | BAIL_PAID user=824516445382901800 fine=-350 pct=24% bal=650
|
||||
2026-03-20 16:54:04 | GIVE from_=450392724169031680 to=824516445382901800 amount=519786 from_bal=0 to_bal=520436
|
||||
2026-03-20 16:56:31 | ROB_BLOCKED robber=178852380018868224 victim=824516445382901800 fine=-188 robber_bal=0 ac_uses_left=1
|
||||
2026-03-20 16:57:43 | GIVE from_=824516445382901800 to=401373976431165449 amount=20000 from_bal=500436 to_bal=193112
|
||||
2026-03-20 16:57:47 | GIVE from_=824516445382901800 to=272518654715887618 amount=20000 from_bal=480436 to_bal=139111
|
||||
2026-03-20 16:57:54 | GIVE from_=824516445382901800 to=340451525799182357 amount=20000 from_bal=460436 to_bal=79955
|
||||
2026-03-20 16:58:02 | GIVE from_=824516445382901800 to=218972931701735424 amount=20000 from_bal=440436 to_bal=68236
|
||||
2026-03-20 16:58:18 | GIVE from_=824516445382901800 to=323906492073771019 amount=20000 from_bal=420436 to_bal=62761
|
||||
2026-03-20 16:58:22 | GIVE from_=824516445382901800 to=367347301322326016 amount=20000 from_bal=400436 to_bal=20652
|
||||
2026-03-20 16:58:31 | GIVE from_=824516445382901800 to=344531774518591498 amount=20000 from_bal=380436 to_bal=20345
|
||||
2026-03-20 16:58:35 | GIVE from_=824516445382901800 to=209554152584380420 amount=20000 from_bal=360436 to_bal=20309
|
||||
2026-03-20 16:58:42 | GIVE from_=824516445382901800 to=338622999127261185 amount=20000 from_bal=340436 to_bal=20179
|
||||
2026-03-20 16:58:46 | ROULETTE_LOSE user=344531774518591498 bet=20345 colour=punane result=must mult=1 bal=0
|
||||
2026-03-20 16:59:01 | GIVE from_=824516445382901800 to=344531774518591498 amount=20000 from_bal=320436 to_bal=20000
|
||||
2026-03-20 16:59:05 | GIVE from_=824516445382901800 to=240454469668569088 amount=20000 from_bal=300436 to_bal=20044
|
||||
2026-03-20 16:59:09 | GIVE from_=824516445382901800 to=311132892795371520 amount=20000 from_bal=280436 to_bal=20000
|
||||
2026-03-20 16:59:12 | GIVE from_=824516445382901800 to=178852380018868224 amount=20000 from_bal=260436 to_bal=20000
|
||||
2026-03-20 16:59:24 | GIVE from_=824516445382901800 to=296322817941569537 amount=20000 from_bal=240436 to_bal=20000
|
||||
2026-03-20 16:59:29 | GIVE from_=824516445382901800 to=485760228508565504 amount=20000 from_bal=220436 to_bal=20000
|
||||
2026-03-20 16:59:34 | GIVE from_=824516445382901800 to=450392724169031680 amount=20000 from_bal=200436 to_bal=20000
|
||||
2026-03-20 17:00:10 | ROULETTE_LOSE user=344531774518591498 bet=10000 colour=punane result=must mult=1 bal=10000
|
||||
2026-03-20 17:00:34 | ROULETTE_WIN user=344531774518591498 bet=10000 colour=punane result=punane mult=1 bal=20000
|
||||
2026-03-20 17:00:58 | ROULETTE_LOSE user=344531774518591498 bet=10000 colour=punane result=must mult=1 bal=10000
|
||||
2026-03-20 17:01:14 | ROULETTE_LOSE user=344531774518591498 bet=10000 colour=punane result=must mult=1 bal=0
|
||||
2026-03-20 17:06:54 | BEG user=272518654715887618 earned=+20 jailed=False bal=139131
|
||||
2026-03-20 17:06:56 | WORK user=272518654715887618 earned=+393 lucky=True bal=139524
|
||||
2026-03-20 17:07:23 | BLACKJACK user=272518654715887618 payout=+0 net=-50000 bal=89524
|
||||
2026-03-20 17:10:34 | BEG user=272518654715887618 earned=+30 jailed=False bal=89554
|
||||
2026-03-20 17:10:40 | CRIME_FAIL user=272518654715887618 fine=-90 jailed=True bal=89464
|
||||
2026-03-20 17:10:53 | JAIL_FREE user=272518654715887618 method=doubles
|
||||
2026-03-20 17:16:58 | BEG user=272518654715887618 earned=+78 jailed=False bal=89542
|
||||
2026-03-20 17:17:49 | ROULETTE_LOSE user=824516445382901800 bet=200436 colour=punane result=must mult=1 bal=0
|
||||
2026-03-20 17:18:09 | BLACKJACK user=272518654715887618 payout=+179084 net=+89542 bal=179084
|
||||
2026-03-20 17:18:37 | BLACKJACK user=272518654715887618 payout=+0 net=-179084 bal=0
|
||||
2026-03-20 17:21:54 | BEG user=344531774518591498 earned=+14 jailed=False bal=14
|
||||
2026-03-20 17:21:57 | WORK user=344531774518591498 earned=+56 lucky=False bal=70
|
||||
2026-03-20 17:21:59 | BEG user=272518654715887618 earned=+39 jailed=False bal=39
|
||||
2026-03-20 17:22:02 | DAILY user=344531774518591498 earned=+150 streak=1 bal=220
|
||||
1088
logs/transactions.log.2026-03-16
Normal file
1088
logs/transactions.log.2026-03-16
Normal file
File diff suppressed because it is too large
Load Diff
1788
logs/transactions.log.2026-03-17
Normal file
1788
logs/transactions.log.2026-03-17
Normal file
File diff suppressed because it is too large
Load Diff
1630
logs/transactions.log.2026-03-18
Normal file
1630
logs/transactions.log.2026-03-18
Normal file
File diff suppressed because it is too large
Load Diff
1160
logs/transactions.log.2026-03-19
Normal file
1160
logs/transactions.log.2026-03-19
Normal file
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@ from dataclasses import dataclass, field
|
||||
import discord
|
||||
|
||||
import config
|
||||
from . import sheets
|
||||
import sheets
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
_PLACEHOLDER = {"-", "x", "n/a", "none", "ei"}
|
||||
@@ -7,28 +7,26 @@ Environment variables (set in .env):
|
||||
PB_URL Base URL of PocketBase (default: http://127.0.0.1:8090)
|
||||
PB_ADMIN_EMAIL PocketBase admin e-mail
|
||||
PB_ADMIN_PASSWORD PocketBase admin password
|
||||
PB_ECONOMY_COLLECTION_DEV / PB_ECONOMY_COLLECTION_ECONOMY / PB_ECONOMY_COLLECTION_LAN
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
import config
|
||||
|
||||
_log = logging.getLogger("tipiCOIN.pb")
|
||||
|
||||
PB_URL = config.PB_URL
|
||||
PB_ADMIN_EMAIL = config.PB_ADMIN_EMAIL
|
||||
PB_ADMIN_PASSWORD = config.PB_ADMIN_PASSWORD
|
||||
ECONOMY_COLLECTION = config.PB_ECONOMY_COLLECTION
|
||||
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", "")
|
||||
ECONOMY_COLLECTION = "economy_users"
|
||||
|
||||
_TIMEOUT = aiohttp.ClientTimeout(total=4)
|
||||
_TIMEOUT = aiohttp.ClientTimeout(total=10)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Persistent session (created once, reused for the lifetime of the process)
|
||||
@@ -75,28 +73,16 @@ async def _hdrs() -> dict[str, str]:
|
||||
return {"Authorization": await _ensure_auth()}
|
||||
|
||||
|
||||
def _escape_filter_value(value: str) -> str:
|
||||
return value.replace("\\", "\\\\").replace('"', '\\"')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CRUD helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def get_record(user_id: str) -> dict[str, Any] | None:
|
||||
"""Fetch one economy record by Discord user_id. Returns None if not found."""
|
||||
return await get_first_record(
|
||||
ECONOMY_COLLECTION,
|
||||
f'user_id="{_escape_filter_value(user_id)}"',
|
||||
)
|
||||
|
||||
|
||||
async def get_first_record(collection: str, filter_expr: str) -> dict[str, Any] | None:
|
||||
"""Fetch one record from any collection by a PocketBase filter expression."""
|
||||
session = _get_session()
|
||||
async with session.get(
|
||||
f"{PB_URL}/api/collections/{collection}/records",
|
||||
params={"filter": filter_expr, "perPage": 1},
|
||||
f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records",
|
||||
params={"filter": f'user_id="{user_id}"', "perPage": 1},
|
||||
headers=await _hdrs(),
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
@@ -105,22 +91,11 @@ async def get_first_record(collection: str, filter_expr: str) -> dict[str, Any]
|
||||
return items[0] if items else None
|
||||
|
||||
|
||||
async def get_record_by_field(collection: str, field: str, value: str) -> dict[str, Any] | None:
|
||||
"""Fetch one record where `field` exactly equals `value`."""
|
||||
escaped = _escape_filter_value(value)
|
||||
return await get_first_record(collection, f'{field}="{escaped}"')
|
||||
|
||||
|
||||
async def create_record(record: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Create a new economy record. Returns the created record (includes PB id)."""
|
||||
return await create_record_in(ECONOMY_COLLECTION, record)
|
||||
|
||||
|
||||
async def create_record_in(collection: str, record: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Create a new record in any collection. Returns the created record."""
|
||||
session = _get_session()
|
||||
async with session.post(
|
||||
f"{PB_URL}/api/collections/{collection}/records",
|
||||
f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records",
|
||||
json=record,
|
||||
headers=await _hdrs(),
|
||||
) as resp:
|
||||
@@ -132,14 +107,9 @@ async def create_record_in(collection: str, record: dict[str, Any]) -> dict[str,
|
||||
|
||||
async def update_record(record_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""PATCH an existing record by its PocketBase record id."""
|
||||
return await update_record_in(ECONOMY_COLLECTION, record_id, data)
|
||||
|
||||
|
||||
async def update_record_in(collection: str, record_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""PATCH an existing record in any collection by its PocketBase record id."""
|
||||
session = _get_session()
|
||||
async with session.patch(
|
||||
f"{PB_URL}/api/collections/{collection}/records/{record_id}",
|
||||
f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records/{record_id}",
|
||||
json=data,
|
||||
headers=await _hdrs(),
|
||||
) as resp:
|
||||
@@ -149,14 +119,9 @@ async def update_record_in(collection: str, record_id: str, data: dict[str, Any]
|
||||
|
||||
async def count_records() -> int:
|
||||
"""Return the total number of records in the collection (single cheap request)."""
|
||||
return await count_records_in(ECONOMY_COLLECTION)
|
||||
|
||||
|
||||
async def count_records_in(collection: str) -> int:
|
||||
"""Return the total number of records in any collection."""
|
||||
session = _get_session()
|
||||
async with session.get(
|
||||
f"{PB_URL}/api/collections/{collection}/records",
|
||||
f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records",
|
||||
params={"perPage": 1, "page": 1},
|
||||
headers=await _hdrs(),
|
||||
) as resp:
|
||||
@@ -167,18 +132,13 @@ async def count_records_in(collection: str) -> int:
|
||||
|
||||
async def list_all_records(page_size: int = 500) -> list[dict[str, Any]]:
|
||||
"""Fetch every record in the collection, handling PocketBase pagination."""
|
||||
return await list_all_records_in(ECONOMY_COLLECTION, page_size=page_size)
|
||||
|
||||
|
||||
async def list_all_records_in(collection: str, page_size: int = 500) -> list[dict[str, Any]]:
|
||||
"""Fetch every record in any collection, handling PocketBase pagination."""
|
||||
results: list[dict[str, Any]] = []
|
||||
page = 1
|
||||
session = _get_session()
|
||||
hdrs = await _hdrs()
|
||||
while True:
|
||||
async with session.get(
|
||||
f"{PB_URL}/api/collections/{collection}/records",
|
||||
f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records",
|
||||
params={"perPage": page_size, "page": page},
|
||||
headers=hdrs,
|
||||
) as resp:
|
||||
@@ -190,51 +150,3 @@ async def list_all_records_in(collection: str, page_size: int = 500) -> list[dic
|
||||
break
|
||||
page += 1
|
||||
return results
|
||||
|
||||
|
||||
async def upsert_record_by_field(
|
||||
collection: str,
|
||||
field: str,
|
||||
value: str,
|
||||
data: dict[str, Any],
|
||||
) -> tuple[dict[str, Any], bool]:
|
||||
"""Create or update a record. Returns (record, created)."""
|
||||
existing = await get_record_by_field(collection, field, value)
|
||||
if existing:
|
||||
return await update_record_in(collection, existing["id"], data), False
|
||||
return await create_record_in(collection, data), True
|
||||
|
||||
|
||||
async def get_collection(collection: str) -> dict[str, Any] | None:
|
||||
"""Fetch collection metadata, returning None if it doesn't exist."""
|
||||
session = _get_session()
|
||||
async with session.get(
|
||||
f"{PB_URL}/api/collections/{collection}",
|
||||
headers=await _hdrs(),
|
||||
) as resp:
|
||||
if resp.status == 404:
|
||||
return None
|
||||
resp.raise_for_status()
|
||||
return await resp.json()
|
||||
|
||||
|
||||
async def create_collection(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Create a PocketBase collection from a full collection payload."""
|
||||
session = _get_session()
|
||||
async with session.post(
|
||||
f"{PB_URL}/api/collections",
|
||||
json=payload,
|
||||
headers=await _hdrs(),
|
||||
) as resp:
|
||||
if resp.status not in (200, 201):
|
||||
text = await resp.text()
|
||||
raise RuntimeError(f"PocketBase collection create failed ({resp.status}): {text}")
|
||||
return await resp.json()
|
||||
|
||||
|
||||
async def ensure_collection(collection: str, payload: dict[str, Any]) -> bool:
|
||||
"""Create `collection` when missing. Returns True if created."""
|
||||
if await get_collection(collection):
|
||||
return False
|
||||
await create_collection(payload)
|
||||
return True
|
||||
@@ -11,6 +11,7 @@ Requirements:
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -20,12 +21,10 @@ from dotenv import load_dotenv
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
load_dotenv()
|
||||
|
||||
import config # noqa: E402
|
||||
|
||||
PB_URL = config.PB_URL
|
||||
PB_ADMIN_EMAIL = config.PB_ADMIN_EMAIL
|
||||
PB_ADMIN_PASSWORD = config.PB_ADMIN_PASSWORD
|
||||
COLLECTION = config.PB_ECONOMY_COLLECTION
|
||||
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", "")
|
||||
COLLECTION = "economy_users"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# New fields to add
|
||||
|
||||
@@ -23,12 +23,12 @@ from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Ensure the project root is on sys.path so core modules can be imported
|
||||
# Ensure the project root is on sys.path so pb_client can be imported
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
load_dotenv()
|
||||
|
||||
from core import pb_client # noqa: E402 (needs dotenv loaded first)
|
||||
import pb_client # noqa: E402 (needs dotenv loaded first)
|
||||
|
||||
DATA_FILE = Path("data") / "economy.json"
|
||||
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
"""Destructively recreate TipiBOT PocketBase collections.
|
||||
|
||||
Usage:
|
||||
python scripts/reset_pb_collections.py --confirm
|
||||
|
||||
This will DELETE and recreate the collections configured by:
|
||||
- PB_ECONOMY_COLLECTION_DEV
|
||||
- PB_ECONOMY_COLLECTION_ECONOMY
|
||||
- PB_ECONOMY_COLLECTION_LAN
|
||||
- PB_FIENTA_COLLECTION_LAN
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import aiohttp
|
||||
from dotenv import load_dotenv
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
load_dotenv()
|
||||
|
||||
import config # noqa: E402
|
||||
|
||||
PB_URL = config.PB_URL
|
||||
PB_ADMIN_EMAIL = config.PB_ADMIN_EMAIL
|
||||
PB_ADMIN_PASSWORD = config.PB_ADMIN_PASSWORD
|
||||
|
||||
|
||||
def _text_field(name: str, required: bool = False) -> dict:
|
||||
return {
|
||||
"name": name,
|
||||
"type": "text",
|
||||
"required": required,
|
||||
"options": {"min": None, "max": None, "pattern": ""},
|
||||
}
|
||||
|
||||
|
||||
def _number_field(name: str) -> dict:
|
||||
return {
|
||||
"name": name,
|
||||
"type": "number",
|
||||
"required": False,
|
||||
"options": {"min": None, "max": None, "noDecimal": False},
|
||||
}
|
||||
|
||||
|
||||
def _bool_field(name: str) -> dict:
|
||||
return {"name": name, "type": "bool", "required": False}
|
||||
|
||||
|
||||
def _json_field(name: str) -> dict:
|
||||
return {"name": name, "type": "json", "required": False}
|
||||
|
||||
|
||||
def _collection_payload(name: str) -> dict:
|
||||
fields = [
|
||||
_text_field("user_id", required=True),
|
||||
_number_field("balance"),
|
||||
_number_field("exp"),
|
||||
_number_field("daily_streak"),
|
||||
_text_field("last_daily"),
|
||||
_text_field("last_work"),
|
||||
_text_field("last_beg"),
|
||||
_text_field("last_crime"),
|
||||
_text_field("last_rob"),
|
||||
_text_field("last_heist"),
|
||||
_text_field("last_streak_date"),
|
||||
_text_field("jailed_until"),
|
||||
_text_field("last_fish"),
|
||||
_json_field("items"),
|
||||
_json_field("item_uses"),
|
||||
_json_field("reminders"),
|
||||
_json_field("prestige_upgrades"),
|
||||
_json_field("fish_book"),
|
||||
_json_field("fish_inventory"),
|
||||
_bool_field("eco_banned"),
|
||||
_bool_field("jailbreak_used"),
|
||||
_number_field("heist_global_cd_until"),
|
||||
_number_field("peak_balance"),
|
||||
_number_field("lifetime_earned"),
|
||||
_number_field("lifetime_lost"),
|
||||
_number_field("work_count"),
|
||||
_number_field("beg_count"),
|
||||
_number_field("total_wagered"),
|
||||
_number_field("biggest_win"),
|
||||
_number_field("biggest_loss"),
|
||||
_number_field("slots_jackpots"),
|
||||
_number_field("crimes_attempted"),
|
||||
_number_field("crimes_succeeded"),
|
||||
_number_field("times_jailed"),
|
||||
_number_field("total_bail_paid"),
|
||||
_number_field("heists_joined"),
|
||||
_number_field("heists_won"),
|
||||
_number_field("total_given"),
|
||||
_number_field("total_received"),
|
||||
_number_field("best_daily_streak"),
|
||||
_number_field("prestige_level"),
|
||||
_number_field("prestige_points"),
|
||||
_number_field("season_total_exp"),
|
||||
_number_field("total_fish_caught"),
|
||||
]
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"type": "base",
|
||||
"fields": fields,
|
||||
"listRule": None,
|
||||
"viewRule": None,
|
||||
"createRule": None,
|
||||
"updateRule": None,
|
||||
"deleteRule": None,
|
||||
}
|
||||
|
||||
|
||||
def _fienta_collection_payload(name: str) -> dict:
|
||||
fields = [
|
||||
_text_field("registration_key", required=True),
|
||||
_text_field("order_id"),
|
||||
_text_field("ticket_code"),
|
||||
_text_field("order_status"),
|
||||
_text_field("order_url"),
|
||||
_text_field("payment_time"),
|
||||
_text_field("game"),
|
||||
_text_field("kind"),
|
||||
_text_field("ticket_type_id"),
|
||||
_text_field("ticket_title"),
|
||||
_text_field("ticket_group_title"),
|
||||
_text_field("team_name"),
|
||||
_text_field("discord_username"),
|
||||
_text_field("nickname"),
|
||||
_text_field("country"),
|
||||
_text_field("country_code"),
|
||||
_text_field("riot_id"),
|
||||
_text_field("steam64_id"),
|
||||
_text_field("vrs_ranking"),
|
||||
_bool_field("is_main"),
|
||||
_bool_field("is_reserve"),
|
||||
_bool_field("is_manager"),
|
||||
_bool_field("is_captain"),
|
||||
_bool_field("sheet_public"),
|
||||
_bool_field("blocked_country"),
|
||||
_bool_field("active"),
|
||||
_bool_field("roles_synced"),
|
||||
_text_field("last_sync_error"),
|
||||
_text_field("updated_at"),
|
||||
]
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"type": "base",
|
||||
"fields": fields,
|
||||
"listRule": None,
|
||||
"viewRule": None,
|
||||
"createRule": None,
|
||||
"updateRule": None,
|
||||
"deleteRule": None,
|
||||
}
|
||||
|
||||
|
||||
async def _auth_token(session: aiohttp.ClientSession) -> str:
|
||||
async with session.post(
|
||||
f"{PB_URL}/api/collections/_superusers/auth-with-password",
|
||||
json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD},
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
raise RuntimeError(f"Auth failed ({resp.status}): {await resp.text()}")
|
||||
return (await resp.json())["token"]
|
||||
|
||||
|
||||
async def _delete_if_exists(session: aiohttp.ClientSession, headers: dict[str, str], name: str) -> None:
|
||||
async with session.get(f"{PB_URL}/api/collections/{name}", headers=headers) as resp:
|
||||
if resp.status == 404:
|
||||
print(f"[SKIP] {name} does not exist")
|
||||
return
|
||||
if resp.status != 200:
|
||||
raise RuntimeError(f"Could not fetch {name} ({resp.status}): {await resp.text()}")
|
||||
|
||||
async with session.delete(f"{PB_URL}/api/collections/{name}", headers=headers) as resp:
|
||||
if resp.status not in (200, 204):
|
||||
raise RuntimeError(f"Delete failed for {name} ({resp.status}): {await resp.text()}")
|
||||
print(f"[DELETE] {name}")
|
||||
|
||||
|
||||
async def _create_collection(
|
||||
session: aiohttp.ClientSession,
|
||||
headers: dict[str, str],
|
||||
name: str,
|
||||
payload: dict,
|
||||
) -> None:
|
||||
async with session.post(f"{PB_URL}/api/collections", json=payload, headers=headers) as resp:
|
||||
if resp.status not in (200, 201):
|
||||
raise RuntimeError(f"Create failed for {name} ({resp.status}): {await resp.text()}")
|
||||
print(f"[CREATE] {name}")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--confirm", action="store_true", help="Required flag to run destructive reset")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.confirm:
|
||||
raise SystemExit("Refusing to run without --confirm (this operation deletes collections).")
|
||||
|
||||
targets: list[tuple[str, dict]] = []
|
||||
for name in [
|
||||
config.PB_ECONOMY_COLLECTION_DEV,
|
||||
config.PB_ECONOMY_COLLECTION_ECONOMY,
|
||||
config.PB_ECONOMY_COLLECTION_LAN,
|
||||
]:
|
||||
if name and all(existing != name for existing, _ in targets):
|
||||
targets.append((name, _collection_payload(name)))
|
||||
fienta_name = config.PB_FIENTA_COLLECTION_LAN
|
||||
if fienta_name and all(name != fienta_name for name, _ in targets):
|
||||
targets.append((fienta_name, _fienta_collection_payload(fienta_name)))
|
||||
|
||||
if not targets:
|
||||
raise SystemExit("No target collections configured.")
|
||||
|
||||
timeout = aiohttp.ClientTimeout(total=20)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
token = await _auth_token(session)
|
||||
headers = {"Authorization": token}
|
||||
|
||||
for name, payload in targets:
|
||||
await _delete_if_exists(session, headers, name)
|
||||
await _create_collection(session, headers, name, payload)
|
||||
|
||||
print("\nDone. Collections recreated:")
|
||||
for name, _ in targets:
|
||||
print(f" - {name}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,43 +0,0 @@
|
||||
# Runtime profile
|
||||
BOT_PROFILE=lan
|
||||
|
||||
# Discord bot tokens
|
||||
DISCORD_TOKEN_DEV=MTQ4MjM2NDcxNzI5MTkzMzc2Ng.G8SmBo._5u6z-Tr13DFpd7n1gI2GfjqorYsvV3S-sOnFA
|
||||
DISCORD_TOKEN_ECONOMY=MTQ5MDAzNDM5OTU4Mjg4Mzg3MA.GmN2OX.AFxiZcSPAtoO00ARcT8eXV8JvH8vRysvOM9KPU
|
||||
DISCORD_BOT_LAN=MTQ5MDAzNDM5OTU4Mjg4Mzg3MA.GL25hE.7Fd59Jw52MxxHnfRZtyW33-xSeUsER3teOvHqE
|
||||
DISCORD_TOKEN=
|
||||
|
||||
# Google Sheets
|
||||
SHEET_ID=1TyW075sOxefQYbeowNV7AWO8lHv2pe4nT6CcsdvFd_E
|
||||
SHEET_ID_DEV=1TyW075sOxefQYbeowNV7AWO8lHv2pe4nT6CcsdvFd_E
|
||||
SHEET_ID_LAN=1cYEI2EmQDMZdVOarbOkVw7IHsnfqtYbWhA-6zC_r-zw
|
||||
GOOGLE_CREDS_PATH=credentials.json
|
||||
|
||||
# Discord guilds
|
||||
GUILD_ID_DEV=1478302278086819946
|
||||
GUILD_ID_ECONOMY=1301145356750426192
|
||||
GUILD_ID_LAN=1301145356750426192
|
||||
GUILD_ID=
|
||||
|
||||
# Birthday system
|
||||
BIRTHDAY_CHANNEL_ID_DEV=1482398641699291357
|
||||
BIRTHDAY_CHANNEL_ID_ECONOMY=
|
||||
BIRTHDAY_CHANNEL_ID=
|
||||
BIRTHDAY_WINDOW_DAYS=7
|
||||
|
||||
# PocketBase
|
||||
PB_URL=http://127.0.0.1:8090
|
||||
PB_ADMIN_EMAIL=tipilaninfo@gmail.com
|
||||
PB_ADMIN_PASSWORD=salakala
|
||||
|
||||
# PocketBase collections
|
||||
PB_ECONOMY_COLLECTION_DEV=economy_users_dev
|
||||
PB_ECONOMY_COLLECTION_ECONOMY=economy_users_prod
|
||||
PB_ECONOMY_COLLECTION_LAN=economy_users_lan
|
||||
PB_FIENTA_COLLECTION_LAN=fienta_registrations_lan
|
||||
PB_ECONOMY_COLLECTION=
|
||||
|
||||
# Fienta LAN registration sync
|
||||
FIENTA_WEBHOOK_SECRET=NC6A4BsaPkPmsT3dayph_p7lpP3-ExWpFFkZSKbljdk
|
||||
FIENTA_WEBHOOK_PORT=8090
|
||||
FIENTA_ADMIN_ALERT_CHANNEL_ID=1478302279894302812
|
||||
305
strings.py
305
strings.py
@@ -128,7 +128,6 @@ CMD: dict[str, str] = {
|
||||
"shutdown": "Lülita bot välja (ilma taaskäivituseta)",
|
||||
"pause": "Peata / jätka kõik käsklused (hooldusrežiim)",
|
||||
"send": "Saada sõnum valitud kanalisse",
|
||||
"profile": "Vaata oma profiili: saldo, tase, esemed, statistika ja kalakogu",
|
||||
"balance": "Vaata enda (või kellegi teise) TipiCOINide saldot",
|
||||
"daily": "Võta enda päevane TipiCOINi boonus",
|
||||
"work": "Tööta ja teeni TipiCOINe (1h ooteaeg)",
|
||||
@@ -158,20 +157,11 @@ CMD: dict[str, str] = {
|
||||
"adminunban": "[Admin] Eemalda majandussüsteemi keeld kasutajalt",
|
||||
"adminreset": "[Admin] Lähtesta kasutaja majandusandmed",
|
||||
"adminview": "[Admin] Vaata kasutaja majandusandmeid",
|
||||
"adminexp": "[Admin] Anna v\u00f5i v\u00f5ta EXP kasutajalt",
|
||||
"adminitem": "[Admin] Anna v\u00f5i eemalda ese kasutajalt (tasuta)",
|
||||
"allowchannel": "[Admin] Lisa kanal, kus bot võib vastata käsklustele",
|
||||
"denychannel": "[Admin] Eemalda kanal lubatud kanalite nimekirjast",
|
||||
"channels": "[Admin] Näita lubatud kanalite nimekirja",
|
||||
"economysetup": "[Admin] Loo ja sea korda majandussüsteemi rollid",
|
||||
"blackjack": "Mängi blackjacki TipiBOTi vastu",
|
||||
"prestige": "Prestiiži (nõuab taset 30) ja teeni Prestiižipunkte",
|
||||
"prestigeshop": "Vaata prestiižipoodi ja sinu uuenduste taset",
|
||||
"prestigebuy": "Osta prestiižiuuendus Prestiižipunktide eest",
|
||||
"fish": "Mine kalastama (interaktiivne mäng, 2min ooteaeg)",
|
||||
"fishbook": "Vaata oma kalakogu ja kogutud kalaliike",
|
||||
"fishsell": "Müü kalu oma inventarist",
|
||||
"fientasync": "[Admin] Sünkroniseeri LAN Fienta registreeringud uuesti",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -205,12 +195,6 @@ OPT: dict[str, str] = {
|
||||
"stats_kasutaja": "Mängija, kelle statistikat tahad vaadata (vaikimisi sina)",
|
||||
"adminseason_top_n": "Kui palju mängijaid võitis (vaikimisi 10)",
|
||||
"blackjack_panus": "Panus TipiCOINides ('all' = kogu saldo)",
|
||||
"prestigebuy_upgrade": "Uuenduse ID (vaata /prestigeshop)",
|
||||
"fishbook_kasutaja": "Mängija, kelle kalakogu vaadata (vaikimisi sina)",
|
||||
"profile_kasutaja": "Mängija, kelle profiili vaadata (vaikimisi sina)",
|
||||
"adminexp_kogus": "Positiivne = anna, negatiivne = võta",
|
||||
"adminitem_ese": "Eseme ID (kasutatav sisse, vaata /shop)",
|
||||
"adminitem_tegevus": "'anna' või 'eemalda'",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -234,7 +218,8 @@ HELP_CATEGORIES: dict[str, dict] = {
|
||||
"description": "TipiCOIN majandus",
|
||||
"color": 0xF4C430,
|
||||
"fields": [
|
||||
("/profile [@user]", "Saldo, tase, EXP progress, prestiiz - kõik ühes kohas. Nupud: Esemed · Statistika · Kalakogu."),
|
||||
("/balance [@user]", "Vaata enda (või kellegi teise) TipiCOINide saldot"),
|
||||
("/rank [@user]", "Vaata oma EXP, taset, progressi ja edetabeli positsiooni. T2 pood avaneb tasemel 10, T3 tasemel 20."),
|
||||
("/cooldowns", "Vaata kõikide käskude ooteaegu. Näitab ka vangla ooteaega."),
|
||||
("/daily", "Võta enda päevane TipiCOINide boonus. 20h ooteaeg. Streak'i boonus: 3d=+50%, 7d=+100%, 14d=+200%."),
|
||||
("/work", "Tööta ja teeni TipiCOINe (1h ooteaeg)"),
|
||||
@@ -260,7 +245,7 @@ HELP_CATEGORIES: dict[str, dict] = {
|
||||
("<:TipiMATT:1483387697132208128> XL hiirematt - 600 ⬡", "Kerjamise ooteaeg 5min → 3min."),
|
||||
("<:TipiKLAPID:1483387694083084349> Kõrvaklapid - 1200 ⬡", "Päevase boonuse ooteaeg 20h → 18h."),
|
||||
("<:TipiPILET:1483004308353060904> LAN pilet (2025) - 1200 ⬡", "Päevane boonus on duubeldatud."),
|
||||
("<:TipiVAC:1483004309510819860> Anticheat - 750 ⬡", "Röövimine sinu vastu ebaõnnestub. Pärast 2 kasutust pead ostma uue."),
|
||||
("<:TipiVAC:1483004309510819860> Anticheat - 1000 ⬡", "Röövimine sinu vastu ebaõnnestub. Pärast 2 kasutust pead ostma uue."),
|
||||
("<:TipiBULL:1483004310924300409> Red Bull - 800 ⬡", "30% tõenäosus, et teenid töötades 3x rohkem."),
|
||||
("<:TipiLAP:1483004307161874566> Botikoobas - 1500 ⬡", "RTX 5090 jooksutab botte 24/7. Päevane boonus genereerib 5% intressi sinu saldo pealt."),
|
||||
("<:TipiLAUD:1483387695576125440> Reguleeritav laud - 3500 ⬡ *(T2)*", "/work teenib 25% rohkem (stackib mängurihiirega)."),
|
||||
@@ -307,7 +292,6 @@ HELP_CATEGORIES: dict[str, dict] = {
|
||||
("/channels", "Näita lubatud kanalite nimekirja"),
|
||||
("/adminseason [top_n]", "Lõpeta võistlus, teavita võitjaid ja lähtesta EXP"),
|
||||
("/economysetup", "Loo ja sea korda majandussüsteemi rollid (ECONOMY + taseme rollid) boti rolli alla"),
|
||||
("/fientasync", "Sünkroniseeri LAN Fienta registreeringute rollid ja avalik tabel uuesti"),
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -340,7 +324,6 @@ REMINDER_OPTS: list[tuple[str, str, str]] = [
|
||||
("beg", "🙏 /beg", "Kerjamine (5min ooteaeg)"),
|
||||
("crime", "🦹 /crime", "Kuritegu (2t ooteaeg)"),
|
||||
("rob", "🔫 /rob", "Rööv (2t ooteaeg)"),
|
||||
("fish", "🎣 /fish", "Kalapüük (2min ooteaeg)"),
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -547,12 +530,8 @@ TITLE: dict[str, str] = {
|
||||
"jailbreak_bail": "💸 Kautsjon",
|
||||
"give": "<:TipiHEART:1483431377561976853> TipiCOINi ülekanne",
|
||||
"stats": "📊 Mängustatistika",
|
||||
"leaderboard_coins": "🪙 TipiBOTi edetabel - Mündid",
|
||||
"leaderboard_exp": "📊 TipiBOTi edetabel - EXP / Tase",
|
||||
"leaderboard_season": "🏆 TipiBOTi edetabel - Hooaja EXP",
|
||||
"leaderboard_prestige": "<:TipiFIRE:1483431381668335687> TipiBOTi edetabel - Prestiiž",
|
||||
"leaderboard_wagered": "🎲 TipiBOTi edetabel - Hasartmängud",
|
||||
"leaderboard_fish": "🎣 TipiBOTi edetabel - Kalapüük",
|
||||
"leaderboard_coins":"🪙 TipiBOTi edetabel - Mündid",
|
||||
"leaderboard_exp": "📊 TipiBOTi edetabel - EXP / Tase",
|
||||
"rps": "⚔️ Kivi, Paber, Käärid",
|
||||
"rps_duel": "⚔️ KPK duell",
|
||||
"rps_duel_active": "⚔️ KPK duell - käimas",
|
||||
@@ -577,16 +556,6 @@ TITLE: dict[str, str] = {
|
||||
"blackjack_push": "🤝 Viik!",
|
||||
"blackjack_dbust": "<:TipiSKULL:1483431378929451028> Üle 21 - mõlemad kaotasid!",
|
||||
"blackjack_dwin": "<:TipiFIRE:1483431381668335687> Topeltpanus võitis!",
|
||||
"prestige_confirm": "🔥 Prestiiž - kinnita",
|
||||
"prestige_success": "<:TipiFIRE:1483431381668335687> Prestiiž {level} saavutatud!",
|
||||
"prestige_too_low": "❌ Prestiiž pole saadaval",
|
||||
"prestige_shop": "<:TipiFIRE:1483431381668335687> Prestiižipood",
|
||||
"prestige_buy_ok": "✅ Uuendus ostetud!",
|
||||
"fish_cast": "🎣 Otsid kala...",
|
||||
"fish_bite": "🐟 KALA NÄKKAB!",
|
||||
"fish_escape": "🎣 Kala pääses!",
|
||||
"fish_junk": "🗑️ Ai ai ai...",
|
||||
"fishbook": "📖 Kalakogu",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -616,7 +585,6 @@ ERR: dict[str, str] = {
|
||||
"item_not_found": "❌ Eset ei leitud.",
|
||||
"item_level_req": "🔒 Selle eseme ostmiseks vajad **taset {min_level}** (sul on tase {user_level}). Teeni EXP-id kõiki käske kasutades.",
|
||||
"not_your_game": "❌ See pole sinu mäng!",
|
||||
"game_in_progress": "❌ Sul on juba mäng käimas! Lõpeta see enne.",
|
||||
"not_your_challenge":"❌ See väljakutse pole sulle!",
|
||||
"not_your_menu": "❌ See ei ole sinu menüü.",
|
||||
"give_self": "❌ Sa ei saa iseendale TipiCOINe anda.",
|
||||
@@ -639,7 +607,6 @@ ERR: dict[str, str] = {
|
||||
"channel_only": "❌ Boti käske saab kasutada ainult nendes kanalites: {channels}",
|
||||
"guild_only": "Seda käsku saab kasutada ainult serveris.",
|
||||
"sheet_error": "❌ Tabeli laadimine ebaõnnestus: ```{error}```",
|
||||
"gamble_cooldown": "🎰 Oled just mänginud! Saad uuesti mängida {ts}.",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -655,7 +622,6 @@ CD_MSG: dict[str, str] = {
|
||||
"heist": "⏳ Saad uuesti heisti teha {ts}.",
|
||||
"heist_global": "⏳ Pangahoidla alles kosub eelmisest röövist. Järgmine heist võimalik {ts}.",
|
||||
"jailed": "<:TipiTROLL:1483431380166774895> Oled vangis! Pääsed välja {ts}. Kasuta `/jailbreak`, et varem välja pääseda.",
|
||||
"fish": "🎣 Saad uuesti kalastada {ts}.",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -695,23 +661,20 @@ BJ: dict[str, str] = {
|
||||
ITEM_DESCRIPTIONS: dict[str, str] = {
|
||||
"gaming_hiir": "Koolist varastatud hiir? Ei, see on mängurihiir. Teeni töötades 50% rohkem TipiCOINe.",
|
||||
"hiirematt": "XXL suuruses, ainult parimast materjalist. Kerjamise ooteaeg 5min → 3min.",
|
||||
"korvaklapid": "Noise-cancelling - kuuled ainult TipiCOINide kõlinat. Päevase boonuse ooteaeg 20h → 18h + 25⬡ boonust.",
|
||||
"korvaklapid": "Noise-cancelling - kuuled ainult TipiCOINide kõlinat. Päevase boonuse ooteaeg 20h → 18h.",
|
||||
"lan_pass": "Ametlik TipiLANi pilet (2025). Päevane boonus on duubeldatud.",
|
||||
"energiajook": "Kolm Red Bulli järjest. 30% tõenäosus, et teenid töötades 3x rohkem.",
|
||||
"gaming_laptop": "RTX 5090 jooksutab botte 24/7. Päevane boonus genereerib 5% intressi sinu saldo pealt.",
|
||||
"anticheat": "VAC, EAC, Faceit AC ja BattlEye korraga. Röövimine sinu vastu ebaõnnestub. **2 kasutust**, siis pead ostma uue.",
|
||||
"reguleeritav_laud": "Võid nii seista kui istuda - alati võidad. /work teenib 25% rohkem (stackib mängurihiirega).",
|
||||
"jellyfin": "Self-hosted meediaserver - oled suurfirmadest sõltumatu. Röövimise edu 45% → 60%. Grupiröövi õnnestumisele +5%.",
|
||||
"jellyfin": "Self-hosted meediaserver - oled suurfirmadest sõltumatu. Röövimise edu tõenäosus 45% → 60%.",
|
||||
"mikrofon": "Parem helikvaliteet teeb sind usutavamaks. Teeni 30% rohkem eduka /crime puhul.",
|
||||
"klaviatuur": "Klõbinad kostuvad üle kogu saali. /beg teenib 2x rohkem.",
|
||||
"monitor": "240Hz ja 27 tolli. /work ooteaeg: 1h → 40min.",
|
||||
"cat6": "Gigabitine internet = ideaalne piraatluseks. /crime edu tõenäosus tõuseb 60% → 75%.",
|
||||
"monitor_360": "360Hz, 1ms. Mänguautomaadi jackpot 10x → 15x, kolmik 4x → 6x. Hasartmängude ooteaeg 30s → 25s.",
|
||||
"monitor_360": "360Hz, 1ms. Mänguautomaadi jackpot 10x → 15x, kolmik 4x → 6x.",
|
||||
"karikas": "Ainult legendidele. Streak ei nulli, kui sa mõne päeva vahele jätad.",
|
||||
"gaming_tool": "Nii mugav, et isegi admin ei saa sind üles. /crime ebaõnnestumine ei saada sind vanglasse.",
|
||||
"ussipurk": "Lakkumatu toiduga ussipurk - kalad ei saa vastu. Kalapyygi ooteaeg 2min → 90s.",
|
||||
"kalavork": "Suurem võrk = suuremad kalad. Kõigi kalade haruldus tõuseb ühe astme võrra.",
|
||||
"echolood": "Täpne ehholood näitab kala täpset asukohta. Haukamise aken 2s → 3s.",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -779,26 +742,19 @@ MEMBER_FIELDS: list[tuple[str, str]] = [
|
||||
]
|
||||
|
||||
ADMINVIEW_UI: dict[str, str] = {
|
||||
"title": "🔍 {name} - majandusandmed",
|
||||
"banned_yes": "🚫 JAH",
|
||||
"banned_no": "✅ Ei",
|
||||
"f_balance": "💰 Saldo",
|
||||
"f_exp": "📊 EXP / Tase",
|
||||
"f_streak": "🔥 Streak",
|
||||
"f_banned": "🚫 Keelatud",
|
||||
"f_jailed": "🚔 Vangis kuni",
|
||||
"f_prestige": "🔥 Prestiiž",
|
||||
"f_items": "🎒 Esemed",
|
||||
"f_uses": "🔢 Kasutused",
|
||||
"f_fish": "🎣 Kala",
|
||||
"f_last_daily": "⏱️ Viimati daily",
|
||||
"f_last_work": "⏱️ Viimati work",
|
||||
"f_last_crime": "⏱️ Viimati crime",
|
||||
"f_last_fish": "⏱️ Viimati fish",
|
||||
"footer": "ID: {uid}",
|
||||
"exp_val": "{exp} EXP (Tase {level})",
|
||||
"prestige_val": "Prestiiž {level} · {pp} PP",
|
||||
"fish_val": "{caught} püütud · {inv} inventaris",
|
||||
"title": "🔍 {name} - majandusandmed",
|
||||
"banned_yes": "🚫 JAH",
|
||||
"banned_no": "✅ Ei",
|
||||
"f_balance": "💰 Saldo",
|
||||
"f_streak": "🔥 Streak",
|
||||
"f_banned": "🚫 Keelatud",
|
||||
"f_jailed": "🚔 Vangis kuni",
|
||||
"f_items": "🎒 Esemed",
|
||||
"f_uses": "🔢 Kasutused",
|
||||
"f_last_daily": "⏱️ Viimati daily",
|
||||
"f_last_work": "⏱️ Viimati work",
|
||||
"f_last_crime": "⏱️ Viimati crime",
|
||||
"footer": "ID: {uid}",
|
||||
}
|
||||
|
||||
ECONOMYSETUP_UI: dict[str, str] = {
|
||||
@@ -860,11 +816,6 @@ BIRTHDAY_UI: dict[str, str] = {
|
||||
"footer": "Leht {month}/12 · {month_name}",
|
||||
}
|
||||
|
||||
BIRTHDAY_MONTHS: list[str] = [
|
||||
"Jaanuar", "Veebruar", "Märts", "Aprill", "Mai", "Juuni",
|
||||
"Juuli", "August", "September", "Oktoober", "November", "Detsember",
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /check summary strings
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -877,7 +828,6 @@ CHECK_UI: dict[str, str] = {
|
||||
"stat_uid": "Kasutaja ID",
|
||||
"stat_discord": "Discordi kasutajanimi",
|
||||
"stat_bday": "Sünnipäev",
|
||||
"no_name": "(no name)",
|
||||
"done": "**Kontroll lõpetatud!**",
|
||||
"already_ok": "✅ Juba korras: {count}",
|
||||
"fixed": "🔧 Parandatud: {count}",
|
||||
@@ -886,35 +836,9 @@ CHECK_UI: dict[str, str] = {
|
||||
"errors": "⚠️ Vead: {count}",
|
||||
"details_header": "**Üksikasjad:**",
|
||||
"details_more": "... ja {count} rohkem",
|
||||
"detail_error": "⚠️ {error}",
|
||||
"detail_nickname": "hüüdnimi",
|
||||
"detail_roles_added": "+rollid: {roles}",
|
||||
"detail_changed": "🔧 **{name}**: {parts}",
|
||||
"ids_filled": "\n🔑 Täideti **{count}** puuduvat kasutaja ID-d.",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /status UI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
STATUS_UI: dict[str, str] = {
|
||||
"title": "🖥️ Boti olek",
|
||||
"uptime_field": "🕐 Uptime",
|
||||
"uptime_val": "{hours}t {minutes}m {seconds}s",
|
||||
"latency_field": "📡 Latency",
|
||||
"latency_val": "{ms} ms",
|
||||
"ram_field": "🧠 RAM (RSS)",
|
||||
"ram_val": "{mb} MB",
|
||||
"cpu_field": "⚙️ CPU",
|
||||
"cpu_val": "{percent}%",
|
||||
"tasks_field": "🔄 Async tasks",
|
||||
"eco_players_field": "👤 Eco players",
|
||||
"members_cache_field": "📋 Liikmed (cache)",
|
||||
"log_files_field": "📂 Log files",
|
||||
"log_line": "`{name}` - {size_kb} KB",
|
||||
"none": "-",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin command responses and DMs
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -932,14 +856,6 @@ ADMIN: dict[str, str] = {
|
||||
"unban_dm": "✅ Sinu TipiBOTi majandussüsteemis osalemise keeld on tühistatud. Saad taas käske kasutada.",
|
||||
"reset_done": "🗑️ **{name}** majandusandmed on lähtestatud.\n📝 Põhjus: {reason}",
|
||||
"reset_dm": "🗑️ Admin lähtestas sinu TipiBOTi majandusandmed (saldo, esemed, streak).\n📝 Põhjus: *{reason}*",
|
||||
"exp_done": "{emoji} **{name}**: {verb} EXP → kokku **{exp} EXP** (Tase {level}).\n📝 Põhjus: {reason}",
|
||||
"exp_dm": "{emoji} Admin muutis sinu EXP-i: **{verb} EXP**\n📝 Põhjus: *{reason}*\nUus EXP: **{exp}** (Tase {level})",
|
||||
"item_given": "✅ **{item}** antud kasutajale **{name}** (tasuta).",
|
||||
"item_removed":"🗑️ **{item}** eemaldatud kasutajalt **{name}**.",
|
||||
"item_invalid":"❌ Tundmatu ese: `{item_id}`. Kontrolli `/shop` eseme ID-d.",
|
||||
"item_not_owned": "❌ **{name}** ei oma eset `{item_id}`.",
|
||||
"item_dm_given": "✅ Admin andis sulle eseme: **{item}**.",
|
||||
"item_dm_removed":"🗑️ Admin eemaldas sult eseme: **{item}**.",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -954,34 +870,6 @@ SEASON: dict[str, str] = {
|
||||
"done": "✅ Hooaeg lõpetatud - EXP, mündid ja esemed lähtestatud.",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /profile tabbed view
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PROFILE_UI: dict[str, str] = {
|
||||
"btn_profile": "💰 Profiil",
|
||||
"btn_items": "🎒 Esemed",
|
||||
"btn_stats": "📊 Statistika",
|
||||
"btn_fish": "🎣 Kalakogu",
|
||||
"main_title": "💰 {name}",
|
||||
"items_title": "🎒 {name} - Esemed",
|
||||
"stats_title": "📊 {name} - Statistika",
|
||||
"fish_title": "🎣 {name} - Kalakogu",
|
||||
"items_empty": "Sul pole ühtegi eset.",
|
||||
"f_balance": "💰 Saldo",
|
||||
"f_level": "📊 Tase",
|
||||
"f_streak": "🔥 Streak",
|
||||
"f_prestige": "⭐ Prestiiz",
|
||||
"f_jail": "🚔 Vangis kuni",
|
||||
"f_progress": "→ Tase {next}",
|
||||
"progress_bar": "`{bar}` {done}/{needed} EXP",
|
||||
"level_val": "Tase {level} - {role}",
|
||||
"prestige_val":"⭐ P{level} · {pp} PP",
|
||||
"footer_t1": "Tase 10 avab T2 poe · Tase 20 avab T3 poe",
|
||||
"footer_t2": "T2 pood avatud · Tase 20 avab T3 poe",
|
||||
"footer_t3": "T2 ja T3 pood avatud",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /balance embed strings
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1007,12 +895,10 @@ COOLDOWNS_UI: dict[str, str] = {
|
||||
"beg_line": "🙏 **/beg** {status}{note}",
|
||||
"crime_line": "🦹 **/crime** {status}",
|
||||
"rob_line": "🔫 **/rob** {status}",
|
||||
"fish_line": "🎣 **/fish** {status}{note}",
|
||||
"note_korvak": " *(kõrvaklapid: 18t)*",
|
||||
"note_monitor": " *(monitor: 40min)*",
|
||||
"note_hiirematt": " *(hiirematt: 3min)*",
|
||||
"note_ussipurk": " *(ussipurk: 90s)*",
|
||||
"jailed": "\n<EFBFBD> **Vanglas** - vabaneb <t:{ts}:R>",
|
||||
"jailed": "\n🚔 **Vanglas** - vabaneb <t:{ts}:R>",
|
||||
"jail_expired": "\n🔓 Vangla lõppes",
|
||||
}
|
||||
|
||||
@@ -1076,7 +962,6 @@ ROB_UI: dict[str, str] = {
|
||||
"win_desc": "Varastasid {stolen} kasutajalt **{name}**!\nSaldo: {balance}",
|
||||
"anticheat_desc": "**{name}** kaitseb end Anticheati'ga - said trahvi {fine}.",
|
||||
"anticheat_worn": "⚠️ Sinu **Anticheat** on kulunud! Osta uus `/buy` käsuga.",
|
||||
"victim_dm": "💸 **{robber}** varastas sinult **{stolen}** münti!",
|
||||
"fail_desc": "Jäid vahele! Trahv: {fine}.\nSaldo: {balance}",
|
||||
}
|
||||
|
||||
@@ -1102,8 +987,9 @@ BUY_UI: dict[str, str] = {
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
JAILBREAK_UI: dict[str, str] = {
|
||||
"btn_roll": "🎲 Viska täringud ({try_}/{max})",
|
||||
"rolling_desc": "<:TipiDICE:1485923107108556950> *Täringud lendavad...*",
|
||||
"btn_die1": "🎲 Viska 1. täring ({try_}/{max})",
|
||||
"btn_die2": "🎲 Viska 2. täring",
|
||||
"die1_desc": "1. täring: **{die}**\n\nNüüd viska 2. täring!",
|
||||
"free_desc": "{d1} {d2}\n\n✅ Viskasid duubli - pääsesid vanglast!",
|
||||
"miss_desc": "{d1} {d2}\n\n{left} katset jäänud. Proovi uuesti!",
|
||||
"intro_desc": "Oled vangis kuni {ts}.\n\nViska täringuid ja proovi **duublit** saada - siis pääsed tasuta vabaks!\nSul on **{tries} katset**. Ebaõnnestumisel saad valida: maksa kautsjon **(20–30% saldost, min 350 ⬡)** või jää vanglasse kuni aja lõpuni.",
|
||||
@@ -1123,22 +1009,13 @@ JAILBREAK_UI: dict[str, str] = {
|
||||
|
||||
LEADERBOARD_UI: dict[str, str] = {
|
||||
"house_entry": "🤖 {name} *(maja)* - {balance}",
|
||||
"house_default_name": "TipiBOT",
|
||||
"no_entries": "Keegi ei ole veel punkte teeninud.",
|
||||
"footer": "Lehekülg {page}/{total} · {count} mängijat",
|
||||
"btn_coins": "🪙 Mündid",
|
||||
"btn_exp": "📊 EXP",
|
||||
"btn_find_me": "📍 Mina",
|
||||
"exp_entry": "{prefix} {name} - {exp} EXP *(Tase {level})*",
|
||||
"unknown_user": "Kasutaja {uid}",
|
||||
"btn_season": "🏆 Hooaeg",
|
||||
"btn_prestige": "🔥 Prestiiž",
|
||||
"btn_wagered": "🎲 Hasartmäng",
|
||||
"btn_fish": "🎣 Kalapyyk",
|
||||
"season_entry": "{prefix} {name} - {exp} EXP *(Prestiiž {prestige})*",
|
||||
"prestige_entry": "{prefix} {name} - Prestiiž **{prestige}** · {pp} PP",
|
||||
"wagered_entry": "{prefix} {name} - {wagered} panustatud",
|
||||
"fish_entry": "{prefix} {name} - {caught} kala",
|
||||
"exp_entry": "{prefix} {name} - {exp} EXP *(Tase {level})*",
|
||||
"unknown_user": "Kasutaja {uid}",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1229,131 +1106,3 @@ BJ_UI: dict[str, str] = {
|
||||
"bust": " 💥",
|
||||
"balance_line": " · Saldo: {balance}",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prestige system strings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PRESTIGE_SHOP_NAMES: dict[str, str] = {
|
||||
"coin_mult": "Mündiboost",
|
||||
"exp_mult": "EXP-boost",
|
||||
"daily_plus": "Päevabonus+",
|
||||
"work_plus": "Töötaja+",
|
||||
}
|
||||
|
||||
PRESTIGE_SHOP_DESCRIPTIONS: dict[str, str] = {
|
||||
"coin_mult": "Kõik TipiCOINide teenimisallikad (daily, töö, kerja, kala) teenivad +8% rohkem iga taseme kohta. Max 5 taset → +40%.",
|
||||
"exp_mult": "Kõik EXP allikad teenivad +8% rohkem iga taseme kohta. Max 5 taset → +40%.",
|
||||
"daily_plus": "Päevase boonuse alussumma tõuseb +20% iga taseme kohta. Max 3 taset → +60%.",
|
||||
"work_plus": "/work teenib +20% rohkem iga taseme kohta. Max 3 taset → +60%.",
|
||||
}
|
||||
|
||||
PRESTIGE_UI: dict[str, str] = {
|
||||
"confirm_desc": (
|
||||
"Oled tasemel **{level}** ({exp} EXP).\n\n"
|
||||
"Prestiiži korral saad **{pp}** <:TipiFIRE:1483431381668335687> ja kõik lähtestub:\n"
|
||||
"• Saldo, EXP, esemed, ooteajad\n\n"
|
||||
"**Kalakogu jääb alles!**\n\nKas oled kindel?"
|
||||
),
|
||||
"btn_confirm": "🔥 Jah, prestiiži!",
|
||||
"btn_cancel": "❌ Tühista",
|
||||
"btn_tab_status": "⭐ Prestiiz",
|
||||
"btn_tab_shop": "🛍️ Uuendused",
|
||||
"success_desc": (
|
||||
"Said **{pp}** <:TipiFIRE:1483431381668335687>\n"
|
||||
"Prestiiži tase: **{level}**\n"
|
||||
"Kogutud PP: **{total_pp}** <:TipiFIRE:1483431381668335687>\n\n"
|
||||
"*Kõik lähtestati. Alusta otsast!*"
|
||||
),
|
||||
"too_low_desc": "Prestiiži jaoks vajad taset **{required}** (sul on tase {level}).",
|
||||
"shop_desc": "Sul on **{pp}** <:TipiFIRE:1483431381668335687> · Vajuta nuppu uuenduse ostmiseks",
|
||||
"shop_maxed": "✅ Max",
|
||||
"shop_level_fmt": "Tase {cur}/{max}",
|
||||
"shop_cost_fmt": "{cost} <:TipiFIRE:1483431381668335687>",
|
||||
"buy_success_desc":"**{name}** uuendatud tasemele **{new_level}/{max_level}**!\nPP alles: **{pp}** <:TipiFIRE:1483431381668335687>",
|
||||
"buy_no_pp": "<:TipICRY:1483431288852709387> Sul pole piisavalt PP. Sul on **{have}**, vajad **{need}** <:TipiFIRE:1483431381668335687>.",
|
||||
"buy_maxed": "❌ See uuendus on juba maksimumtasemel.",
|
||||
"buy_not_found": "❌ Sellist uuendust ei leitud. Vaata `/prestigeshop`.",
|
||||
"rank_line": "<:TipiFIRE:1483431381668335687> Prestiiž **{level}** · {pp} PP",
|
||||
"rank_season": "🏆 Hooaja EXP: **{exp}**",
|
||||
"btn_buy_upgrade": "{emoji} {name} +1 ({cost} PP)",
|
||||
"status_footer": "⭐ Prestiiž {level} · {pp} PP",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fishing system strings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FISH_NAMES: dict[str, str] = {
|
||||
"sarj": "Särg",
|
||||
"ahven": "Ahven",
|
||||
"koger": "Koger",
|
||||
"viidikas": "Viidikas",
|
||||
"latikas": "Latikas",
|
||||
"karpkala": "Karpkala",
|
||||
"linask": "Linask",
|
||||
"haug": "Haug",
|
||||
"angerjas": "Angerjas",
|
||||
"siig": "Siig",
|
||||
"forell": "Forell",
|
||||
"koha": "Koha",
|
||||
"tougjas": "Tõugjas",
|
||||
"lohe": "Lõhe",
|
||||
"vimb": "Vimb",
|
||||
}
|
||||
|
||||
FISH_RARITY_NAMES: dict[str, str] = {
|
||||
"common": "Tavaline",
|
||||
"uncommon": "Ebatavaline",
|
||||
"rare": "Haruldane",
|
||||
"epic": "Eepiline",
|
||||
"legendary": "Legendaarne",
|
||||
}
|
||||
|
||||
FISH_RARITY_EMOJI: dict[str, str] = {
|
||||
"common": "🐟",
|
||||
"uncommon": "🐠",
|
||||
"rare": "🎣",
|
||||
"epic": "⭐",
|
||||
"legendary": "🌟",
|
||||
}
|
||||
|
||||
FISH_JUNK_LINES: list[str] = [
|
||||
"Sa saad... **vana saabas**. Klassika.",
|
||||
"Õnnitlused, leidsid **kasutatud autorehvi**. Keskkond tänab sind... mitte.",
|
||||
"Taas üks **klaaspudel** rohkem jões.",
|
||||
"**Vana poes käimise kott**! Hoidis aega hästi.",
|
||||
"**Roostes konserv** - ilma sildita. Parem mitte teada, mis sees on.",
|
||||
"**Ummistunud drenaažitoru**. Keegi oli hoolimatu.",
|
||||
"**Tühi rahakott**. Kellegi päev läks halvemaks kui sinu oma.",
|
||||
"**Vana CD-plaat** - Evanescence, 2003. Heas seisukorras.",
|
||||
"Sa said **kivikese**. Ilus kivikene. Aga siiski kivikene.",
|
||||
"**Kaotsi läinud droon**. GPS ei tööta, aku tühi.",
|
||||
]
|
||||
|
||||
FISH_UI: dict[str, str] = {
|
||||
"btn_wait": "🎣 Oota näkkamist...",
|
||||
"btn_bite": "🐟 TÕMBA!",
|
||||
"btn_sell": "💰 Müü",
|
||||
"btn_keep": "🎒 Hoia",
|
||||
"cast_desc": "Viskad õnge vette. Oota, kuni kala näkkab...\n\n-# Vajuta nuppu, kui kala näkkab!",
|
||||
"bite_desc": "**KALA NÄKKAB!** Tõmba kiiresti! ⚡\n\n-# Sul on 2 sekundit!",
|
||||
"escape_desc": "Liiga hilja - kala lipsas minema. Proovi järgmine kord kiiremini!",
|
||||
"junk_desc": "{text}\n\n-# Saldo: {balance}",
|
||||
"catch_desc": "**{name}** · {weight}g · +{exp} EXP\n-# Kas müüd kohe ({value}) või hoiad inventaris?",
|
||||
"catch_sold": "**{name}** · {weight}g\n\n+{coins} · +{exp} EXP\nSaldo: {balance}",
|
||||
"catch_kept": "**{name}** · {weight}g lisatud inventarisse. *(+{exp} EXP)*",
|
||||
"new_fish": "\n✨ **Uus kala kalakogusse lisatud!**",
|
||||
"too_early": "❌ Kala pole veel näkkanud! Oota...",
|
||||
"book_caught": "Püütud kalaliike: **{caught}/{total}**",
|
||||
"book_yes": "✅ {emoji} **{name}** *({rarity})* · {count}×{inv}",
|
||||
"book_inv": " *(inventaris: {n})*",
|
||||
"book_no": "❓ **???** *({rarity})*",
|
||||
"book_footer": "Lehekülg {page}/{total_pages} · {caught}/{total} liiki",
|
||||
"book_empty": "Sa pole veel ühtegi kala püüdnud! Kasuta `/fish`.",
|
||||
"inv_empty": "Sinu kalainventaar on tühi! Kasuta `/fish` kala püüdmiseks.",
|
||||
"inv_header": "Sul on **{count}** kala inventaris *(kokku väärt {total_value})*",
|
||||
"inv_entry": "{emoji} **{name}** · {weight}g · {value}",
|
||||
"inv_sold_all": "Müüsid **{count}** kala kokku {coins} eest!\nSaldo: {balance}",
|
||||
"inv_none": "Inventaaris pole midagi müüa.",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user