21 Commits

Author SHA1 Message Date
Rene Arumetsa
b606b48686 Merge branch 'master' of ssh://git.lapikud.ee:202/renkar/tipibot
All checks were successful
Test & Deploy / test (push) Successful in 5s
Test & Deploy / deploy (push) Successful in 7s
# Conflicts:
#	bot.py
2026-09-04 10:47:23 +03:00
Rene Arumetsa
558d53bfa9 Merge branch 'fix/economy-money-safety' 2026-09-04 10:45:30 +03:00
Rene Arumetsa
837ad32757 docs(dev-notes): list new economy submodules and commands
Reflects bank/lootbox/achievements/lottery/consumables/vanity modules, the
pending-wager escrow + reconcile, effective_cooldown/ITEM_COOLDOWNS, and the
net-worth leaderboard/economy-stats helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT
2026-09-04 03:11:55 +03:00
Rene Arumetsa
d1cf6cdbd1 feat(economy): add /lottery - daily draw, weighted winner takes the pot
Buy tickets (200 coins each, max 100/draw); one winner is drawn daily at 21:00
Tallinn time weighted by ticket count and credited the whole pot. Coin-conserving
by design: each ticket's cost is deducted at purchase and the winner is minted
exactly the sum of all ticket spend - no shared pot record, so no cross-period
race. Ticket state lives per-user keyed by draw period (full scan only at draw
time and for the pot view).

- New lottery.py: TICKET_COST/MAX_TICKETS/DRAW_HOUR, pure period_for, and
  do_buy_ticket / get_lottery_state / do_lottery_draw. New lottery_tickets +
  lottery_period schema fields (period added to _TEXT_FIELDS).
- /lottery [kogus] command (view or buy) with full failure handling.
- Scheduled lottery_draw_daily loop in bot.py (both profiles; each draws its own
  collection), announcing to the optional LOTTERY_CHANNEL_ID (config + .env).

14 tests: period boundary, buy/accumulate/reset/caps/guards, pot state, draw
payout with coin conservation, and the more-tickets-wins-more weighting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT
2026-09-04 03:10:42 +03:00
Rene Arumetsa
ba044bf16f polish(economy): surface bank/lootbox/achievement stats in /stats and /adminview
- /stats and /profile stats now show lootboxes opened and achievements unlocked
  alongside the best streak.
- /adminview shows bank balance, achievements count, lootboxes opened, and any
  pending (escrowed) wager.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT
2026-09-04 03:02:27 +03:00
Rene Arumetsa
570d8b7cbd feat(economy): add /achievements milestone badges
15 one-time achievements over the lifetime stat counters (work/wealth/gambling/
crime/heists/fishing/streaks/prestige), each paying a modest one-time coin reward
when unlocked. Detection is lazy - opening /achievements claims any newly earned
(like quests roll on view) - so no per-command hook is needed, and rewards are a
bounded, one-time coin source.

- New achievements.py: ACHIEVEMENTS table, pure newly_earned/achievements_view,
  and locked do_check_achievements. New achievements_earned schema field.
- /achievements command (own view claims; viewing others is read-only) with
  progress bars, db_error handling, and an "unlocked" banner.

8 tests: threshold detection, one-time claim (no double pay), multi-unlock,
view progress capping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT
2026-09-04 02:57:34 +03:00
Rene Arumetsa
42bbdbd93d feat(economy): add /bank vault (rob-proof storage) + net-worth leaderboard
A strategic counterpart to /rob: coins moved to the bank are safe from /rob and
/heist (which only touch liquid balance) but earn no Bot Farm interest and can't
be spent, gambled or given until withdrawn - the deliberate trade-off against
keeping coins liquid.

- New bank.py (do_deposit/do_withdraw), bank_balance schema field.
- /bank (view), /deposit, /withdraw commands ('all' supported), with db_error
  handling; /balance shows the vault when non-zero.
- Coins leaderboard and /status money-supply now count net worth
  (balance + bank_balance), so banking never hides you from the board or the
  supply metric.
- Season reset wipes bank_balance too (no cross-season wealth hiding).

10 tests: deposit/withdraw math, guards, coin conservation, rob cannot touch the
vault, and net-worth accounting on the leaderboard + stats.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT
2026-09-04 02:53:14 +03:00
Rene Arumetsa
417617175e docs(readme): document /consumables, /lootbox and /vanity coin sinks
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT
2026-09-04 02:45:25 +03:00
Rene Arumetsa
b34c1e22da feat(economy): add /lootbox mystery box (coin sink with random reward)
A pay-to-open box (1000 coins) with a weighted reward: coin tiers (usually a net
loss - the sink), a random 30-min earn/exp buff, or a rare jackpot. All rolling
lives in do_open_lootbox for testability; the command adds a short reveal.

- New core module lootbox.py; extracted consumables.grant_buff (reused by both
  consumables and lootbox) to avoid duplicating the buff-stacking logic.
- New lootboxes_opened stat; pending schema syncs as a number automatically.
- Added /consumables, /lootbox, /vanity to the help embed (the first two were
  previously missing from /help).

Tests cover charging, insufficient/banned, the coin and buff outcomes, the
net-vs-reward invariant, and that balance never goes negative.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT
2026-09-04 02:44:46 +03:00
Rene Arumetsa
8481003edf feat(economy): persist interactive-game stakes so a restart can't eat them
Blackjack and RPS PvP deduct a stake up front and held it only in an in-memory
View - a restart mid-hand lost the coins. Now the stake is escrowed on the user
record (new pending_wager JSON field) in the SAME commit as the deduction, and:

- do_blackjack_bet accumulates the escrow (covers double/split); do_blackjack_payout
  clears it on settlement (incl. the 0-payout loss/timeout paths).
- do_rps_pvp_deposit records it; do_rps_pvp_payout/refund clear it, and a new
  do_rps_pvp_forfeit clears the loser's marker (their stake went to the winner).
- reconcile_pending_wagers() runs on startup (on_ready) and refunds any stake left
  escrowed by an interrupted game. It's idempotent and locks per user.

Schema: pending_wager auto-types as json via sync_pb_schema. Tests cover the full
escrow lifecycle, loser forfeit, and idempotent reconciliation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT
2026-09-04 02:37:43 +03:00
Rene Arumetsa
f72d72b355 fix(blackjack): return db_error on bet-commit failure; document new patterns
- do_blackjack_bet now wraps its _commit so a DB failure returns db_error
  (handled by the command layer) instead of raising through the interaction.
- DEV_NOTES "Adding a New Economy Command" checklist updated: use
  effective_cooldown / store.ITEM_COOLDOWNS as the single source of truth for
  item-modified cooldowns, and handle db_error via reply_db_error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT
2026-09-04 02:31:10 +03:00
Rene Arumetsa
4cd13503a7 feat(status): show money-supply metric in /status
Adds economy.get_economy_stats() (single-scan total coins, house balance,
player-held coins, player count) and a "Rahavaru" field to /status, so admins
can see whether the sinks are keeping pace with minted income. Also removed a
duplicate bot_admin_check import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT
2026-09-04 02:28:19 +03:00
Rene Arumetsa
eb7346b851 fix(economy): handle db_error in commands, dedupe cooldowns, single leaderboard scan
Three robustness/perf fixes from the review:

1. db_error UX: economy core returns {"ok": False, "reason": "db_error"} on a
   PocketBase outage, but no handler expected it - deferred commands hung on
   "thinking..." and others showed a misleading "you're broke". Added a shared
   reply_db_error helper (commands/_replies.py) + S.ERR["db_error"], and wired a
   db_error branch into every handler that can receive it (daily/work/beg/crime/
   rob/give/buy/roulette/slots/blackjack/heist/fish/prestige/vanity/consumables/
   request-funding). Also fixed a latent KeyError in vs-bot RPS that read
   res["balance"] without checking res["ok"].

2. Deduped the item->cooldown mapping that was copied in do_daily/do_work/do_beg,
   do_fish_start, _maybe_remind and _restore_reminders. Single source of truth:
   store.ITEM_COOLDOWNS + effective_cooldown(cmd, items).

3. /leaderboard did six full-collection scans (one per tab). Added
   get_all_leaderboards() which scans once and builds all six views in memory.

Tests: effective_cooldown cases, and get_all_leaderboards matches the individual
queries + scans the collection exactly once.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT
2026-09-04 02:26:07 +03:00
Rene Arumetsa
7a28617657 docs: add CLAUDE.md guidance for Claude Code
Architecture overview, dev/test commands, the dual-profile and re-export
patterns, the economy locking/PocketBase-schema footguns, and the test harness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT
2026-09-04 02:09:33 +03:00
Rene Arumetsa
037628d24f feat(economy): add vanity shop and harden economy money-safety
Vanity shop (/vanity): cosmetic badges/titles as a pure whale coin sink -
purchases burn coins (not credited to the house) and equip a badge shown on
/profile. New vanity_owned/vanity_active fields, schema sync, and tests.

Money-safety and robustness fixes from a codebase review:

- _parse_amount now rejects negative amounts. Every bet/give/request flows
  through it, so a negative value can no longer mint coins on a loss/transfer
  path that trusts the caller's sign (all call sites already guarded <= 0;
  this closes the source).
- do_blackjack_payout no longer raises on a DB failure. The stake was already
  deducted in do_blackjack_bet, so it now logs critical with the owed amount
  (for admin reconciliation) and returns db_error; all payout call sites render
  a clear "payout failed" notice instead of crashing the interaction.
- Instant "kohv" consumable now cancels the pending reminder DMs for the
  cooldowns it wipes (via new INSTANT_RESET_COMMANDS), so no stale/duplicate
  reminders fire.
- Renamed the misleadingly-named _refund_user_safe -> _debit_house_safe (it
  debits the house) and dropped its ignored first arg.
- Added __all__ to vanity.py and consumables.py so `import *` no longer leaks
  incidental imports into the economy namespace.
- Documented Kõrvaklapid's +25 coin daily bonus in README and DEV_NOTES.

Tests: blackjack payout DB-failure safety and INSTANT_RESET_COMMANDS lockstep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT
2026-09-04 02:09:25 +03:00
931941ae21 Merge pull request 'feat/participant-divider-roles' (#10) from feat/participant-divider-roles into master
All checks were successful
Test & Deploy / test (push) Successful in 5s
Test & Deploy / deploy (push) Successful in 7s
Reviewed-on: #10
2026-09-03 21:57:06 +00:00
Rene Arumetsa
fb08bc56d7 chore(teams): log role batch vs bot_top when edit_role_positions fails
A 50013 on positioning despite Manage Roles points to a role in the reorder
batch sitting at/above the bot's top role. Log the batch and bot_top so the
offending role is identifiable from the journal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPRsW4tazVtYi2jNzVQkre
2026-09-04 00:54:00 +03:00
Rene Arumetsa
a280ba05cc feat(teams): add Fienta as primary Discord->team source (sheet fallback)
The registration-log sheet only has in-game nicknames, so matching Discord
users to teams failed for ~10 of 40 teams. Fienta collects each competitor's
Discord username (+ sometimes user ID) and team name per ticket, giving a
reliable Discord-identity -> team mapping (validated: 201 usernames, 42 teams).

- core/fienta.py: token-auth client; fetch /events/{id}/tickets?attendees=true,
  parse competitor/coach/substitute tickets into {username|id -> team} and
  {team -> game}; exclude visitor/supporter/LAN/early-bird/waiting-list. No-op
  when FIENTA_API_TOKEN/FIENTA_EVENT_ID unset.
- member_sync: resolve_team() tries Fienta (id, then username) then the sheet;
  all_managed_team_names() and team_dividers() merge both sources.
- /teamsync + hourly task refresh Fienta alongside the sheet; enabled when
  either source is configured.
- config + .env.example: FIENTA_API_TOKEN, FIENTA_EVENT_ID.
- tests: fienta parsing (game detection, inclusion rules, id/username mapping).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPRsW4tazVtYi2jNzVQkre
2026-09-04 00:50:54 +03:00
Rene Arumetsa
3c8927184b feat(teams): grant each participant their game's divider role
Reuse the existing CS2/LoL divider roles as participant tags: sync_team_role
now also adds the divider role for the member's team's game, and strips any
other configured divider role (switched game / dropped out). Matched by ID
against config.TEAM_DIVIDERS, so only the divider roles are ever touched.

- TeamSyncResult gains divider_added / divider_removed; summary tallies both
- /teamsync report and log line surface divider_assigned / divider_removed
- tests: grants the game divider, swaps it on a game switch

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPRsW4tazVtYi2jNzVQkre
2026-09-04 00:00:41 +03:00
b813ed5f81 Merge pull request 'fix(teams): resolve dividers from tab name + full title, not last notice row' (#9) from feat/teamsync-reposition into master
All checks were successful
Test & Deploy / test (push) Successful in 5s
Test & Deploy / deploy (push) Successful in 7s
Reviewed-on: #9
2026-09-03 20:35:24 +00:00
b20bca9244 Merge pull request 'feat(teams): resolve team dividers by role ID instead of name' (#8) from feat/teamsync-reposition into master
All checks were successful
Test & Deploy / test (push) Successful in 5s
Test & Deploy / deploy (push) Successful in 6s
Reviewed-on: #8
2026-09-03 19:38:23 +00:00
54 changed files with 2722 additions and 122 deletions

View File

@@ -10,10 +10,18 @@ DISCORD_TOKEN=
SHEET_ID=your-google-sheet-id-here
# Separate spreadsheet holding tournament team registrations (Team Name + lineup
# of Discord usernames). Optional; drives /teamsync + hourly team-role sync on
# the economy/community bot. Leave unset to disable team-role sync entirely.
# of Discord usernames). Optional; a FALLBACK source for /teamsync + hourly
# team-role sync on the economy/community bot. Leave unset to disable it.
TEAM_SHEET_ID=
# Fienta ticketing - PRIMARY source for team-role sync. The registration collects
# each competitor's Discord username + team name per ticket, so matching is by
# real Discord handle (not game nickname). Get an API token from the Fienta admin
# (organizer settings) and the event's numeric ID from its dashboard URL. Leave
# unset to use only the sheet.
FIENTA_API_TOKEN=
FIENTA_EVENT_ID=
# Where each game's team roles get positioned in the role list. The key suffix
# is matched against the section's title row in the sheet ("TipiLAN 2026 CS2
# Registration Log") - every underscore-separated part must appear in it, so
@@ -46,6 +54,12 @@ BIRTHDAY_CHANNEL_ID=
# How many days before a birthday the on-join check counts as "coming up"
BIRTHDAY_WINDOW_DAYS=7
# Channel ID where the daily lottery draw result is announced (optional - the
# draw still runs and pays the winner if unset; per-profile like BIRTHDAY_CHANNEL)
LOTTERY_CHANNEL_ID_DEV=
LOTTERY_CHANNEL_ID_ECONOMY=
LOTTERY_CHANNEL_ID=
# PocketBase backend (https://pocketbase.io)
PB_URL=http://127.0.0.1:8090
PB_ADMIN_EMAIL=admin@example.com

3
.gitignore vendored
View File

@@ -10,3 +10,6 @@ pocketbase
pb_data/
pb_migrations/
logs/
fientalog
fientatickets
fientaorders

66
CLAUDE.md Normal file
View File

@@ -0,0 +1,66 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
Discord bot for the TipiLAN community (`discord.py`). Two responsibilities: **member management** (syncs nicknames/roles from a Google Sheet, birthday announcements) and the **TipiCOIN economy** (a large game economy stored in PocketBase). User-facing text is **Estonian**; only `docs/PATCHNOTES.md` content is English.
See `README.md` for the full feature/gameplay spec and `docs/DEV_NOTES.md` for the developer reference (add-a-command / add-a-shop-item checklists, constants).
## Commands
```bash
# Run tests (pure logic + economy flows against an in-memory PocketBase fake)
python -m pytest tests/ -q
# Single file / single test
python -m pytest tests/test_economy_pure.py -q
python -m pytest tests/test_economy_flows.py::TestDaily::test_streak -q
# Run the bot (requires .env; PocketBase must already be running)
BOT_PROFILE=dev python bot.py # or BOT_PROFILE=economy
```
Deps: `pip install -r requirements-dev.txt` (dev = runtime + pytest). CI (`.gitea/workflows/deploy.yml`) runs `pytest tests/ -q` on push to `master`, then deploys to the host by restarting `tipibot_dev` (canary) and `tipibot_eco` systemd units. The bot runs on host `tipilan-bots:/root/tipibot`; the local checkout has no `.env`/live DB.
## Architecture
Three layers, deliberately decoupled:
- **`core/`** — domain logic, **no Discord objects**. `core/economy/` is the economy package.
- **`commands/`** — one Discord slash-command group per file. Each exposes `register_<group>_commands(tree, bot, ...)`.
- **`bot.py`** — thin wiring layer: Discord client, event handlers (`on_ready`, `on_member_join`), background tasks, shared helpers, and it calls every `register_*_commands(...)` on startup, passing shared helpers down (`coin`, `cd_ts`, `award_exp`, `maybe_remind`, `parse_amount`).
### Dual-profile design (dev vs economy)
`BOT_PROFILE` env var (`dev` | `economy`) selects **everything** at import time in `config.py`: which Discord token, guild ID, birthday channel, and **PocketBase collection** (`economy_users_dev` vs `economy_users_prod`) are used. Legacy non-suffixed env vars act as fallbacks. Logs and `data/` are also namespaced per profile (`logs/<profile>/`, `data/<profile>/`). `/check`, `/member`, `/birthdays` are **dev-profile only**. When adding config, follow the `_DEV` / `_ECONOMY` + legacy-fallback pattern.
### The re-export pattern (important — two places)
Both `strings/` and `core/economy/` are packages split into submodules but **re-exported flat** through their `__init__.py`, so callers use `import strings as S; S.NAME` and `from core import economy; economy.do_daily(...)` unchanged.
- **Edit the submodule**, not the `__init__`. Strings live in `strings/{common,commands,member,economy,admin,games,fishing}.py`; economy logic in `core/economy/{store,income,gambling,fishing,jail,heist,prestige,shop,levels,quests,leaderboards,house,admin}.py`.
- `tests/test_strings.py` guards that every submodule name is re-exported — a new string that isn't re-exported fails CI.
- Caveat (see `tests/conftest.py:93`): the re-exported `economy.house` mutable state is a snapshot; live house state is owned by `economy.house`. Patch the submodule, not the alias.
### Economy persistence & concurrency (`core/economy/store.py`)
- Every mutation is a **read-modify-write**: `get_user(id)` → mutate the `UserData` dict → `await _commit(id, user)`. Money/EXP changes must also call `_txn(...)` for the transaction log.
- **Per-user async locks** serialize commits. Public mutating functions are decorated with `@_locked_by(<arg positions of user ids>)`. Two hard rules to avoid deadlock/lost-writes:
1. A `@_locked_by` function must **never call another `@_locked_by` function**.
2. House balance changes go through `_credit_house` (an atomic PocketBase increment, no lock) so they're safe to call while holding user locks.
Multi-user functions (`do_give`, `do_rob`) list both positions and acquire locks in sorted order.
- **PocketBase silently drops writes to fields not in the collection schema** — this is the #1 footgun. Adding any new persisted field means adding it to `_default_user()` **and** running `python scripts/sync_pb_schema.py` (reconciles both dev + prod collections). `store.missing_schema_fields()` / `/status` surface drift.
### Command result convention
`core.economy` functions return a dict with a `"reason"` key describing the outcome (success, `"cooldown"`, `"jailed"`, `"banned"`, insufficient funds, ...). The command handler in `commands/` maps every possible `res["reason"]` to a string/embed. On success it calls `award_exp(interaction, economy.EXP_REWARDS["<cmd>"])` and `maybe_remind(...)` where relevant.
## Adding an economy command or shop item
Follow the exact ordered checklists in `docs/DEV_NOTES.md` ("Adding a New Economy Command", "Adding a New Shop Item"). They enumerate every touchpoint across `core/economy/`, `strings/`, `commands/`, and `bot.py` (cooldowns, EXP rewards, help embed, reminders, item-modified cooldown branches). Missing a step generally means a silently broken feature rather than an error.
## Tests
No `pytest-asyncio`. Async tests wrap coroutines with the `run(coro)` helper from `tests/conftest.py` (which is `asyncio.run`). The `fake_pb` fixture monkeypatches `core.pb_client` with an in-memory `FakePocketBase` that mimics real PocketBase behaviour — including atomic `field+`/`field-` increments and **silently dropping writes to fields missing from the schema** (use `fake_pb_without_quest_fields` to simulate pre-migration schema drift). Prefer testing `core/` logic directly; there's no Discord in the test path.

View File

@@ -289,7 +289,7 @@ 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. Bot Farm adds 5% interest on your balance (capped at 500 ⬡/day). Prestige daily_plus adds +20% base per upgrade level. |
| `/daily` | 20h | 150 ⬡ | Streak multiplier applied (see below). Kõrvaklapid reduces cooldown to 18h and adds +25 ⬡. LAN pilet doubles the reward. Bot Farm adds 5% interest on your balance (capped at 500 ⬡/day). Prestige daily_plus adds +20% base per upgrade level. |
| `/work` | 1h | 1575 ⬡ | Random job flavour text. Mängurihiir +50%, Reguleeritav laud +25% (stacks). Red Bull: 30% chance of ×3. Ultralai monitor reduces cooldown to 40min. Prestige work_plus adds +20% per upgrade level. |
| `/beg` | 5min | 1040 ⬡ | Hiirematt reduces cooldown to 3min. Mehaaniline klaviatuur multiplies earnings ×2. |
| `/crime` | 2h | 200500 ⬡ | 60% success rate (75% with Cat6 kaabel). +30% earnings with Mikrofon on win. Fail = fine + 30min jail. Gaming tool skips jail on fail. |
@@ -380,11 +380,19 @@ 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 leaderboard with 6 tabs: 🪙 Coins (net worth = wallet + bank), 📊 EXP, 🏆 Season EXP, 🔥 Prestige, 🎲 Wagered, 🎣 Fish caught. House pinned at #0 on coins tab. |
| `/shop` | Browse all items by tier. Shows owned status, Anticheat charges remaining, and level lock for T2/T3. |
| `/buy <item>` | Purchase an item by name (partial match accepted). |
| `/bank` | View your vault. Banked coins are **rob-proof** (`/rob` and `/heist` only touch liquid balance) but earn no Bot Farm interest and can't be spent until withdrawn. |
| `/deposit <amount>` | Move coins from your wallet into the bank. `all` deposits everything liquid. |
| `/withdraw <amount>` | Move coins from the bank back to your wallet. `all` withdraws everything banked. |
| `/consumables` | Buy repeatable, expiring boosts (earn ×2, EXP ×2, or an instant cooldown wipe). A recurring coin sink. |
| `/lootbox` | Open a mystery box for **1 000 ⬡**. Weighted random reward: coin tiers (usually a small net loss), a 30-min earn/EXP ×2 buff, or a rare jackpot. |
| `/vanity` | Status shop — buy and equip cosmetic badges/titles (shown on `/profile`). Coins are **burned**, not recirculated. No gameplay effect. |
| `/reminders` | Toggle per-command DM notifications. Bot DMs you the moment each cooldown expires. |
| `/quests` | Personal daily (3) and weekly (2) quests with progress bars and a claim button. |
| `/achievements` | Milestone badges over your lifetime stats (work/wealth/gambling/crime/fishing/streaks/prestige). Each unlocks once and pays a one-time coin reward; opening the command claims any newly earned. |
| `/lottery [amount]` | View the pot or buy tickets (200 ⬡ each, max 100/draw). One winner is drawn daily at 21:00 Tallinn time and takes the whole pot — more tickets = higher weighted chance. Coins are conserved (the pot equals total ticket spend). Announced in `LOTTERY_CHANNEL_ID` if set. |
| `/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. |
@@ -468,7 +476,7 @@ All items are **permanent** once purchased **except Anticheat**, which expires a
| Hiirematt | 600 ⬡ | `/beg` cooldown 5min → 3min |
| Red Bull | 800 ⬡ | `/work` has 30% chance to earn ×3 |
| Anticheat | 1 000 ⬡ | Rob attempts against you fail and fine the robber. **2 uses**, then repurchase. |
| Kõrvaklapid | 1 200 ⬡ | `/daily` cooldown 20h → 18h |
| Kõrvaklapid | 1 200 ⬡ | `/daily` cooldown 20h → 18h and +25 ⬡ bonus |
| LAN pilet | 1 200 ⬡ | `/daily` reward ×2 |
| Bot Farm | 1 500 ⬡ | `/daily` adds 5% interest on balance (capped at 500 ⬡/day) |

141
bot.py
View File

@@ -21,7 +21,7 @@ import psutil
import config
import strings as S
from core import economy, pb_client, sheets
from core import economy, fienta, pb_client, sheets
from core.admin import is_bot_admin
from core.member_sync import SyncResult, sync_all_team_roles
from commands.dev_member_commands import register_dev_member_commands
@@ -339,31 +339,35 @@ async def before_birthday_daily():
@tasks.loop(hours=1)
async def team_sync_hourly():
"""Reload the tournament registration sheet and re-apply team roles.
"""Reload the tournament registration (Fienta + sheet) and re-apply team roles.
Economy profile only (the tournament players live in the community guild).
Runs the first iteration immediately on start, so this also covers the
initial load at boot. No-op when TEAM_SHEET_ID is unset.
initial load at boot. No-op when neither Fienta nor the team sheet is set.
"""
if IS_DEV_PROFILE or not config.TEAM_SHEET_ID:
if IS_DEV_PROFILE or not (config.TEAM_SHEET_ID or config.FIENTA_API_TOKEN):
return
try:
rosters = await sheets.refresh_teams()
fienta_teams = await fienta.refresh_teams()
except Exception as e:
log.error("team_sync_hourly: failed to load team sheet: %s", e)
log.error("team_sync_hourly: failed to load team data: %s", e)
return
if not rosters:
if not rosters and not fienta_teams:
return
guild = bot.get_guild(config.GUILD_ID)
if guild is None:
log.warning("team_sync_hourly: guild %s not found", config.GUILD_ID)
return
summary = await sync_all_team_roles(guild, log)
if summary.assigned or summary.removed or summary.created or summary.positioned or summary.errors:
if (summary.assigned or summary.removed or summary.created or summary.positioned
or summary.divider_assigned or summary.divider_removed or summary.errors):
log.info(
"team_sync_hourly: assigned=%d, removed=%d, created=%d, positioned=%d, errors=%d",
"team_sync_hourly: assigned=%d, removed=%d, created=%d, positioned=%d, "
"divider_assigned=%d, divider_removed=%d, errors=%d",
summary.assigned, summary.removed, len(summary.created),
summary.positioned, len(summary.errors),
summary.positioned, summary.divider_assigned, summary.divider_removed,
len(summary.errors),
)
for err in summary.errors:
log.warning("team_sync_hourly: %s", err)
@@ -374,6 +378,72 @@ async def before_team_sync_hourly():
await bot.wait_until_ready()
# ---------------------------------------------------------------------------
# Daily lottery draw (Tallinn-time DRAW_HOUR:00)
# ---------------------------------------------------------------------------
async def _resolve_channel(channel_id: int):
"""Best-effort fetch of a text channel by id (cache, then API)."""
if not channel_id:
return None
channel = bot.get_channel(channel_id)
if channel is None:
try:
channel = await bot.fetch_channel(channel_id)
except (discord.NotFound, discord.Forbidden, discord.HTTPException):
return None
return channel
@tasks.loop(time=datetime.time(hour=economy.lottery.DRAW_HOUR, minute=0, tzinfo=TALLINN_TZ))
async def lottery_draw_daily():
"""Draw the day's lottery winner and announce it (if a channel is set)."""
period = datetime.datetime.now(TALLINN_TZ).date().isoformat()
try:
result = await economy.do_lottery_draw(period)
except Exception:
log.exception("Lottery draw failed for %s", period)
return
channel = await _resolve_channel(config.LOTTERY_CHANNEL_ID)
if result is None:
log.info("Lottery draw %s: no participants", period)
if channel:
try:
await channel.send(S.LOTTERY_UI["draw_none"])
except discord.HTTPException:
pass
return
if not result.get("ok"):
log.error("Lottery draw %s could not pay winner %s (pot %s)",
period, result.get("winner_id"), result.get("pot"))
return
log.info("Lottery draw %s: winner %s won %s (%s/%s tickets)",
period, result["winner_id"], result["pot"],
result["winner_tickets"], result["total_tickets"])
if channel:
mention = f"<@{result['winner_id']}>"
embed = discord.Embed(
title=S.LOTTERY_UI["draw_title"],
description=S.LOTTERY_UI["draw_win"].format(
winner=mention,
pot=_coin(result["pot"]),
tickets=result["winner_tickets"],
total=result["total_tickets"],
chance=round(result["win_chance"] * 100, 1),
players=result["participants"],
),
color=0xF4C430,
)
try:
await channel.send(content=mention, embed=embed)
except discord.HTTPException:
pass
@lottery_draw_daily.before_loop
async def before_lottery_draw_daily():
await bot.wait_until_ready()
# ---------------------------------------------------------------------------
# Rotating rich presence
# ---------------------------------------------------------------------------
@@ -471,8 +541,14 @@ async def on_ready():
birthday_daily.start()
log.info("Birthday daily task started (fires 09:00 Tallinn time)")
# Start daily lottery draw (runs in every profile; each has its own collection)
if not lottery_draw_daily.is_running():
lottery_draw_daily.start()
log.info("Lottery draw task started (fires %02d:00 Tallinn time)", economy.lottery.DRAW_HOUR)
# Start hourly tournament team-role sync (economy/community guild)
if not IS_DEV_PROFILE and config.TEAM_SHEET_ID and not team_sync_hourly.is_running():
if (not IS_DEV_PROFILE and (config.TEAM_SHEET_ID or config.FIENTA_API_TOKEN)
and not team_sync_hourly.is_running()):
team_sync_hourly.start()
log.info("Team-role sync task started (hourly, from the registration sheet)")
@@ -484,6 +560,16 @@ async def on_ready():
# Re-schedule any reminder tasks lost on restart
await _restore_reminders()
# Refund stakes escrowed by interactive games (blackjack/RPS PvP) that a
# restart interrupted, so a mid-hand crash never eats a player's coins.
try:
refunded = await economy.reconcile_pending_wagers()
if refunded:
total = sum(amt for _, amt, _ in refunded)
log.info("Reconciled %d interrupted wager(s), refunded %d coins", len(refunded), total)
except Exception:
log.exception("Pending-wager reconciliation failed")
# Notify the channel where /restart was triggered
if _RESTART_FILE.exists():
try:
@@ -735,8 +821,10 @@ register_prestige_commands(
def _parse_amount(value: str, balance: int) -> tuple[int | None, str | None]:
"""Parse an amount string; 'all' resolves to the user's full balance.
Accepts plain integers and valid thousand-separated numbers (1,000 / 1.000 / 1 000).
Rejects decimals and ambiguous inputs like 1,1 or 1.5.
Accepts plain non-negative integers and valid thousand-separated numbers
(1,000 / 1.000 / 1 000). Rejects decimals, ambiguous inputs like 1,1 or 1.5,
and negative amounts (a negative bet/give would mint coins on loss/transfer
paths that trust the caller's sign).
Returns (amount, None) on success or (None, error_msg) on failure."""
v = value.strip()
if v.lower() == "all":
@@ -745,9 +833,12 @@ def _parse_amount(value: str, balance: int) -> tuple[int | None, str | None]:
if re.fullmatch(r'\d{1,3}([,. ]\d{3})*', v):
v = re.sub(r'[,. ]', '', v)
try:
return int(v), None
amount = int(v)
except ValueError:
return None, S.ERR["invalid_amount"]
if amount < 0:
return None, S.ERR["invalid_amount"]
return amount, None
# ---------------------------------------------------------------------------
@@ -814,17 +905,7 @@ async def _restore_reminders() -> None:
last_str = user.get(last_key)
if not last_str:
continue
items = user.get("items", [])
if cmd == "work" and "monitor" in items:
cooldown = datetime.timedelta(minutes=40)
elif cmd == "beg" and "hiirematt" in items:
cooldown = datetime.timedelta(minutes=3)
elif cmd == "daily" and "korvaklapid" in items:
cooldown = datetime.timedelta(hours=18)
elif cmd == "fish" and "ussipurk" in items:
cooldown = datetime.timedelta(seconds=90)
else:
cooldown = economy.COOLDOWNS.get(cmd)
cooldown = economy.effective_cooldown(cmd, user.get("items", []))
if not cooldown:
continue
last_dt = datetime.datetime.fromisoformat(last_str)
@@ -843,17 +924,7 @@ async def _maybe_remind(user_id: int, cmd: str) -> None:
user_data = await economy.get_user(user_id)
if cmd not in user_data.get("reminders", []):
return
items = set(user_data.get("items", []))
if cmd == "work" and "monitor" in items:
delay = datetime.timedelta(minutes=40)
elif cmd == "beg" and "hiirematt" in items:
delay = datetime.timedelta(minutes=3)
elif cmd == "daily" and "korvaklapid" in items:
delay = datetime.timedelta(hours=18)
elif cmd == "fish" and "ussipurk" in items:
delay = datetime.timedelta(seconds=90)
else:
delay = economy.COOLDOWNS.get(cmd, datetime.timedelta(hours=1))
delay = economy.effective_cooldown(cmd, user_data.get("items", [])) or datetime.timedelta(hours=1)
_schedule_reminder(user_id, cmd, delay)

25
commands/_replies.py Normal file
View File

@@ -0,0 +1,25 @@
"""Shared reply helpers for command handlers."""
from __future__ import annotations
import discord
import strings as S
async def reply_db_error(interaction: discord.Interaction) -> None:
"""Tell the user the database is unavailable.
Economy core functions return {"ok": False, "reason": "db_error"} on a
PocketBase outage. Without this, handlers either fall through silently (a
deferred interaction hangs on "thinking...") or show a misleading "you're
broke" message. Works whether or not the interaction was already deferred.
"""
msg = S.ERR["db_error"]
try:
if interaction.response.is_done():
await interaction.followup.send(msg, ephemeral=True)
else:
await interaction.response.send_message(msg, ephemeral=True)
except discord.HTTPException:
pass

View File

@@ -211,17 +211,29 @@ def register_economy_admin_commands(
prestige_pp = data.get("prestige_points", 0)
total_fish = data.get("total_fish_caught", 0)
inv_fish = len(data.get("fish_inventory") or [])
pw = data.get("pending_wager") or {}
wager_str = f"{pw.get('amount', 0):,} ({pw.get('kind')})" if pw.get("amount") else "-"
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_bank"], value=f"{data.get('bank_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_extras"],
value=S.ADMINVIEW_UI["extras_val"].format(
ach=len(data.get("achievements_earned") or []),
lootboxes=data.get("lootboxes_opened", 0),
wager=wager_str,
),
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)

View File

@@ -5,6 +5,7 @@ import datetime
import random
import time
from collections.abc import Awaitable, Callable, MutableSet
from zoneinfo import ZoneInfo
import discord
from discord import app_commands
@@ -13,6 +14,10 @@ from core import economy
from core.emoji import EMOJI as E
import strings as S
from ._replies import reply_db_error
_TALLINN = ZoneInfo("Europe/Tallinn")
def register_economy_extra_commands(
tree: app_commands.CommandTree,
@@ -120,6 +125,9 @@ def register_economy_extra_commands(
return
res = await economy.do_heist_check(interaction.user.id)
if not res["ok"]:
if res["reason"] == "db_error":
await reply_db_error(interaction)
return
if res["reason"] == "banned":
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
elif res["reason"] == "jailed":
@@ -270,6 +278,9 @@ def register_economy_extra_commands(
return
res = await economy.do_heist_check(interaction.user.id)
if not res["ok"]:
if res["reason"] == "db_error":
await reply_db_error(interaction)
return
if res["reason"] == "banned":
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
elif res["reason"] == "jailed":
@@ -518,6 +529,9 @@ def register_economy_extra_commands(
res = await economy.do_give(interaction.user.id, kasutaja.id, summa_int)
if not res["ok"]:
if res["reason"] == "db_error":
await reply_db_error(interaction)
return
if res["reason"] == "banned":
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
elif res["reason"] == "jailed":
@@ -543,6 +557,64 @@ def register_economy_extra_commands(
)
await interaction.response.send_message(embed=embed)
# -----------------------------------------------------------------------
# /lottery - daily draw, one weighted winner takes the pot
# -----------------------------------------------------------------------
@tree.command(name="lottery", description=S.CMD["lottery"])
@app_commands.describe(kogus=S.OPT["lottery_kogus"])
async def cmd_lottery(interaction: discord.Interaction, kogus: int | None = None):
period = economy.period_for(datetime.datetime.now(_TALLINN))
if kogus is None:
state = await economy.get_lottery_state(period, interaction.user.id)
embed = discord.Embed(
title=S.LOTTERY_UI["title"],
description=S.LOTTERY_UI["desc"].format(
draw_time=f"{economy.DRAW_HOUR}:00", cost=coin(state["ticket_cost"])
),
color=0xF4C430,
)
embed.add_field(name=S.LOTTERY_UI["f_pot"], value=coin(state["pot"]), inline=True)
embed.add_field(name=S.LOTTERY_UI["f_players"], value=str(state["participants"]), inline=True)
if state["your_tickets"]:
chance = round(state["your_tickets"] / state["total_tickets"] * 100, 1)
embed.add_field(
name=S.LOTTERY_UI["f_your"],
value=S.LOTTERY_UI["your_val"].format(tickets=state["your_tickets"], chance=chance),
inline=False,
)
else:
embed.add_field(name=S.LOTTERY_UI["f_your"], value=S.LOTTERY_UI["your_none"], inline=False)
await interaction.response.send_message(embed=embed)
return
if kogus <= 0:
await interaction.response.send_message(S.ERR["positive_amount"], ephemeral=True)
return
res = await economy.do_buy_ticket(interaction.user.id, kogus, period)
if not res["ok"]:
if res["reason"] == "db_error":
await reply_db_error(interaction)
elif res["reason"] == "banned":
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
elif res["reason"] == "max_tickets":
await interaction.response.send_message(
S.LOTTERY_UI["max_tickets"].format(cap=res["cap"], held=res["held"]), 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["invalid_amount"], ephemeral=True)
return
await interaction.response.send_message(
S.LOTTERY_UI["bought"].format(
count=res["bought"], cost=coin(res["cost"]),
tickets=res["tickets"], balance=coin(res["balance"]),
)
)
class LeaderboardView(discord.ui.View):
PER_PAGE = 10
@@ -751,14 +823,13 @@ def register_economy_extra_commands(
@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),
)
lbs = await economy.get_all_leaderboards() # single collection scan for all six tabs
coins_raw = lbs["coins"]
exp_raw = lbs["exp"]
season_raw = lbs["season"]
prestige_raw = lbs["prestige"]
wagered_raw = lbs["wagered"]
fish_raw = lbs["fish"]
house_entry = None
regular = []
@@ -865,6 +936,9 @@ def register_economy_extra_commands(
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"] == "db_error":
await reply_db_error(interaction)
return
if res["reason"] == "banned":
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
elif res["reason"] == "owned":

View File

@@ -10,6 +10,8 @@ from discord import app_commands
from core import economy
import strings as S
from ._replies import reply_db_error
def register_economy_fish_commands(
tree: app_commands.CommandTree,
@@ -218,6 +220,9 @@ def register_economy_fish_commands(
res = await economy.do_fish_start(interaction.user.id)
if not res["ok"]:
if res["reason"] == "db_error":
await reply_db_error(interaction)
return
if res["reason"] == "banned":
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
elif res["reason"] == "cooldown":

View File

@@ -12,6 +12,8 @@ from core import economy
from core.emoji import EMOJI as E
import strings as S
from ._replies import reply_db_error
def register_economy_games_commands(
tree: app_commands.CommandTree,
@@ -116,6 +118,9 @@ def register_economy_games_commands(
res = await economy.do_roulette(interaction.user.id, panus_int, värv.value)
if not res["ok"]:
active_games.discard(interaction.user.id)
if res["reason"] == "db_error":
await reply_db_error(interaction)
return
if res["reason"] == "banned":
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
elif res["reason"] == "jailed":
@@ -202,7 +207,14 @@ def register_economy_games_commands(
bet_line = ""
if self.bet > 0:
res = await economy.do_game_bet(interaction.user.id, self.bet, outcome)
if outcome == "win":
if not res.get("ok"):
if res.get("reason") == "db_error":
await reply_db_error(interaction)
return
# Player was jailed/went broke since the duel started - show the
# result without a bet line rather than crashing on res["balance"].
bet_line = ""
elif outcome == "win":
bet_line = S.RPS_UI["bet_win"].format(amount=coin(self.bet), balance=coin(res["balance"]))
elif outcome == "lose":
bet_line = S.RPS_UI["bet_lose"].format(amount=coin(self.bet), balance=coin(res["balance"]))
@@ -278,10 +290,12 @@ def register_economy_games_commands(
if self.bet > 0:
if winner == "a":
await economy.do_rps_pvp_payout(self.player_a.id, self.bet)
await economy.do_rps_pvp_forfeit(self.player_b.id)
bet_line_a = f"\n+{coin(self.bet)}"
bet_line_b = f"\n-{coin(self.bet)}"
elif winner == "b":
await economy.do_rps_pvp_payout(self.player_b.id, self.bet)
await economy.do_rps_pvp_forfeit(self.player_a.id)
bet_line_a = f"\n-{coin(self.bet)}"
bet_line_b = f"\n+{coin(self.bet)}"
else:
@@ -652,6 +666,9 @@ def register_economy_games_commands(
res = await economy.do_slots(interaction.user.id, panus_int)
if not res["ok"]:
active_games.discard(interaction.user.id)
if res["reason"] == "db_error":
await reply_db_error(interaction)
return
if res["reason"] == "banned":
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
return
@@ -926,13 +943,17 @@ def register_economy_games_commands(
embed = self._cur_embed(game_over=True, hand_results=hand_results)
embed.title = S.TITLE[title_key]
embed.color = color
if res.get("ok"):
result_line = result_str + S.BJ_UI["balance_line"].format(balance=coin(res["balance"]))
else:
result_line = result_str + "\n" + S.ERR["payout_failed"]
embed.add_field(
name=S.BJ["result_field"],
value=result_str + S.BJ_UI["balance_line"].format(balance=coin(res["balance"])),
value=result_line,
inline=False,
)
await self.message.edit(embed=embed, view=self)
if total_payout > total_invested:
if res.get("ok") and total_payout > total_invested:
asyncio.create_task(award_exp(interaction, economy.gamble_exp(total_invested)))
async def _do_dealer_reveal(self, interaction: discord.Interaction) -> None:
@@ -1013,6 +1034,9 @@ def register_economy_games_commands(
try:
res = await economy.do_blackjack_bet(self.user_id, self.bet)
if not res["ok"]:
if res["reason"] == "db_error":
await reply_db_error(interaction)
return
await interaction.response.send_message(
S.ERR["broke"].format(bal=coin(res.get("balance", 0))), ephemeral=True
)
@@ -1038,6 +1062,9 @@ def register_economy_games_commands(
try:
res = await economy.do_blackjack_bet(self.user_id, self.bet)
if not res["ok"]:
if res["reason"] == "db_error":
await reply_db_error(interaction)
return
await interaction.response.send_message(
S.ERR["broke"].format(bal=coin(res.get("balance", 0))), ephemeral=True
)
@@ -1097,6 +1124,9 @@ def register_economy_games_commands(
res = await economy.do_blackjack_bet(interaction.user.id, bet)
if not res["ok"]:
if res["reason"] == "db_error":
await reply_db_error(interaction)
return
if res["reason"] == "banned":
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
elif res["reason"] == "jailed":
@@ -1150,32 +1180,37 @@ def register_economy_games_commands(
await asyncio.sleep(_BJ_DEAL_DELAY)
if _bj_is_blackjack(dealer_hand):
push_res = await economy.do_blackjack_payout(interaction.user.id, bet, bet)
push_line = (
S.BJ["push_result"] + S.BJ_UI["balance_line"].format(balance=coin(push_res["balance"]))
if push_res.get("ok")
else S.BJ["push_result"] + "\n" + S.ERR["payout_failed"]
)
embed = _bj_embed(
player_hand,
dealer_hand,
S.TITLE["blackjack_push"],
0x99AAB5,
hide_dealer=False,
result_field=(
S.BJ["result_field"],
S.BJ["push_result"] + S.BJ_UI["balance_line"].format(balance=coin(push_res["balance"])),
),
result_field=(S.BJ["result_field"], push_line),
)
else:
payout = bet + int(bet * 1.5)
bj_res = await economy.do_blackjack_payout(interaction.user.id, payout, bet)
bj_line = (
f"+{coin(payout)}" + S.BJ_UI["balance_line"].format(balance=coin(bj_res["balance"]))
if bj_res.get("ok")
else f"+{coin(payout)}" + "\n" + S.ERR["payout_failed"]
)
embed = _bj_embed(
player_hand,
dealer_hand,
S.TITLE["blackjack_bj"],
0xF4C430,
hide_dealer=False,
result_field=(
S.BJ["result_field"],
f"+{coin(payout)}" + S.BJ_UI["balance_line"].format(balance=coin(bj_res["balance"])),
),
result_field=(S.BJ["result_field"], bj_line),
)
asyncio.create_task(award_exp(interaction, economy.gamble_exp(bet)))
if bj_res.get("ok"):
asyncio.create_task(award_exp(interaction, economy.gamble_exp(bet)))
active_games.discard(interaction.user.id)
await msg.edit(embed=embed)
return

View File

@@ -10,6 +10,8 @@ from discord import app_commands
from core import economy
import strings as S
from ._replies import reply_db_error
def register_economy_income_commands(
tree: app_commands.CommandTree,
@@ -25,6 +27,9 @@ def register_economy_income_commands(
await interaction.response.defer()
res = await economy.do_daily(interaction.user.id)
if not res["ok"]:
if res["reason"] == "db_error":
await reply_db_error(interaction)
return
if res["reason"] == "banned":
await interaction.followup.send(S.MSG_BANNED, ephemeral=True)
elif res["reason"] == "cooldown":
@@ -63,6 +68,9 @@ def register_economy_income_commands(
await interaction.response.defer()
res = await economy.do_work(interaction.user.id)
if not res["ok"]:
if res["reason"] == "db_error":
await reply_db_error(interaction)
return
if res["reason"] == "banned":
await interaction.followup.send(S.MSG_BANNED, ephemeral=True)
elif res["reason"] == "cooldown":
@@ -97,6 +105,9 @@ def register_economy_income_commands(
await interaction.response.defer()
res = await economy.do_beg(interaction.user.id)
if not res["ok"]:
if res["reason"] == "db_error":
await reply_db_error(interaction)
return
if res["reason"] == "banned":
await interaction.followup.send(S.MSG_BANNED, ephemeral=True)
elif res["reason"] == "cooldown":
@@ -128,6 +139,9 @@ def register_economy_income_commands(
await interaction.response.defer()
res = await economy.do_crime(interaction.user.id)
if not res["ok"]:
if res["reason"] == "db_error":
await reply_db_error(interaction)
return
if res["reason"] == "banned":
await interaction.followup.send(S.MSG_BANNED, ephemeral=True)
elif res["reason"] == "cooldown":
@@ -187,6 +201,9 @@ def register_economy_income_commands(
await interaction.response.defer()
res = await economy.do_rob(interaction.user.id, sihtmärk.id)
if not res["ok"]:
if res["reason"] == "db_error":
await reply_db_error(interaction)
return
if res["reason"] == "banned":
await interaction.followup.send(S.MSG_BANNED, ephemeral=True)
elif res["reason"] == "cooldown":

View File

@@ -9,6 +9,8 @@ from discord import app_commands
from core import economy
import strings as S
from ._replies import reply_db_error
def register_prestige_commands(
tree: app_commands.CommandTree,
@@ -127,6 +129,9 @@ def register_prestige_commands(
return
await interaction.response.defer()
res = await economy.do_prestige(self.user_id)
if not res["ok"] and res.get("reason") == "db_error":
await reply_db_error(interaction)
return
self.clear_items()
if not res["ok"]:
embed = discord.Embed(
@@ -166,6 +171,9 @@ def register_prestige_commands(
await interaction.response.defer()
res = await economy.do_prestige_buy(self.user_id, upgrade_id)
if not res["ok"]:
if res["reason"] == "db_error":
await reply_db_error(interaction)
return
if res["reason"] == "insufficient_pp":
err = S.PRESTIGE_UI["buy_no_pp"].format(have=res["have"], need=res["need"])
elif res["reason"] == "maxed":
@@ -221,6 +229,9 @@ def register_prestige_commands(
return
res = await economy.do_prestige_buy(interaction.user.id, upgrade.strip().lower())
if not res["ok"]:
if res["reason"] == "db_error":
await reply_db_error(interaction)
return
if res["reason"] == "banned":
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
elif res["reason"] == "not_found":

View File

@@ -10,6 +10,8 @@ from discord import app_commands
from core import economy
import strings as S
from ._replies import reply_db_error
def register_economy_profile_commands(
tree: app_commands.CommandTree,
@@ -29,10 +31,15 @@ def register_economy_profile_commands(
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,
badge = economy.vanity_badge(data)
title = (
f"{badge[0]} {target.display_name}"
if badge
else S.PROFILE_UI["main_title"].format(name=target.display_name)
)
embed = discord.Embed(title=title, color=0xF4C430)
if badge:
embed.set_author(name=badge[1])
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"],
@@ -148,7 +155,12 @@ def register_economy_profile_commands(
)
embed.add_field(
name=S.STATS_UI["records_field"],
value=S.STATS_UI["records_val"].format(streak=_s("best_daily_streak")),
value=S.STATS_UI["records_val"].format(
streak=_s("best_daily_streak"),
lootboxes=_s("lootboxes_opened"),
achievements=len(data.get("achievements_earned") or []),
ach_total=len(economy.ACHIEVEMENTS),
),
inline=True,
)
return embed
@@ -287,6 +299,9 @@ def register_economy_profile_commands(
color=0xF4C430,
)
embed.add_field(name=S.BALANCE_UI["saldo"], value=coin(data["balance"]), inline=True)
bank_balance = data.get("bank_balance", 0)
if bank_balance:
embed.add_field(name=S.BANK_UI["f_bank"], value=coin(bank_balance), inline=True)
streak = data.get("daily_streak", 0)
if streak:
embed.add_field(
@@ -334,6 +349,53 @@ def register_economy_profile_commands(
data = await economy.get_user(target.id)
await interaction.response.send_message(embed=_balance_embed(target, data))
@tree.command(name="achievements", description=S.CMD["achievements"])
async def cmd_achievements(
interaction: discord.Interaction, kasutaja: discord.Member | None = None
):
target = kasutaja or interaction.user
note = ""
# Only the invoker's own view claims newly-earned achievements.
if target.id == interaction.user.id:
res = await economy.do_check_achievements(interaction.user.id)
if not res["ok"]:
await reply_db_error(interaction)
return
if res["new"]:
names = ", ".join(
f"{economy.ACHIEVEMENTS[a]['emoji']} {economy.ACHIEVEMENTS[a]['name']}"
for a in res["new"]
)
note = S.ACHIEVEMENTS_UI["unlocked_note"].format(
names=names, reward=coin(res["reward"])
) + "\n\n"
data = await economy.get_user(target.id)
rows = economy.achievements_view(data)
earned_count = sum(1 for r in rows if r["earned"])
lines = [
S.ACHIEVEMENTS_UI["row_earned"].format(
emoji=r["emoji"], name=r["name"], reward=coin(r["reward"])
)
if r["earned"]
else S.ACHIEVEMENTS_UI["row_locked"].format(
emoji=r["emoji"], name=r["name"], progress=r["progress"],
goal=r["goal"], reward=coin(r["reward"]),
)
for r in rows
]
title = S.ACHIEVEMENTS_UI["title"]
if target.id != interaction.user.id:
title += f" · {target.display_name}"
embed = discord.Embed(
title=title,
description=note
+ S.ACHIEVEMENTS_UI["desc"].format(earned=earned_count, total=len(rows))
+ "\n\n" + "\n".join(lines),
color=0xF4C430,
)
await interaction.response.send_message(embed=embed)
@tree.command(name="cooldowns", description=S.CMD["cooldowns"])
async def cmd_cooldowns(interaction: discord.Interaction):
data = await economy.get_user(interaction.user.id)
@@ -539,7 +601,12 @@ def register_economy_profile_commands(
)
embed.add_field(
name=S.STATS_UI["records_field"],
value=S.STATS_UI["records_val"].format(streak=_s("best_daily_streak")),
value=S.STATS_UI["records_val"].format(
streak=_s("best_daily_streak"),
lootboxes=_s("lootboxes_opened"),
achievements=len(data.get("achievements_earned") or []),
ach_total=len(economy.ACHIEVEMENTS),
),
inline=True,
)
await interaction.response.send_message(embed=embed, ephemeral=True)

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
import discord
@@ -8,6 +9,8 @@ from discord import app_commands
from core import economy
import strings as S
from ._replies import reply_db_error
def register_economy_support_commands(
tree: app_commands.CommandTree,
@@ -47,6 +50,9 @@ def register_economy_support_commands(
res = await economy.do_give(interaction.user.id, self._view.requester.id, amount)
if not res["ok"]:
self._view.remaining += amount # roll back the reservation
if res["reason"] == "db_error":
await reply_db_error(interaction)
return
data = await economy.get_user(interaction.user.id)
await interaction.response.send_message(
S.ERR["broke"].format(bal=coin(data["balance"])), ephemeral=True
@@ -212,6 +218,9 @@ def register_economy_support_commands(
res = await economy.do_buy_consumable(interaction.user.id, ese.value)
if not res["ok"]:
if res["reason"] == "db_error":
await reply_db_error(interaction)
return
if res["reason"] == "banned":
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
elif res["reason"] == "insufficient":
@@ -224,6 +233,10 @@ def register_economy_support_commands(
cons = res["consumable"]
if res["instant"]:
# Kohv wiped these cooldowns, so any reminder DM already scheduled for
# them is now stale - cancel it (the next command run reschedules).
for cmd in economy.INSTANT_RESET_COMMANDS:
cancel_reminder_task(interaction.user.id, cmd)
desc = S.CONSUMABLES_UI["bought_instant"].format(balance=coin(res["balance"]))
else:
key = "bought_extended" if res["extended"] else "bought_buff"
@@ -238,6 +251,181 @@ def register_economy_support_commands(
)
await interaction.response.send_message(embed=embed)
# -- /vanity ------------------------------------------------------------
def _vanity_embed(user_data: dict) -> discord.Embed:
owned = set(user_data.get("vanity_owned") or [])
active = user_data.get("vanity_active")
embed = discord.Embed(
title=S.VANITY_UI["title"],
description=S.VANITY_UI["desc"].format(bal=coin(user_data.get("balance", 0))),
color=0xF4C430,
)
for vid, v in economy.VANITY.items():
if vid == active:
status = S.VANITY_UI["line_active"]
elif vid in owned:
status = S.VANITY_UI["line_owned"]
else:
status = f"{v['cost']} {economy.COIN}"
embed.add_field(
name=f"{v['emoji']} {v['name']} · {status}",
value=S.VANITY_UI["entry_title"].format(title=v["title"]),
inline=False,
)
embed.set_footer(text=S.VANITY_UI["footer"])
return embed
@tree.command(name="vanity", description=S.CMD["vanity"])
@app_commands.describe(ese=S.OPT["vanity_ese"])
@app_commands.choices(
ese=[
app_commands.Choice(name=f"{v['emoji']} {v['title']} ({v['cost']} TipiCOINi)", value=vid)
for vid, v in economy.VANITY.items()
]
+ [app_commands.Choice(name=S.VANITY_UI["none_choice"], value=economy.vanity.NONE_ID)]
)
async def cmd_vanity(
interaction: discord.Interaction,
ese: app_commands.Choice[str] | None = None,
):
if ese is None:
data = await economy.get_user(interaction.user.id)
await interaction.response.send_message(
embed=_vanity_embed(data), ephemeral=True
)
return
res = await economy.do_vanity_select(interaction.user.id, ese.value)
if not res["ok"]:
if res["reason"] == "db_error":
await reply_db_error(interaction)
return
if res["reason"] == "banned":
await interaction.response.send_message(S.MSG_BANNED, 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
action = res["action"]
if action == "unequipped":
await interaction.response.send_message(S.VANITY_UI["unequipped"], ephemeral=True)
return
v = res["vanity"]
if action == "bought":
msg = S.VANITY_UI["bought"].format(
emoji=v["emoji"], title=v["title"], balance=coin(res["balance"])
)
else:
msg = S.VANITY_UI["equipped"].format(emoji=v["emoji"], title=v["title"])
await interaction.response.send_message(msg)
# -- /lootbox -----------------------------------------------------------
@tree.command(name="lootbox", description=S.CMD["lootbox"])
async def cmd_lootbox(interaction: discord.Interaction):
res = await economy.do_open_lootbox(interaction.user.id)
if not res["ok"]:
if res["reason"] == "db_error":
await reply_db_error(interaction)
elif res["reason"] == "banned":
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
else:
await interaction.response.send_message(
S.ERR["broke_need"].format(need=coin(res["need"])), ephemeral=True
)
return
# Suspense: show the box opening, then reveal the reward.
await interaction.response.send_message(
embed=discord.Embed(
title=S.LOOTBOX_UI["title"], description=S.LOOTBOX_UI["opening"], color=0xF4C430
)
)
msg = await interaction.original_response()
await asyncio.sleep(1.2)
if res["buff_kind"]:
line = S.LOOTBOX_UI["buff_" + res["buff_kind"]].format(min=res["buff_min"])
foot = S.LOOTBOX_UI["foot_buff"].format(balance=coin(res["balance"]))
color = 0x5865F2
else:
line = S.LOOTBOX_UI[res["outcome"]].format(coins=coin(res["reward_coins"]))
if res["net"] >= 0:
foot = S.LOOTBOX_UI["foot_win"].format(net=coin(res["net"]), balance=coin(res["balance"]))
color = 0x57F287
else:
foot = S.LOOTBOX_UI["foot_loss"].format(net=coin(abs(res["net"])), balance=coin(res["balance"]))
color = 0xF4C430 if res["outcome"] != "coins_small" else 0xED4245
embed = discord.Embed(title=S.LOOTBOX_UI["title"], description=line, color=color)
embed.set_footer(text=foot)
await msg.edit(embed=embed)
# -- /bank, /deposit, /withdraw -----------------------------------------
@tree.command(name="bank", description=S.CMD["bank"])
async def cmd_bank(interaction: discord.Interaction):
data = await economy.get_user(interaction.user.id)
embed = discord.Embed(
title=S.BANK_UI["title"], description=S.BANK_UI["desc"], color=0xF4C430
)
embed.add_field(name=S.BANK_UI["f_liquid"], value=coin(data.get("balance", 0)), inline=True)
embed.add_field(name=S.BANK_UI["f_bank"], value=coin(data.get("bank_balance", 0)), inline=True)
await interaction.response.send_message(embed=embed, ephemeral=True)
@tree.command(name="deposit", description=S.CMD["deposit"])
@app_commands.describe(summa=S.OPT["deposit_summa"])
async def cmd_deposit(interaction: discord.Interaction, summa: str):
data = await economy.get_user(interaction.user.id)
amount, err = parse_amount(summa, data.get("balance", 0))
if err or amount is None:
await interaction.response.send_message(err or S.ERR["invalid_amount"], ephemeral=True)
return
if amount <= 0:
await interaction.response.send_message(S.ERR["positive_amount"], ephemeral=True)
return
res = await economy.do_deposit(interaction.user.id, amount)
if not res["ok"]:
if res["reason"] == "db_error":
await reply_db_error(interaction)
elif res["reason"] == "banned":
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
else:
await interaction.response.send_message(S.BANK_UI["nothing_liquid"], ephemeral=True)
return
await interaction.response.send_message(
S.BANK_UI["deposited"].format(
amount=coin(res["amount"]), balance=coin(res["balance"]), bank=coin(res["bank"])
)
)
@tree.command(name="withdraw", description=S.CMD["withdraw"])
@app_commands.describe(summa=S.OPT["withdraw_summa"])
async def cmd_withdraw(interaction: discord.Interaction, summa: str):
data = await economy.get_user(interaction.user.id)
amount, err = parse_amount(summa, data.get("bank_balance", 0))
if err or amount is None:
await interaction.response.send_message(err or S.ERR["invalid_amount"], ephemeral=True)
return
if amount <= 0:
await interaction.response.send_message(S.ERR["positive_amount"], ephemeral=True)
return
res = await economy.do_withdraw(interaction.user.id, amount)
if not res["ok"]:
if res["reason"] == "db_error":
await reply_db_error(interaction)
elif res["reason"] == "banned":
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
else:
await interaction.response.send_message(S.BANK_UI["nothing_bank"], ephemeral=True)
return
await interaction.response.send_message(
S.BANK_UI["withdrawn"].format(
amount=coin(res["amount"]), balance=coin(res["balance"]), bank=coin(res["bank"])
)
)
class RemindersSelect(discord.ui.Select):
def __init__(self, user_id: int, current: list[str]):
self.user_id = user_id

View File

@@ -14,7 +14,7 @@ import logging
import discord
from discord import app_commands
from core import sheets
from core import fienta, sheets
from core.admin import bot_admin_check
from core.member_sync import sync_all_team_roles
import strings as S
@@ -38,24 +38,28 @@ def register_economy_team_commands(
try:
rosters = await sheets.refresh_teams()
fienta_teams = await fienta.refresh_teams()
except Exception as e:
await interaction.followup.send(
S.TEAMSYNC_UI["refresh_error"].format(error=e), ephemeral=True
)
return
if not rosters:
if not rosters and not fienta_teams:
await interaction.followup.send(S.TEAMSYNC_UI["disabled"], ephemeral=True)
return
summary = await sync_all_team_roles(guild, log)
await interaction.followup.send(_format_summary(summary), ephemeral=True)
log.info(
"/teamsync - scanned=%d, assigned=%d, removed=%d, created=%d, positioned=%d, errors=%d",
"/teamsync - scanned=%d, assigned=%d, removed=%d, created=%d, "
"positioned=%d, divider_assigned=%d, divider_removed=%d, errors=%d",
summary.scanned,
summary.assigned,
summary.removed,
len(summary.created),
summary.positioned,
summary.divider_assigned,
summary.divider_removed,
len(summary.errors),
)
@@ -74,6 +78,10 @@ def _format_summary(summary) -> str:
lines.append(S.TEAMSYNC_UI["created"].format(roles=", ".join(unique)))
if summary.positioned:
lines.append(S.TEAMSYNC_UI["positioned"].format(count=summary.positioned))
if summary.divider_assigned:
lines.append(S.TEAMSYNC_UI["divider_assigned"].format(count=summary.divider_assigned))
if summary.divider_removed:
lines.append(S.TEAMSYNC_UI["divider_removed"].format(count=summary.divider_removed))
if summary.errors:
lines.append(S.TEAMSYNC_UI["errors"].format(count=len(summary.errors)))

View File

@@ -13,9 +13,9 @@ from pathlib import Path
import discord
from discord import app_commands
from core import economy
from core.admin import bot_admin_check
import strings as S
from core.admin import bot_admin_check
def register_ops_admin_commands(
@@ -45,6 +45,7 @@ def register_ops_admin_commands(
latency_ms = round(bot.latency * 1000, 1)
cache_size = get_member_cache_size()
user_count = await count_economy_users()
eco_stats = await economy.get_economy_stats()
embed = discord.Embed(title=S.STATUS_UI["title"], color=0x57F287)
embed.add_field(
@@ -82,6 +83,18 @@ def register_ops_admin_commands(
value=str(cache_size),
inline=True,
)
total = eco_stats["total_coins"]
house_pct = round(eco_stats["house_balance"] / total * 100) if total else 0
embed.add_field(
name=S.STATUS_UI["supply_field"],
value=S.STATUS_UI["supply_val"].format(
total=f"{total:,}".replace(",", " "),
players=f"{eco_stats['player_coins']:,}".replace(",", " "),
house=f"{eco_stats['house_balance']:,}".replace(",", " "),
house_pct=house_pct,
),
inline=False,
)
log_lines = [
S.STATUS_UI["log_line"].format(name=p.name, size_kb=f"{p.stat().st_size / 1024:.1f}")

View File

@@ -28,6 +28,13 @@ SHEET_ID = os.getenv("SHEET_ID")
TEAM_SHEET_ID = os.getenv("TEAM_SHEET_ID")
GOOGLE_CREDS_PATH = os.getenv("GOOGLE_CREDS_PATH", "credentials.json")
# Fienta ticketing: the registration collects each competitor's Discord username
# (+ sometimes Discord user ID) and team name per ticket, giving a reliable
# Discord-identity -> team mapping the nickname-only sheet cannot. Primary source
# for team-role sync; the sheet stays as a fallback. Unset -> Fienta is skipped.
FIENTA_API_TOKEN = os.getenv("FIENTA_API_TOKEN", "")
FIENTA_EVENT_ID = os.getenv("FIENTA_EVENT_ID", "")
_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)
@@ -45,6 +52,15 @@ BIRTHDAY_CHANNEL_ID = (
BIRTHDAY_WINDOW_DAYS = int(os.getenv("BIRTHDAY_WINDOW_DAYS", "7"))
BASE_ROLE_IDS: list[int] = [1478304631930228779, 1478302278862766190]
# Channel where the daily lottery draw result is announced. Optional - if unset,
# the draw still runs and pays the winner, it just isn't announced.
_LEGACY_LOTTERY_CHANNEL_ID = _env_int("LOTTERY_CHANNEL_ID", 0)
LOTTERY_CHANNEL_ID_DEV = _env_int("LOTTERY_CHANNEL_ID_DEV", _LEGACY_LOTTERY_CHANNEL_ID)
LOTTERY_CHANNEL_ID_ECONOMY = _env_int("LOTTERY_CHANNEL_ID_ECONOMY", 0)
LOTTERY_CHANNEL_ID = (
LOTTERY_CHANNEL_ID_ECONOMY if BOT_PROFILE == "economy" else LOTTERY_CHANNEL_ID_DEV
)
def _parse_admin_roles(raw: str) -> dict[int, set[int]]:
"""Parse DISCORD_ADMIN_ROLES env var as "guild_id:role_id[:role_id...],guild_id:role_id...".

View File

@@ -16,9 +16,12 @@ from .store import (
)
from .house import *
from .house import _credit_house, _house_record_id
from .bank import *
from .levels import *
from .shop import *
from .consumables import *
from .vanity import *
from .lootbox import *
from .fishing import *
from .quests import *
from .quests import _ensure_quests, _pick_quests, _quest_view
@@ -28,9 +31,12 @@ from .gambling import *
from .prestige import *
from .leaderboards import *
from .heist import *
from .achievements import *
from .lottery import *
from .admin import *
from . import ( # noqa: E402 (submodules addressable as economy.store etc.)
admin, consumables, fishing, gambling, heist, house, income, jail,
leaderboards, levels, prestige, quests, shop, store,
achievements, admin, bank, consumables, fishing, gambling, heist, house,
income, jail, leaderboards, levels, lootbox, lottery, prestige, quests,
shop, store, vanity,
)

View File

@@ -0,0 +1,111 @@
"""Achievements: one-time milestone badges over the lifetime stat counters.
Each achievement watches a monotonic stat the bot already tracks and unlocks once
that stat crosses a threshold, paying a modest one-time coin reward. Detection is
lazy: do_check_achievements is called when the player opens /achievements (like
quests roll on view), so no hook is needed on every command. Rewards are bounded
(each pays once) so this is a small, capped coin source.
"""
from __future__ import annotations
from typing import TypedDict
from ..pb_client import DatabaseError
from .store import _commit, _locked_by, _txn, get_user
__all__ = [
"Achievement",
"ACHIEVEMENTS",
"newly_earned",
"achievements_view",
"do_check_achievements",
]
class Achievement(TypedDict):
stat: str # UserData counter this milestone watches
goal: int
name: str
emoji: str
reward: int # one-time coin payout
# Ordered roughly easy -> hard within each theme. Rewards scale with difficulty.
ACHIEVEMENTS: dict[str, Achievement] = {
# Grind
"work_10": {"stat": "work_count", "goal": 10, "name": "Töömesilane", "emoji": "🐝", "reward": 250},
"work_100": {"stat": "work_count", "goal": 100, "name": "Töönarkomaan", "emoji": "🛠️", "reward": 1_500},
"beg_50": {"stat": "beg_count", "goal": 50, "name": "Elukutseline kerjus", "emoji": "🥺", "reward": 500},
# Wealth
"earned_50k": {"stat": "lifetime_earned", "goal": 50_000, "name": "Jõukas", "emoji": "💰", "reward": 1_000},
"earned_500k": {"stat": "lifetime_earned", "goal": 500_000, "name": "TipiRIKAS", "emoji": "🤑", "reward": 5_000},
# Gambling
"wager_10k": {"stat": "total_wagered", "goal": 10_000, "name": "Hasartmängur", "emoji": "🎲", "reward": 750},
"wager_100k": {"stat": "total_wagered", "goal": 100_000, "name": "Kõrgete panuste mängija", "emoji": "🃏", "reward": 3_000},
"jackpot_1": {"stat": "slots_jackpots", "goal": 1, "name": "Jackpot!", "emoji": "🎰", "reward": 1_000},
# Crime & heists
"crime_25": {"stat": "crimes_succeeded", "goal": 25, "name": "Kurjategija", "emoji": "🦹", "reward": 1_000},
"heist_5": {"stat": "heists_won", "goal": 5, "name": "Pangaröövel", "emoji": "💣", "reward": 1_500},
# Fishing
"fish_25": {"stat": "total_fish_caught", "goal": 25, "name": "Kalur", "emoji": "🎣", "reward": 500},
"fish_250": {"stat": "total_fish_caught", "goal": 250, "name": "Kalapüügimeister", "emoji": "🐟", "reward": 3_000},
# Dedication
"streak_7": {"stat": "best_daily_streak", "goal": 7, "name": "Püsiv", "emoji": "🔥", "reward": 500},
"streak_30": {"stat": "best_daily_streak", "goal": 30, "name": "Pühendunud", "emoji": "🗓️", "reward": 2_500},
"prestige_1": {"stat": "prestige_level", "goal": 1, "name": "Taassünd", "emoji": "♻️", "reward": 2_000},
}
def _earned_ids(user) -> set[str]:
return set(user.get("achievements_earned") or [])
def newly_earned(user) -> list[str]:
"""Achievement ids whose threshold is met but which are not yet claimed."""
earned = _earned_ids(user)
return [
aid for aid, a in ACHIEVEMENTS.items()
if aid not in earned and int(user.get(a["stat"], 0) or 0) >= a["goal"]
]
def achievements_view(user) -> list[dict]:
"""Display rows for every achievement: progress + earned flag (ordered as
defined, earned last so unfinished goals surface first)."""
earned = _earned_ids(user)
rows = []
for aid, a in ACHIEVEMENTS.items():
prog = int(user.get(a["stat"], 0) or 0)
rows.append({
"id": aid, "name": a["name"], "emoji": a["emoji"],
"goal": a["goal"], "reward": a["reward"],
"progress": min(prog, a["goal"]), "earned": aid in earned,
})
rows.sort(key=lambda r: r["earned"]) # unearned first
return rows
@_locked_by(0)
async def do_check_achievements(user_id: int) -> dict:
"""Claim any newly-earned achievements and pay their one-time rewards."""
try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
new = newly_earned(user)
if not new:
return {"ok": True, "new": [], "reward": 0, "balance": user["balance"]}
earned = list(user.get("achievements_earned") or [])
total = 0
for aid in new:
earned.append(aid)
total += ACHIEVEMENTS[aid]["reward"]
user["achievements_earned"] = earned
user["balance"] += total
user["lifetime_earned"] = user.get("lifetime_earned", 0) + total
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
await _commit(user_id, user)
_txn("ACHIEVEMENTS", user=user_id, unlocked=",".join(new), reward=f"+{total}", bal=user["balance"])
return {"ok": True, "new": new, "reward": total, "balance": user["balance"]}

View File

@@ -22,6 +22,7 @@ async def do_season_reset(top_n: int = 10) -> list[tuple[str, int, int]]:
reset_fields = {
"exp": 0,
"balance": 0,
"bank_balance": 0,
"items": [],
"item_uses": {},
"last_daily": None,

63
core/economy/bank.py Normal file
View File

@@ -0,0 +1,63 @@
"""Bank vault: rob-proof coin storage.
Coins moved to the bank are safe from /rob and /heist (those target the liquid
`balance` only), but they earn no interest and cannot be spent, gambled or given
until withdrawn. This is the deliberate trade-off against keeping coins liquid,
where Bot Farm can earn interest but a robber can take a cut. Net worth
(balance + bank_balance) is what the coins leaderboard ranks, so banking never
hides you from the leaderboard.
"""
from __future__ import annotations
from ..pb_client import DatabaseError
from .store import _commit, _locked_by, _txn, get_user
__all__ = ["do_deposit", "do_withdraw"]
@_locked_by(0)
async def do_deposit(user_id: int, amount: int) -> dict:
"""Move `amount` coins from liquid balance into the bank vault."""
try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
if amount <= 0:
return {"ok": False, "reason": "invalid"}
if user["balance"] < amount:
return {"ok": False, "reason": "insufficient", "balance": user["balance"]}
user["balance"] -= amount
user["bank_balance"] = user.get("bank_balance", 0) + amount
try:
await _commit(user_id, user)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
_txn("BANK_DEPOSIT", user=user_id, amount=amount, bal=user["balance"], bank=user["bank_balance"])
return {"ok": True, "amount": amount, "balance": user["balance"], "bank": user["bank_balance"]}
@_locked_by(0)
async def do_withdraw(user_id: int, amount: int) -> dict:
"""Move `amount` coins from the bank vault back to liquid balance."""
try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
if amount <= 0:
return {"ok": False, "reason": "invalid"}
bank = user.get("bank_balance", 0)
if bank < amount:
return {"ok": False, "reason": "insufficient", "bank": bank}
user["bank_balance"] = bank - amount
user["balance"] += amount
try:
await _commit(user_id, user)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
_txn("BANK_WITHDRAW", user=user_id, amount=amount, bal=user["balance"], bank=user["bank_balance"])
return {"ok": True, "amount": amount, "balance": user["balance"], "bank": user["bank_balance"]}

View File

@@ -23,6 +23,18 @@ from ..pb_client import DatabaseError
from ..emoji import EMOJI as E
from .store import _commit, _locked_by, _now, _parse_dt, _txn, get_user
__all__ = [
"Consumable",
"CONSUMABLES",
"INSTANT_RESET_COMMANDS",
"active_buffs",
"buff_remaining",
"earn_mult",
"exp_buff_mult",
"grant_buff",
"do_buy_consumable",
]
class Consumable(TypedDict):
name: str
@@ -66,6 +78,11 @@ _BUFF_MULT: dict[str, float] = {"earn": 2.0, "exp": 2.0}
# Cooldown timestamps an instant "kohv" clears.
_COOLDOWN_FIELDS = ("last_work", "last_beg", "last_crime", "last_rob", "last_fish")
# The slash-command names whose cooldowns an instant "kohv" clears. The Discord
# layer uses these to cancel any pending reminder DMs, since the cooldowns they
# were scheduled for no longer exist.
INSTANT_RESET_COMMANDS = tuple(f.removeprefix("last_") for f in _COOLDOWN_FIELDS)
# ---------------------------------------------------------------------------
# Buff inspection helpers (pure - safe to call while holding a user lock)
@@ -100,6 +117,19 @@ def exp_buff_mult(user) -> float:
return _BUFF_MULT["exp"] if "exp" in active_buffs(user) else 1.0
def grant_buff(user, kind: str, duration_min: int) -> bool:
"""Add/extend a timed buff of `kind` on `user` in place. Stacks: extends from
the current expiry if still active, else starts now. Returns True if it
extended an existing buff. Pure - safe under a user lock (caller commits)."""
buffs = dict(user.get("active_buffs") or {})
current = _parse_dt(buffs.get(kind))
extended = current is not None and current > _now()
start = current if extended else _now()
buffs[kind] = (start + timedelta(minutes=duration_min)).isoformat()
user["active_buffs"] = buffs
return extended
# ---------------------------------------------------------------------------
# /consumables purchase
# ---------------------------------------------------------------------------
@@ -125,13 +155,7 @@ async def do_buy_consumable(user_id: int, cons_id: str) -> dict:
for field in _COOLDOWN_FIELDS:
user[field] = None
else:
buffs = dict(user.get("active_buffs") or {})
current = _parse_dt(buffs.get(cons["kind"]))
# Stack: extend from the current expiry if still active, else from now.
extended = current is not None and current > _now()
start = current if extended else _now()
buffs[cons["kind"]] = (start + timedelta(minutes=cons["duration_min"])).isoformat()
user["active_buffs"] = buffs
extended = grant_buff(user, cons["kind"], cons["duration_min"])
await _commit(user_id, user)
_txn("BUY_CONSUMABLE", user=user_id, item=cons_id, cost=f"-{cons['cost']}", bal=user["balance"])

View File

@@ -3,12 +3,11 @@
from __future__ import annotations
import random
from datetime import timedelta
from ..pb_client import DatabaseError
from .store import (
COOLDOWNS, _cooldown_remaining, _commit, _is_jailed, _locked_by, _now,
_prestige_mult, _txn, get_user,
_cooldown_remaining, _commit, _is_jailed, _locked_by, _now,
_prestige_mult, _txn, effective_cooldown, get_user,
)
@@ -81,7 +80,7 @@ async def do_fish_start(user_id: int) -> dict:
if jail := _is_jailed(user):
return {"ok": False, "reason": "jailed", "remaining": jail}
fish_cd = timedelta(seconds=90) if "ussipurk" in user["items"] else COOLDOWNS["fish"]
fish_cd = effective_cooldown("fish", user["items"])
if cd := _cooldown_remaining(user, "fish", override_cd=fish_cd):
return {"ok": False, "reason": "cooldown", "remaining": cd}

View File

@@ -6,7 +6,10 @@ import random
from ..pb_client import DatabaseError
from ..emoji import EMOJI as E
from .store import _commit, _is_jailed, _locked_by, _txn, get_user
from .store import (
_commit, _is_jailed, _locked_by, _log, _txn, add_pending_wager,
clear_pending_wager, get_user,
)
from .house import _credit_house
@@ -105,6 +108,7 @@ async def do_rps_pvp_deposit(user_id: int, bet: int) -> dict:
return {"ok": False, "reason": "insufficient"}
user["balance"] -= bet
user["total_wagered"] = user.get("total_wagered", 0) + bet
add_pending_wager(user, "rps", bet) # escrow survives a restart
try:
await _commit(user_id, user)
except DatabaseError:
@@ -125,6 +129,7 @@ async def do_rps_pvp_payout(winner_id: int, bet: int) -> dict:
user["lifetime_earned"] = user.get("lifetime_earned", 0) + bet
user["biggest_win"] = max(user.get("biggest_win", 0), bet)
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
clear_pending_wager(user) # winner's escrow settled
try:
await _commit(winner_id, user)
except DatabaseError:
@@ -133,6 +138,23 @@ async def do_rps_pvp_payout(winner_id: int, bet: int) -> dict:
return {"ok": True, "balance": user["balance"]}
@_locked_by(0)
async def do_rps_pvp_forfeit(loser_id: int) -> dict:
"""Release the loser's escrow marker without refunding - their stake was paid
to the winner as part of the 2*bet payout. Without this the loser's
pending_wager would linger and be wrongly refunded on the next restart."""
try:
user = await get_user(loser_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
clear_pending_wager(user)
try:
await _commit(loser_id, user)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
return {"ok": True}
@_locked_by(0)
async def do_rps_pvp_refund(user_id: int, bet: int) -> dict:
"""Refund a previously escrowed bet (tie / timeout / cancel)."""
@@ -142,6 +164,7 @@ async def do_rps_pvp_refund(user_id: int, bet: int) -> dict:
return {"ok": False, "reason": "db_error"}
user["balance"] = user.get("balance", 0) + bet
user["total_wagered"] = max(0, user.get("total_wagered", 0) - bet)
clear_pending_wager(user) # escrow returned
try:
await _commit(user_id, user)
except DatabaseError:
@@ -248,14 +271,29 @@ async def do_blackjack_bet(user_id: int, bet: int) -> dict:
if user["balance"] < bet:
return {"ok": False, "reason": "insufficient", "balance": user["balance"]}
user["balance"] -= bet
await _commit(user_id, user)
# Escrow the stake in the same commit (accumulates across double/split), so a
# restart mid-hand refunds it via reconcile_pending_wagers instead of eating it.
add_pending_wager(user, "blackjack", bet)
try:
await _commit(user_id, user)
except DatabaseError:
# Deduction never persisted, so the player was not charged - report it
# instead of raising through the interaction handler.
return {"ok": False, "reason": "db_error"}
return {"ok": True, "balance": user["balance"]}
@_locked_by(0)
async def do_blackjack_payout(user_id: int, payout: int, total_invested: int = 0) -> dict:
"""Credit the net payout. House receives the difference when payout < total_invested."""
user = await get_user(user_id)
"""Credit the net payout. House receives the difference when payout < total_invested.
The stake was already deducted in do_blackjack_bet, so a DB failure here must
not raise through the interaction handler and swallow the player's winnings:
report db_error like every other mutation so the caller can surface it."""
try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
user["balance"] += payout
user["balance"] = max(0, user["balance"])
user["total_wagered"] = user.get("total_wagered", 0) + total_invested
@@ -267,7 +305,18 @@ async def do_blackjack_payout(user_id: int, payout: int, total_invested: int = 0
elif net < 0:
user["lifetime_lost"] = user.get("lifetime_lost", 0) + abs(net)
user["biggest_loss"] = max(user.get("biggest_loss", 0), abs(net))
await _commit(user_id, user)
clear_pending_wager(user) # hand settled - release the escrow marker
try:
await _commit(user_id, user)
except DatabaseError:
# The stake is already gone (deducted in do_blackjack_bet) and this credit
# did not persist. Nothing to roll back locally - log the owed amount so an
# admin can reconcile with /admincoins.
_log.critical(
"blackjack payout commit failed for %s: owed payout=%s (invested=%s)",
user_id, payout, total_invested,
)
return {"ok": False, "reason": "db_error"}
house_gain = total_invested - payout
if house_gain > 0:
await _credit_house(house_gain)

View File

@@ -8,7 +8,7 @@ from .. import pb_client
from ..pb_client import DatabaseError
from . import house
from .store import HEIST_JAIL, _commit, _is_jailed, _now, _txn, _user_lock, get_user
from .house import _credit_house, _refund_house_safe, _refund_user_safe
from .house import _credit_house, _refund_house_safe, _debit_house_safe
# ---------------------------------------------------------------------------
@@ -98,6 +98,6 @@ async def do_heist_resolve(user_ids: list[int], success: bool) -> dict:
if success and payout_each > 0:
await _refund_house_safe(payout_each, "heist_win_compensate", uid)
elif not success and fine_credited:
await _refund_user_safe(house.HOUSE_ID, fine if 'fine' in locals() else 0, "heist_fail_compensate", uid)
await _debit_house_safe(fine if 'fine' in locals() else 0, "heist_fail_compensate", uid)
return {"ok": True, "payout_each": payout_each, "success": success, "failed_users": failed_users}

View File

@@ -75,9 +75,10 @@ async def _refund_house_safe(amount: int, context: str, related_uid: int) -> Non
)
async def _refund_user_safe(_unused_house_id, amount: int, context: str, uid: int) -> None:
"""Best-effort atomic debit of `amount` from the house (compensates a failed
user fine). Logs critical if it fails."""
async def _debit_house_safe(amount: int, context: str, uid: int) -> None:
"""Best-effort atomic debit of `amount` from the house. Compensates a fine
that was credited to the house but whose matching user debit failed to persist
(the coins must be pulled back out of the house). Logs critical if it fails."""
if HOUSE_ID is None or amount <= 0:
return
try:

View File

@@ -3,15 +3,16 @@
from __future__ import annotations
import random
from datetime import date, timedelta
from datetime import date
import strings
from ..pb_client import DatabaseError
from . import house
from .store import (
COOLDOWNS, JAIL_DURATION, PRESTIGE_SHOP, _commit, _cooldown_remaining,
_is_jailed, _locked_by, _log, _now, _prestige_mult, _txn, get_user,
JAIL_DURATION, PRESTIGE_SHOP, _commit, _cooldown_remaining,
_is_jailed, _locked_by, _log, _now, _prestige_mult, _txn, effective_cooldown,
get_user,
)
from .house import _credit_house
from .consumables import earn_mult
@@ -29,7 +30,7 @@ async def do_daily(user_id: int) -> dict:
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
daily_cd = timedelta(hours=18) if "korvaklapid" in user["items"] else COOLDOWNS["daily"]
daily_cd = effective_cooldown("daily", user["items"])
if cd := _cooldown_remaining(user, "daily", override_cd=daily_cd):
return {"ok": False, "reason": "cooldown", "remaining": cd}
@@ -109,7 +110,7 @@ async def do_work(user_id: int) -> dict:
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
work_cd = timedelta(minutes=40) if "monitor" in user["items"] else COOLDOWNS["work"]
work_cd = effective_cooldown("work", user["items"])
if cd := _cooldown_remaining(user, "work", override_cd=work_cd):
return {"ok": False, "reason": "cooldown", "remaining": cd}
if jail := _is_jailed(user):
@@ -163,7 +164,7 @@ async def do_beg(user_id: int) -> dict:
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
beg_cd = timedelta(minutes=3) if "hiirematt" in user["items"] else COOLDOWNS["beg"]
beg_cd = effective_cooldown("beg", user["items"])
if cd := _cooldown_remaining(user, "beg", override_cd=beg_cd):
return {"ok": False, "reason": "cooldown", "remaining": cd}

View File

@@ -3,14 +3,21 @@
from __future__ import annotations
from .. import pb_client
from . import house
from .levels import get_level
def _net_worth(r: dict) -> int:
"""Coins that count toward wealth: liquid balance + banked vault."""
return (r.get("balance", 0) or 0) + (r.get("bank_balance", 0) or 0)
async def get_leaderboard(top_n: int | None = 10) -> list[tuple[str, int]]:
"""Return top_n (user_id_str, balance) pairs sorted descending."""
"""Return top_n (user_id_str, net_worth) pairs sorted descending.
Net worth = balance + bank_balance, so banking coins does not hide them."""
records = await pb_client.list_all_records()
result = sorted(
((r["user_id"], r.get("balance", 0)) for r in records if r.get("user_id")),
((r["user_id"], _net_worth(r)) for r in records if r.get("user_id")),
key=lambda x: x[1],
reverse=True,
)
@@ -80,3 +87,60 @@ async def get_leaderboard_fish(top_n: int | None = 10) -> list[tuple[str, int]]:
reverse=True,
)
return result if top_n is None else result[:top_n]
async def get_all_leaderboards() -> dict[str, list[tuple]]:
"""Build every leaderboard view from a SINGLE collection scan.
/leaderboard shows six tabs; calling each get_leaderboard_* separately would
read the whole collection six times. This reads once and sorts in memory,
returning the same tuple shapes the individual functions produce (unbounded -
the command paginates)."""
records = await pb_client.list_all_records()
users = [r for r in records if r.get("user_id")]
def desc(keyfn) -> list[dict]:
return sorted(users, key=keyfn, reverse=True)
return {
"coins": [(r["user_id"], _net_worth(r))
for r in desc(_net_worth)],
"exp": [(r["user_id"], r.get("exp", 0), get_level(r.get("exp", 0)))
for r in desc(lambda r: r.get("exp", 0))],
"season": [(r["user_id"], r.get("season_total_exp", 0), r.get("prestige_level", 0))
for r in desc(lambda r: r.get("season_total_exp", 0))],
"prestige": [(r["user_id"], r.get("prestige_level", 0), r.get("prestige_points", 0))
for r in desc(lambda r: (r.get("prestige_level", 0), r.get("prestige_points", 0)))],
"wagered": [(r["user_id"], r.get("total_wagered", 0))
for r in desc(lambda r: r.get("total_wagered", 0))],
"fish": [(r["user_id"], r.get("total_fish_caught", 0))
for r in desc(lambda r: r.get("total_fish_caught", 0))],
}
async def get_economy_stats() -> dict[str, int]:
"""Money-supply snapshot from a single scan: total coins in circulation, how
much players hold vs. the house. Lets /status show whether the sinks (house,
vanity burn, bail, fines) are keeping pace with minted income."""
records = await pb_client.list_all_records()
house_id = str(house.HOUSE_ID) if house.HOUSE_ID is not None else None
total = 0
house_balance = 0
player_count = 0
for r in records:
uid = r.get("user_id")
if not uid:
continue
# Banked coins are still part of the money supply.
worth = (r.get("balance", 0) or 0) + (r.get("bank_balance", 0) or 0)
total += worth
if uid == house_id:
house_balance = worth
else:
player_count += 1
return {
"total_coins": total,
"house_balance": house_balance,
"player_coins": total - house_balance,
"player_count": player_count,
}

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

@@ -0,0 +1,91 @@
"""Mystery box (/lootbox): a pay-to-open coin sink with a weighted reward.
Buying always burns LOOTBOX_COST; the reward is usually worth less than the cost
(a deliberate sink, like consumables/vanity) but occasionally pays out big or
grants a timed buff. All randomness lives in do_open_lootbox so it stays a single,
testable core function; the command layer only renders the result.
"""
from __future__ import annotations
import random
from ..pb_client import DatabaseError
from .store import _commit, _locked_by, _txn, get_user
from .consumables import grant_buff
__all__ = ["LOOTBOX_COST", "do_open_lootbox"]
LOOTBOX_COST = 1_000
_BUFF_DURATION_MIN = 30
# (weight, outcome_key). Weights need not sum to 100. Coin outcomes roll an
# amount in the ranges below; "buff" grants a random 30-min earn/exp boost.
_OUTCOMES: list[tuple[int, str]] = [
(42, "coins_small"), # usually a net loss - the sink
(28, "coins_medium"),
(15, "buff"),
(10, "coins_big"),
(5, "jackpot"),
]
_COIN_RANGES: dict[str, tuple[int, int]] = {
"coins_small": (50, 500),
"coins_medium": (500, 1_200),
"coins_big": (1_200, 2_500),
"jackpot": (4_000, 9_000),
}
_BUFF_KINDS = ("earn", "exp")
@_locked_by(0)
async def do_open_lootbox(user_id: int) -> dict:
"""Charge LOOTBOX_COST and grant one weighted reward. Returns the outcome."""
try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
if user["balance"] < LOOTBOX_COST:
return {"ok": False, "reason": "insufficient", "need": LOOTBOX_COST - user["balance"]}
user["balance"] -= LOOTBOX_COST
outcome = random.choices(
[k for _, k in _OUTCOMES], weights=[w for w, _ in _OUTCOMES], k=1
)[0]
reward_coins = 0
buff_kind = None
if outcome == "buff":
buff_kind = random.choice(_BUFF_KINDS)
grant_buff(user, buff_kind, _BUFF_DURATION_MIN)
else:
lo, hi = _COIN_RANGES[outcome]
reward_coins = random.randint(lo, hi)
user["balance"] += reward_coins
user["lifetime_earned"] = user.get("lifetime_earned", 0) + reward_coins
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
if outcome == "jackpot":
user["biggest_win"] = max(user.get("biggest_win", 0), reward_coins)
net = reward_coins - LOOTBOX_COST
user["lootboxes_opened"] = user.get("lootboxes_opened", 0) + 1
await _commit(user_id, user)
_txn(
"LOOTBOX", user=user_id, outcome=outcome,
reward=f"+{reward_coins}" if reward_coins else (buff_kind or "-"),
net=f"{net:+}", bal=user["balance"],
)
return {
"ok": True,
"outcome": outcome,
"reward_coins": reward_coins,
"buff_kind": buff_kind,
"buff_min": _BUFF_DURATION_MIN if buff_kind else 0,
"net": net,
"balance": user["balance"],
}

149
core/economy/lottery.py Normal file
View File

@@ -0,0 +1,149 @@
"""Daily lottery: buy tickets, one weighted winner takes the whole pot.
Coin flow is conserved without any shared pot record: each ticket's cost is
deducted from the buyer at purchase, and at draw time the winner is credited
exactly the sum of every ticket's cost (tickets * TICKET_COST). More tickets =
higher win chance (weighted draw). Ticket state lives on each user's own record
keyed by the draw period, so a full scan is only needed at draw time and for the
/lottery pot view - never on the hot path.
The period is a draw-date ISO string computed by the caller (Tallinn-time aware);
core functions take it explicitly so they stay timezone-agnostic and testable.
"""
from __future__ import annotations
import random
from datetime import timedelta
from .. import pb_client
from ..pb_client import DatabaseError
from .store import _commit, _txn, _user_lock, get_user
__all__ = [
"TICKET_COST",
"MAX_TICKETS_PER_DRAW",
"DRAW_HOUR",
"period_for",
"do_buy_ticket",
"do_lottery_draw",
"get_lottery_state",
]
TICKET_COST = 200
MAX_TICKETS_PER_DRAW = 100 # per-user cap so one whale can't guarantee a win
DRAW_HOUR = 21 # Tallinn-time hour the daily draw fires
def period_for(now_local) -> str:
"""Draw-date (ISO) that tickets bought at `now_local` (a tz-aware local
datetime) count toward: today before DRAW_HOUR, else tomorrow (today's draw
has already fired). The draw loop itself draws for `now_local.date()`."""
d = now_local.date()
if now_local.hour >= DRAW_HOUR:
d = d + timedelta(days=1)
return d.isoformat()
async def do_buy_ticket(user_id: int, count: int, period: str) -> dict:
"""Buy `count` tickets for the draw on `period`. Deducts count*TICKET_COST."""
if count <= 0:
return {"ok": False, "reason": "invalid"}
async with _user_lock(user_id):
try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
# A new period resets any tickets left over from a previous draw.
held = user.get("lottery_tickets", 0) if user.get("lottery_period") == period else 0
if held + count > MAX_TICKETS_PER_DRAW:
return {"ok": False, "reason": "max_tickets", "held": held, "cap": MAX_TICKETS_PER_DRAW}
cost = count * TICKET_COST
if user["balance"] < cost:
return {"ok": False, "reason": "insufficient", "need": cost - user["balance"]}
user["balance"] -= cost
user["lottery_tickets"] = held + count
user["lottery_period"] = period
try:
await _commit(user_id, user)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
_txn("LOTTERY_BUY", user=user_id, tickets=count, period=period, cost=f"-{cost}", bal=user["balance"])
return {
"ok": True,
"bought": count,
"tickets": user["lottery_tickets"],
"cost": cost,
"balance": user["balance"],
}
def _participants(records: list[dict], period: str) -> list[tuple[str, int]]:
"""(user_id, tickets) for everyone holding tickets for `period`."""
out = []
for r in records:
uid = r.get("user_id")
if uid and r.get("lottery_period") == period and (r.get("lottery_tickets", 0) or 0) > 0:
out.append((uid, int(r["lottery_tickets"])))
return out
async def get_lottery_state(period: str, user_id: int | None = None) -> dict:
"""Pot / participant snapshot for the /lottery view."""
records = await pb_client.list_all_records()
parts = _participants(records, period)
total_tickets = sum(t for _, t in parts)
your_tickets = 0
if user_id is not None:
your_tickets = next((t for uid, t in parts if uid == str(user_id)), 0)
return {
"pot": total_tickets * TICKET_COST,
"total_tickets": total_tickets,
"participants": len(parts),
"your_tickets": your_tickets,
"ticket_cost": TICKET_COST,
}
async def do_lottery_draw(period: str) -> dict | None:
"""Draw the winner for `period` and credit them the whole pot (minted, since
ticket costs were burned at purchase - net conserved). Returns the result, or
None if nobody entered."""
records = await pb_client.list_all_records()
parts = _participants(records, period)
if not parts:
return None
total_tickets = sum(t for _, t in parts)
pot = total_tickets * TICKET_COST
winner_id = int(random.choices(
[uid for uid, _ in parts], weights=[t for _, t in parts], k=1
)[0])
winner_tickets = next(t for uid, t in parts if uid == str(winner_id))
async with _user_lock(winner_id):
try:
winner = await get_user(winner_id)
except DatabaseError:
return {"ok": False, "reason": "db_error", "winner_id": winner_id, "pot": pot}
winner["balance"] += pot
winner["lifetime_earned"] = winner.get("lifetime_earned", 0) + pot
winner["biggest_win"] = max(winner.get("biggest_win", 0), pot)
winner["peak_balance"] = max(winner.get("peak_balance", 0), winner["balance"])
winner["lottery_tickets"] = 0 # consumed
try:
await _commit(winner_id, winner)
except DatabaseError:
return {"ok": False, "reason": "db_error", "winner_id": winner_id, "pot": pot}
_txn("LOTTERY_DRAW", winner=winner_id, period=period, pot=f"+{pot}",
tickets=winner_tickets, total_tickets=total_tickets, players=len(parts))
return {
"ok": True,
"winner_id": winner_id,
"pot": pot,
"winner_tickets": winner_tickets,
"total_tickets": total_tickets,
"participants": len(parts),
"win_chance": winner_tickets / total_tickets,
}

View File

@@ -87,6 +87,26 @@ COOLDOWNS: dict[str, timedelta] = {
"fish": timedelta(minutes=2),
}
# Items that shorten a command's cooldown: command -> (item_id, reduced cooldown).
# Single source of truth so the cooldown check (do_*), the reminder scheduler
# (_maybe_remind) and the restart restore (_restore_reminders) never drift apart.
ITEM_COOLDOWNS: dict[str, tuple[str, timedelta]] = {
"work": ("monitor", timedelta(minutes=40)),
"beg": ("hiirematt", timedelta(minutes=3)),
"daily": ("korvaklapid", timedelta(hours=18)),
"fish": ("ussipurk", timedelta(seconds=90)),
}
def effective_cooldown(cmd: str, items) -> timedelta | None:
"""The cooldown for `cmd` given the user's owned `items`, applying any
item-based reduction. Returns None for commands with no cooldown."""
override = ITEM_COOLDOWNS.get(cmd)
if override is not None and override[0] in items:
return override[1]
return COOLDOWNS.get(cmd)
JAIL_DURATION = timedelta(minutes=30)
HEIST_JAIL = timedelta(hours=1, minutes=30)
@@ -94,7 +114,8 @@ HEIST_JAIL = timedelta(hours=1, minutes=30)
# User schema
# ---------------------------------------------------------------------------
class UserData(TypedDict, total=False):
balance: int
balance: int # liquid coins - spendable, gamblable, robbable
bank_balance: int # vaulted coins - safe from /rob and /heist, not spendable until withdrawn
exp: int # lifetime EXP (resets each season)
last_daily: str | None
last_work: str | None
@@ -107,6 +128,8 @@ class UserData(TypedDict, total=False):
items: list[str]
item_uses: dict # {item_id: remaining_uses} for consumables
active_buffs: dict # {buff_kind: expiry_iso} for timed consumable boosts
vanity_owned: list[str] # cosmetic badge ids the user has purchased
vanity_active: str | None # currently-equipped vanity badge id (shown on /profile)
jailed_until: str | None # ISO datetime or None
jailbreak_used: bool
reminders: list[str] # command names user wants DM reminders for
@@ -130,6 +153,10 @@ class UserData(TypedDict, total=False):
total_given: int
total_received: int
best_daily_streak: int
lootboxes_opened: int
achievements_earned: list # ids of achievements already claimed
lottery_tickets: int # tickets held for the current lottery period
lottery_period: str | None # draw-date the held tickets count for (ISO date)
heist_global_cd_until: float
# Prestige system
prestige_level: int
@@ -144,11 +171,17 @@ class UserData(TypedDict, total=False):
# Quest system
quest_daily: dict # {"date": "YYYY-MM-DD", "quests": {qid: {snap, claimed}}}
quest_weekly: dict # {"week": "YYYY-Www", "quests": {qid: {snap, claimed}}}
# Coins a running interactive game (blackjack/RPS PvP) has deducted but not
# yet settled. {"kind": ..., "amount": int, "ts": iso} while escrowed, {}
# otherwise. Reconciled (refunded) on startup so a restart mid-game never
# eats the stake. See reconcile_pending_wagers.
pending_wager: dict
def _default_user() -> UserData:
return {
"balance": 0,
"bank_balance": 0,
"exp": 0,
"last_daily": None,
"last_work": None,
@@ -161,6 +194,8 @@ def _default_user() -> UserData:
"items": [],
"item_uses": {},
"active_buffs": {},
"vanity_owned": [],
"vanity_active": None,
"jailed_until": None,
"jailbreak_used": False,
"reminders": ["daily", "work", "beg", "crime", "rob"],
@@ -184,6 +219,10 @@ def _default_user() -> UserData:
"total_given": 0,
"total_received": 0,
"best_daily_streak": 0,
"lootboxes_opened": 0,
"achievements_earned": [],
"lottery_tickets": 0,
"lottery_period": None,
"heist_global_cd_until": 0.0,
# ── Prestige ─────────────────────────────────────────────────────────
"prestige_level": 0,
@@ -198,6 +237,8 @@ def _default_user() -> UserData:
# ── Quests ───────────────────────────────────────────────────────────
"quest_daily": {},
"quest_weekly": {},
# ── Interactive-game escrow (blackjack / RPS PvP) ────────────────────
"pending_wager": {},
}
@@ -375,6 +416,59 @@ async def _commit(user_id: int, user: UserData) -> dict | None:
raise DatabaseError(f"Failed to persist user {user_id}: {exc}") from exc
# ---------------------------------------------------------------------------
# Pending-wager escrow (interactive games survive a restart)
# ---------------------------------------------------------------------------
# Interactive games (blackjack, RPS PvP) deduct a stake up front and hold it in
# an in-memory View until the hand resolves. A restart would drop the View and
# lose the coins. To prevent that, the deduction commit also records the escrowed
# amount on the user (add_pending_wager), the settlement commit clears it
# (clear_pending_wager), and reconcile_pending_wagers refunds anything still
# outstanding at startup. All three mutate the user dict in place so the escrow
# state rides along in the SAME commit as the balance change (atomic).
def add_pending_wager(user: UserData, kind: str, amount: int) -> None:
"""Record/accumulate `amount` coins as escrowed by a `kind` game."""
pw = dict(user.get("pending_wager") or {})
pw = {
"kind": kind,
"amount": int(pw.get("amount", 0) or 0) + amount,
"ts": _now().isoformat(),
}
user["pending_wager"] = pw
def clear_pending_wager(user: UserData) -> None:
"""Mark the user's escrow settled (call in the settlement commit)."""
user["pending_wager"] = {}
async def reconcile_pending_wagers() -> list[tuple[int, int, str]]:
"""Refund every stake left escrowed by a game that a restart interrupted.
Runs once at startup (before commands are served). Returns the list of
(user_id, refunded_amount, kind) so the caller can log a summary."""
refunded: list[tuple[int, int, str]] = []
for uid_str, snapshot in (await get_all_users_raw()).items():
pw = snapshot.get("pending_wager") or {}
if int(pw.get("amount", 0) or 0) <= 0:
continue
uid = int(uid_str)
async with _user_lock(uid):
user = await get_user(uid)
pw = user.get("pending_wager") or {}
amount = int(pw.get("amount", 0) or 0)
if amount <= 0:
continue
kind = str(pw.get("kind", "?"))
user["balance"] += amount
clear_pending_wager(user)
await _commit(uid, user)
_txn("WAGER_RECONCILE", user=uid, refund=f"+{amount}", kind=kind, bal=user["balance"])
_log.info("Refunded interrupted %s wager: %s coins to user %s", kind, amount, uid)
refunded.append((uid, amount, kind))
return refunded
# ---------------------------------------------------------------------------
# /reminders
# ---------------------------------------------------------------------------

98
core/economy/vanity.py Normal file
View File

@@ -0,0 +1,98 @@
"""Vanity shop: cosmetic badges/titles - a pure status sink for the wealthy.
No gameplay effect whatsoever. Buying burns the coins (they are NOT credited
to the house, so they leave circulation for good) and unlocks a badge the
player can equip; the equipped badge shows on /profile. Prices scale steeply
on purpose - this is where the richest players dump coins for bragging rights,
draining the top end of the economy where inflation hurts most.
A single entry point, do_vanity_select, handles buy / equip / unequip so the
whole feature fits one slash command.
"""
from __future__ import annotations
from typing import TypedDict
from ..pb_client import DatabaseError
from .store import _commit, _locked_by, _txn, get_user
__all__ = [
"Vanity",
"VANITY",
"NONE_ID",
"vanity_badge",
"do_vanity_select",
]
class Vanity(TypedDict):
name: str # short shop name
emoji: str # the badge shown next to the player
title: str # the flavour title shown on /profile
cost: int
# Ordered cheapest -> priciest: a LAN/gaming status ladder from casual couch
# gamer to LAN legend. The top rungs are the deliberate whale sink.
VANITY: dict[str, Vanity] = {
"couch": {"name": "Sohvapadi", "emoji": "🎮", "title": "Sohvasõdur", "cost": 5_000},
"cables": {"name": "Võrgukaabel", "emoji": "🔌", "title": "Kaablihaldur", "cost": 8_000},
"discord": {"name": "Peakomplekt", "emoji": "🎧", "title": "Discordi Admin", "cost": 12_000},
"aimbot": {"name": "Kahtlane Hiir", "emoji": "🖱️", "title": "Aimbot Kahtlusalune", "cost": 18_000},
"sniper": {"name": "360Hz Monitor", "emoji": "🎯", "title": "Snaipripüss", "cost": 25_000},
"champ": {"name": "Meistrikarikas", "emoji": "🏆", "title": "Turniirivõitja", "cost": 40_000},
"legend": {"name": "Mängurijaam", "emoji": "🖥️", "title": "LAN Legend", "cost": 75_000},
}
# Sentinel choice value that unequips the active badge.
NONE_ID = "none"
def vanity_badge(user) -> tuple[str, str] | None:
"""Return (emoji, title) for the user's equipped badge, or None."""
vid = user.get("vanity_active")
vanity = VANITY.get(vid) if vid else None
return (vanity["emoji"], vanity["title"]) if vanity else None
@_locked_by(0)
async def do_vanity_select(user_id: int, vanity_id: str) -> dict:
"""Buy (if unowned), equip (if owned), or unequip (NONE_ID) a badge."""
try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
if vanity_id == NONE_ID:
user["vanity_active"] = None
await _commit(user_id, user)
_txn("VANITY_UNEQUIP", user=user_id)
return {"ok": True, "action": "unequipped"}
if vanity_id not in VANITY:
return {"ok": False, "reason": "not_found"}
owned = list(user.get("vanity_owned") or [])
vanity = VANITY[vanity_id]
# Already owned -> just equip it (free).
if vanity_id in owned:
user["vanity_active"] = vanity_id
await _commit(user_id, user)
_txn("VANITY_EQUIP", user=user_id, item=vanity_id)
return {"ok": True, "action": "equipped", "vanity": vanity}
# Not owned -> purchase (burns the coins) and auto-equip.
if user["balance"] < vanity["cost"]:
return {"ok": False, "reason": "insufficient", "need": vanity["cost"] - user["balance"]}
user["balance"] -= vanity["cost"] # burned: not credited to the house
owned.append(vanity_id)
user["vanity_owned"] = owned
user["vanity_active"] = vanity_id
await _commit(user_id, user)
_txn("VANITY_BUY", user=user_id, item=vanity_id, cost=f"-{vanity['cost']}", bal=user["balance"])
return {"ok": True, "action": "bought", "vanity": vanity, "balance": user["balance"]}

190
core/fienta.py Normal file
View File

@@ -0,0 +1,190 @@
"""Fienta ticketing integration - authoritative Discord -> team mapping.
The tournament registration on Fienta collects, per competitor ticket, the
player's Discord username (and sometimes Discord user ID), their team name
(order-level, echoed onto each attendee), and the ticket type (which names the
game). That gives a reliable Discord-identity -> team mapping the nickname-only
registration sheet cannot, so this is the PRIMARY source for team-role sync,
with :mod:`core.sheets` kept as a fallback.
Enabled by ``FIENTA_API_TOKEN`` + ``FIENTA_EVENT_ID``; when either is unset all
caches stay empty and every getter is a no-op, so team sync silently falls back
to the sheet. Only real team members get roles - competitor, coach/manager and
substitute tickets are included; visitors, supporters, LAN-access, early-bird
and waiting-list tickets are excluded.
"""
from __future__ import annotations
import logging
import aiohttp
import config
log = logging.getLogger(__name__)
_API_BASE = "https://fienta.com/api/v1"
_PAGE_SIZE = 1000 # Fienta's max per page; one page covers a whole tournament
# Ticket-type titles that represent an actual team member who should get a role.
_INCLUDED_KEYWORDS = ("competitor", "coach", "manager", "substitute")
# ...unless the title also matches one of these (visitors etc. are never players).
_EXCLUDED_KEYWORDS = (
"visitor", "supporter", "lan area", "early bird", "waiting list", "waitlist",
)
# Attendee custom-field keys. Fienta appends the field id to the machine name;
# these come from GET /events/{id}/custom-fields for event 176532.
_F_DISCORD_USERNAME = "discord_username_134871"
_F_DISCORD_USERID = "discord_user_id_135840"
_F_TEAM_NAME = "team_name_134821"
_GAME_KEYWORDS: list[tuple[str, tuple[str, ...]]] = [
("CS2", ("counter-strike", "counter strike", "cs2", "csgo", "cs:go")),
("LoL", ("league of legends", "league", "lol")),
]
# Caches, rebuilt by refresh_teams()/parse_tickets().
_by_username: dict[str, str] = {} # discord username (lower) -> team name
_by_userid: dict[str, str] = {} # discord user id (str) -> team name
_team_game: dict[str, str] = {} # team name -> "CS2" / "LoL"
_team_names: set[str] = set()
def _norm_username(name: str) -> str:
"""Normalise a Discord username for matching: lowercased, no leading @."""
return name.strip().lower().lstrip("@")
def _detect_game(ticket_type_title: str) -> str | None:
"""Return the game code for a ticket-type title, or None if unrecognised."""
title = ticket_type_title.lower()
for game, keywords in _GAME_KEYWORDS:
if any(k in title for k in keywords):
return game
return None
def _is_included(ticket_type_title: str) -> bool:
"""True when this ticket type is an actual team member (not a visitor etc.)."""
title = ticket_type_title.lower()
if any(k in title for k in _EXCLUDED_KEYWORDS):
return False
return any(k in title for k in _INCLUDED_KEYWORDS)
def _game_divider_ids() -> dict[str, int]:
"""Map game code -> divider role id, derived from ``config.TEAM_DIVIDERS``.
Reuses the same ``TEAM_DIVIDER_<SUFFIX>`` role IDs the sheet path uses: a
suffix like ``cs2_2026`` contributes its id to game ``CS2``.
"""
out: dict[str, int] = {}
for suffix, rid in config.TEAM_DIVIDERS.items():
parts = suffix.split("_")
if any(p in ("cs2", "cs", "csgo") for p in parts):
out.setdefault("CS2", rid)
if any(p in ("lol", "league") for p in parts):
out.setdefault("LoL", rid)
return out
def parse_tickets(tickets: list[dict]) -> None:
"""Rebuild the caches from a list of Fienta ticket objects.
Pure/synchronous so it can be unit-tested without hitting the API.
"""
global _by_username, _by_userid, _team_game, _team_names
by_username: dict[str, str] = {}
by_userid: dict[str, str] = {}
team_game: dict[str, str] = {}
for ticket in tickets:
rows = ticket.get("rows") or []
if not rows:
continue
row = rows[0]
title = (row.get("ticket_type") or {}).get("title", "")
if not _is_included(title):
continue
attendee = row.get("attendee") or {}
team = (attendee.get(_F_TEAM_NAME) or "").strip()
if not team:
continue
game = _detect_game(title)
# Keep the first non-None game seen for a team (all its tickets agree).
team_game[team] = game or team_game.get(team)
uname = _norm_username(attendee.get(_F_DISCORD_USERNAME) or "")
uid = (attendee.get(_F_DISCORD_USERID) or "").strip()
if uname:
by_username[uname] = team
if uid.isdigit():
by_userid[uid] = team
_by_username = by_username
_by_userid = by_userid
_team_game = team_game
_team_names = set(team_game)
async def refresh_teams() -> set[str]:
"""Fetch competitor tickets from Fienta and rebuild the caches.
No-op returning an empty set when ``FIENTA_API_TOKEN`` / ``FIENTA_EVENT_ID``
are unset, so the caller transparently falls back to the sheet.
"""
if not config.FIENTA_API_TOKEN or not config.FIENTA_EVENT_ID:
parse_tickets([])
return set()
url = f"{_API_BASE}/events/{config.FIENTA_EVENT_ID}/tickets"
headers = {"Authorization": f"Bearer {config.FIENTA_API_TOKEN}"}
tickets: list[dict] = []
async with aiohttp.ClientSession() as session:
page = 1
while True:
params = {"attendees": "true", "per_page": str(_PAGE_SIZE), "page": str(page)}
async with session.get(url, headers=headers, params=params) as resp:
resp.raise_for_status()
data = await resp.json()
batch = data.get("tickets") or []
tickets.extend(batch)
if len(batch) < _PAGE_SIZE:
break
page += 1
parse_tickets(tickets)
log.info(
"Fienta: %d tickets -> %d teams, %d discord usernames, %d discord ids",
len(tickets), len(_team_names), len(_by_username), len(_by_userid),
)
return set(_team_names)
def get_team_for_username(username: str) -> str | None:
"""Team the given Discord username is registered on, or None."""
return _by_username.get(_norm_username(username))
def get_team_for_userid(user_id: int) -> str | None:
"""Team the given Discord user ID is registered on, or None (IDs are sparse)."""
return _by_userid.get(str(user_id))
def all_team_names() -> set[str]:
"""Every team name seen in the included Fienta tickets."""
return set(_team_names)
def get_team_game(team: str) -> str | None:
"""Game code ("CS2"/"LoL") for a team, or None."""
return _team_game.get(team)
def get_team_dividers() -> dict[str, int]:
"""{team name -> divider role id}, via each team's game and config dividers."""
game_div = _game_divider_ids()
return {
team: game_div[game]
for team, game in _team_game.items()
if game and game in game_div
}

View File

@@ -11,9 +11,32 @@ from zoneinfo import ZoneInfo
import discord
import config
from . import sheets
from . import fienta, sheets
log = logging.getLogger(__name__)
def resolve_team(member: discord.Member) -> str | None:
"""Team a member is registered on: Fienta first (by ID, then username),
then the sheet by username. Fienta is authoritative; the sheet is fallback."""
return (
fienta.get_team_for_userid(member.id)
or fienta.get_team_for_username(member.name)
or sheets.get_team_for_username(member.name)
)
def all_managed_team_names() -> set[str]:
"""Union of every team name from Fienta and the sheet - the only role names
team sync ever adds or removes."""
return fienta.all_team_names() | sheets.all_team_names()
def team_dividers() -> dict[str, int]:
"""{team -> divider role id} merged from both sources; Fienta wins on overlap."""
merged = dict(sheets.get_team_dividers())
merged.update(fienta.get_team_dividers())
return merged
_PLACEHOLDER = {"-", "x", "n/a", "none", "ei"}
_TZ = ZoneInfo("Europe/Tallinn")
@@ -65,11 +88,13 @@ class TeamSyncResult:
added: str | None = None # team role name granted, if any
removed: list[str] = field(default_factory=list) # stale team roles taken away
created: str | None = None # team role name auto-created in the guild, if any
divider_added: str | None = None # game divider role granted as participant tag
divider_removed: list[str] = field(default_factory=list) # stale divider roles taken away
errors: list[str] = field(default_factory=list)
@property
def changed(self) -> bool:
return bool(self.added or self.removed)
return bool(self.added or self.removed or self.divider_added or self.divider_removed)
@dataclass
@@ -80,6 +105,8 @@ class TeamSyncSummary:
removed: int = 0
created: list[str] = field(default_factory=list)
positioned: int = 0 # team roles moved under a divider
divider_assigned: int = 0 # members given their game divider role
divider_removed: int = 0 # stale game divider roles taken away
changes: list[str] = field(default_factory=list) # human-readable per-member lines
errors: list[str] = field(default_factory=list)
@@ -260,18 +287,21 @@ async def sync_team_role(
* grants the role for the team they're registered on (auto-creating that
role in the guild when it does not exist yet);
* removes any *other* team role they still carry (left / switched teams).
* removes any *other* team role they still carry (left / switched teams);
* grants their game's divider role as a participant tag (and strips any
other configured divider role they still carry, i.e. switched game).
Only role NAMES present in the team sheet are ever added or removed, so no
Only team role NAMES present in the team sheet and the configured divider
role IDs (``config.TEAM_DIVIDERS``) are ever added or removed, so no
unrelated role is ever at risk. When ``TEAM_SHEET_ID`` is unset the caches
are empty and this is a no-op returning an unchanged result.
"""
result = TeamSyncResult()
team_name = sheets.get_team_for_username(member.name)
all_teams = sheets.all_team_names()
team_name = resolve_team(member)
all_teams = all_managed_team_names()
if not all_teams:
return result # feature switched off (no team sheet loaded)
return result # feature switched off (no Fienta token and no team sheet)
desired: discord.Role | None = None
if team_name:
@@ -307,6 +337,35 @@ async def sync_team_role(
except discord.HTTPException as e:
result.errors.append(f"Tiimirolli eemaldamise viga kasutajale {member}: {e}")
# --- Participant divider role (the game's divider role doubles as a tag) ---
# Grant the divider role for the member's team's game, and strip any other
# configured divider role (switched game / dropped out). Matched by ID, so
# only the roles named in config.TEAM_DIVIDERS are ever touched.
divider_ids = set(config.TEAM_DIVIDERS.values())
want_divider_id = team_dividers().get(team_name) if team_name else None
want_divider = guild.get_role(want_divider_id) if want_divider_id else None
if want_divider is not None and want_divider not in member.roles:
try:
await member.add_roles(want_divider, reason="Team sync: mänguosaleja")
result.divider_added = want_divider.name
except discord.Forbidden:
log.debug("No permission to add divider role for %s, skipping", member)
except discord.HTTPException as e:
result.errors.append(f"Eraldajarolli viga kasutajale {member}: {e}")
stale_dividers = [
r for r in member.roles if r.id in divider_ids and r.id != want_divider_id
]
if stale_dividers:
try:
await member.remove_roles(*stale_dividers, reason="Team sync: mäng vahetus")
result.divider_removed = [r.name for r in stale_dividers]
except discord.Forbidden:
log.debug("No permission to remove divider roles for %s, skipping", member)
except discord.HTTPException as e:
result.errors.append(f"Eraldajarolli eemaldamise viga kasutajale {member}: {e}")
return result
@@ -369,8 +428,8 @@ async def apply_team_role_positions(
downstream is name-based. Returns ``(roles_moved, errors)``; a no-op returns
``(0, [])``.
"""
team_dividers = sheets.get_team_dividers() # {team: divider role ID}
if not team_dividers:
team_divider_ids = team_dividers() # {team: divider role ID}, both sources
if not team_divider_ids:
return 0, [] # no dividers configured, or nothing matched
errors: list[str] = []
@@ -378,7 +437,7 @@ async def apply_team_role_positions(
# that role's current name for the name-based positioning maths below.
placements: dict[str, list[str]] = {}
resolved: dict[int, discord.Role | None] = {}
for team, divider_id in team_dividers.items():
for team, divider_id in team_divider_ids.items():
if divider_id not in resolved:
resolved[divider_id] = guild.get_role(divider_id)
if resolved[divider_id] is None:
@@ -421,6 +480,16 @@ async def apply_team_role_positions(
return 0, errors
except discord.HTTPException as e:
errors.append(f"Tiimirollide järjestamine ebaõnnestus: {e}")
# 50013 here despite Manage Roles usually means a role in the batch sits
# at/above the bot's top role. Log the batch vs bot_top to pinpoint it.
log.warning(
"edit_role_positions failed (%s); bot_top=%d; batch=%s",
e, bot_top,
sorted(
((r.name, r.position, target) for r, target in positions.items()),
key=lambda x: -x[1],
),
)
return 0, errors
log.info("Positioned %d team role(s) under their dividers", len(positions))
return len(positions), errors
@@ -449,12 +518,20 @@ async def sync_all_team_roles(
summary.assigned += 1
if res.removed:
summary.removed += len(res.removed)
if res.divider_added:
summary.divider_assigned += 1
if res.divider_removed:
summary.divider_removed += len(res.divider_removed)
if res.changed:
bits: list[str] = []
if res.added:
bits.append(f"+{res.added}")
if res.removed:
bits.append("-" + ", -".join(res.removed))
if res.divider_added:
bits.append(f"+[{res.divider_added}]")
if res.divider_removed:
bits.append("-[" + "], -[".join(res.divider_removed) + "]")
summary.changes.append(f"{member.display_name}: {', '.join(bits)}")
# Placement runs after the grant/remove pass so roles created this run are

View File

@@ -16,7 +16,7 @@ The codebase is split into **`core/`** (domain logic), **`commands/`** (Discord
| File | Purpose |
|---|---|
| `core/economy/` | Economy business logic **package**, re-exported via `core/economy/__init__.py` so callers use `from core import economy` + attribute access (`economy.do_daily`, `economy.SHOP`, ...). Submodules: `store.py` (user records, per-user locks, `COOLDOWNS`, `JAIL_DURATION`, `COIN`, `get_user`/`_commit`/`_txn`), `income.py`, `gambling.py`, `fishing.py`, `jail.py`, `heist.py`, `prestige.py`, `shop.py`, `levels.py`, `quests.py`, `leaderboards.py`, `house.py`, `admin.py` |
| `core/economy/` | Economy business logic **package**, re-exported via `core/economy/__init__.py` so callers use `from core import economy` + attribute access (`economy.do_daily`, `economy.SHOP`, ...). Submodules: `store.py` (user records, per-user locks, `COOLDOWNS`, `ITEM_COOLDOWNS`/`effective_cooldown`, `JAIL_DURATION`, `COIN`, `get_user`/`_commit`/`_txn`, `pending_wager` escrow + `reconcile_pending_wagers`), `income.py`, `gambling.py`, `fishing.py`, `jail.py`, `heist.py`, `prestige.py`, `shop.py`, `consumables.py` (timed buffs + `grant_buff`), `vanity.py` (cosmetic badges), `lootbox.py` (mystery box), `bank.py` (rob-proof vault), `achievements.py` (milestone badges), `lottery.py` (daily draw), `levels.py`, `quests.py`, `leaderboards.py` (incl. `get_all_leaderboards`/`get_economy_stats`, net-worth = balance+bank), `house.py`, `admin.py` |
| `core/pb_client.py` | Async PocketBase REST client - auth token cache, CRUD on `economy_users` collection |
| `core/sheets.py` | Google Sheets integration (member sync) |
| `core/member_sync.py` | Birthday/member sync helpers, plus tournament team-role sync + divider placement |
@@ -31,10 +31,10 @@ Each file exposes a `register_<group>_commands(tree, bot, ...)` function. `bot.p
| `commands/dev_member_runtime.py` | `on_member_join` flow + `birthday_daily` task body |
| `commands/economy_income_commands.py` | `/daily`, `/work`, `/beg`, `/crime`, `/rob` |
| `commands/economy_games_commands.py` | `/roulette`, `/slots`, `/blackjack`, `/rps` |
| `commands/economy_extra_commands.py` | `/heist`, `/jailbreak`, `/reminders`, `/request`, ... |
| `commands/economy_extra_commands.py` | `/heist`, `/jailbreak`, `/reminders`, `/request`, `/give`, `/lottery`, `/leaderboard`, ... |
| `commands/economy_fish_commands.py` | `/fish`, `/fishbook`, `/fishsell` |
| `commands/economy_profile_commands.py` | `/balance`, `/rank`, `/stats`, `/cooldowns`, `/leaderboard` |
| `commands/economy_support_commands.py` | `/shop`, `/buy`, `/give`, `/economysetup` |
| `commands/economy_profile_commands.py` | `/balance`, `/rank`, `/stats`, `/cooldowns`, `/achievements` |
| `commands/economy_support_commands.py` | `/shop`, `/buy`, `/economysetup`, `/consumables`, `/vanity`, `/lootbox`, `/bank`, `/deposit`, `/withdraw` |
| `commands/economy_prestige_commands.py` | `/prestige`, `/prestigeshop`, `/prestigebuy` |
| `commands/economy_admin_commands.py` | `/admincoins`, `/adminexp`, `/adminitem`, `/adminjail`, `/adminban`, `/adminreset`, `/adminview` |
| `commands/ops_admin_commands.py` | `/sync`, `/restart`, `/shutdown`, `/pause`, `/send`, `/status` |
@@ -58,7 +58,7 @@ Pick the `commands/economy_*_commands.py` file that matches the new command's ca
Checklist - do all of these, in order:
1. **`core/economy/<area>.py`** (e.g. `income.py`, `gambling.py`, `fishing.py`) - add the `do_<cmd>` async function with cooldown check, logic, `_commit`, and `_txn` logging (`get_user`/`_commit`/`_txn` live in `store.py`)
2. **`core/economy/store.py`** - add the cooldown to `COOLDOWNS` dict if it has one
2. **`core/economy/store.py`** - add the cooldown to `COOLDOWNS` dict if it has one. Compute the effective cooldown in `do_<cmd>` with `effective_cooldown("<cmd>", user["items"])` rather than an inline `timedelta(...) if item in items else ...`
3. **`core/economy/levels.py`** - add the EXP reward to `EXP_REWARDS` dict
4. **`strings/commands.py` `CMD`** - add the slash command description
5. **`strings/commands.py` `OPT`** - add any parameter descriptions
@@ -66,11 +66,11 @@ Checklist - do all of these, in order:
7. **`strings/common.py` `ERR`** - add any error messages (banned, cooldown uses `CD_MSG`, jailed uses `CD_MSG["jailed"]`)
8. **`strings/common.py` `CD_MSG`** - add cooldown message if command has a cooldown
9. **`strings/commands.py` `HELP_CATEGORIES["tipibot"]["fields"]`** - add the command to the help embed
10. **`commands/economy_<group>_commands.py`** - inside `register_*_commands`, add `@tree.command(name="<cmd>", ...)` `cmd_<name>`; handle all `res["reason"]` cases
10. **`commands/economy_<group>_commands.py`** - inside `register_*_commands`, add `@tree.command(name="<cmd>", ...)` `cmd_<name>`; handle all `res["reason"]` cases. If `do_<cmd>` can return `db_error` (any function that calls `get_user`/`_commit`), handle it first in the failure block with `await reply_db_error(interaction); return` (import from `._replies`) - otherwise a DB outage hangs the deferred interaction or shows a misleading message
11. **`commands/economy_<group>_commands.py`** - call `maybe_remind(user_id, "<cmd>")` if the command has a cooldown and reminders make sense (the helper is passed in via the `register_*` signature)
12. **`commands/economy_<group>_commands.py`** - call `await award_exp(interaction, economy.EXP_REWARDS["<cmd>"])` on success
13. **`strings/common.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 (this helper still lives in `bot.py` and is shared across all command modules)
14. **`core/economy/store.py` `ITEM_COOLDOWNS`** - if an item shortens the command's cooldown, add `"<cmd>": ("<item_id>", timedelta(...))` here. This is the single source of truth: `effective_cooldown` (used by `do_<cmd>`), `_maybe_remind`, and `_restore_reminders` all read it, so you no longer edit the reminder helpers by hand
---
@@ -116,7 +116,7 @@ All economy state is stored in **PocketBase** (`economy_users` collection). `cor
| Command | Cooldown | Base Earn | Notes |
|---|---|---|---|
| `/daily` | 20h (18h w/ korvaklapid) | 150⬡ | ×streak multiplier, ×2 w/ lan_pass, +5% interest w/ gaming_laptop |
| `/daily` | 20h (18h w/ korvaklapid) | 150⬡ | ×streak multiplier, ×2 w/ lan_pass, +25⬡ w/ korvaklapid, +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 |

View File

@@ -34,10 +34,12 @@ from core import economy # noqa: E402
PB_URL = config.PB_URL
COLLECTIONS = sorted({config.PB_ECONOMY_COLLECTION_DEV, config.PB_ECONOMY_COLLECTION_ECONOMY})
# _default_user() keys whose default is None, all ISO date/datetime strings
# _default_user() keys with a None/str default that PocketBase stores as text
# (ISO date/datetime strings, plus the vanity badge id).
_TEXT_FIELDS = {
"last_daily", "last_work", "last_beg", "last_crime", "last_rob",
"last_heist", "last_fish", "last_streak_date", "jailed_until",
"vanity_active", "lottery_period",
}

View File

@@ -59,6 +59,11 @@ from .economy import (
ITEM_DESCRIPTIONS,
CONSUMABLES_UI,
CONSUMABLE_DESCRIPTIONS,
VANITY_UI,
LOOTBOX_UI,
BANK_UI,
ACHIEVEMENTS_UI,
LOTTERY_UI,
JAILED_UI,
SHOP_BTN,
DAILY_UI,
@@ -152,6 +157,11 @@ __all__ = [
'ITEM_DESCRIPTIONS',
'CONSUMABLES_UI',
'CONSUMABLE_DESCRIPTIONS',
'VANITY_UI',
'LOOTBOX_UI',
'BANK_UI',
'ACHIEVEMENTS_UI',
'LOTTERY_UI',
'JAILED_UI',
'SHOP_BTN',
'DAILY_UI',

View File

@@ -19,6 +19,9 @@ ADMINVIEW_UI: dict[str, str] = {
"banned_yes": "🚫 JAH",
"banned_no": "✅ Ei",
"f_balance": "💰 Saldo",
"f_bank": "🏦 Pangas",
"f_extras": "🏅 Muu",
"extras_val": "Saavutusi: {ach}\nÕnnekaste: {lootboxes}\nOotel panus: {wager}",
"f_exp": "📊 EXP / Tase",
"f_streak": "🔥 Streak",
"f_banned": "🚫 Keelatud",

View File

@@ -75,6 +75,13 @@ CMD: dict[str, str] = {
"patchnotes": "Vaata TipiBOTi viimaseid muudatusi ja uuendusi",
"quests": "Vaata oma päeva- ja nädalaülesandeid ning nõua auhinnad",
"consumables": "Sirvi ja osta turgutusi (korduvostetavad boostid)",
"vanity": "Staatusepood - osta ja kanna kosmeetilisi tiitleid",
"lootbox": "Ava õnnekast - juhuslik auhind müntide või boonuse näol",
"achievements": "Vaata oma saavutusi ja teeni märkide eest ühekordseid preemiaid",
"bank": "Vaata oma panka - röövikindel hoius",
"deposit": "Pane münte panka (röövikindel, aga ei teeni intressi)",
"withdraw": "Võta münte pangast rahakotti",
"lottery": "Vaata TipiLOTO potti või osta pileteid (tühjaks jättes näeb infot)",
}
# ---------------------------------------------------------------------------
@@ -92,7 +99,11 @@ OPT: dict[str, str] = {
"give_kasutaja": "Kellele annad?",
"give_summa": "Kui palju annad? ('all' = kogu saldo)",
"buy_ese": "Eseme nimi (vaata /shop)",
"deposit_summa": "Kui palju panna panka? ('all' = kogu vaba raha)",
"withdraw_summa": "Kui palju pangast välja võtta? ('all' = kogu pangas)",
"lottery_kogus": "Mitu piletit osta (tühjaks jättes näeb potti)",
"consumable_ese": "Turgutus, mida osta (tühjaks jättes näeb menüüd)",
"vanity_ese": "Tiitel, mida osta või kanda (tühjaks jättes näeb poodi)",
"rps_panus": "Valikuline TipiCOINide panus ('all' = kogu saldo)",
"rps_vastane": "Väljakutse teisele mängijale (PvP)",
"slots_panus": "Panus TipiCOINides ('all' = kogu saldo)",
@@ -148,10 +159,18 @@ HELP_CATEGORIES: dict[str, dict] = {
("/heist", "Alusta grupiröövi pangahoidlasse. Min 2 mängijat, max 8. 5 min ühinemisaeg. Õnnestumisel jagatakse saak võrdselt - ebaõnnestumisel 1h 30min vangis + trahv. 4h serveri ooteaeg (ei ole isiklik)."),
("/jailbreak", "Proovi vanglas olles täringuid visata, et duublit saada (3 katset). Duubli korral saad vabaks. Ebaõnnestumisel saad valida: maksa kautsjon (20-30% saldost, min 350 ⬡) või jää vanglasse kuni aja lõpuni."),
("/give @user <amount>", "Anna TipiCOINe teisele mängijale"),
("/bank", "Vaata oma panka. Panka pandud mündid on röövikindlad (/rob ja /heist ei puuduta)."),
("/deposit <amount>", "Pane münte panka (röövikindel hoius, ei teeni intressi)."),
("/withdraw <amount>", "Võta münte pangast rahakotti."),
("/quests", "Vaata oma päeva- ja nädalaülesandeid ning nõua auhinnad (uueneb iga päev/nädal)."),
("/achievements", "Vaata oma saavutusi. Iga lukust lahti saanud märk annab ühekordse müntipreemia."),
("/lottery [kogus]", "Vaata TipiLOTO potti või osta pileteid. Loosimine iga päev - üks võitja saab kogu poti (rohkem pileteid = suurem võiduvõimalus)."),
("/leaderboard", "TipiBOTi edetabel - kes on kõige rikkam?"),
("/shop", "Sirvi TipiBOTi poodi"),
("/buy <item>", "Osta ese TipiBOTi poodist"),
("/consumables", "Osta korduvostetavaid turgutusi (ajutised boostid)."),
("/lootbox", "Ava õnnekast (1000 ⬡) - juhuslik auhind: mündid või ajutine boonus."),
("/vanity", "Staatusepood - osta ja kanna kosmeetilisi tiitleid (näha /profile-l)."),
("/request <amount> <reason> [target]", "Saada crowdfundingu taotlus. Keegi saab 'Toeta' nuppu vajutades raha kanda (taotlus kehtib 5 minutit)."),
("/reminders", "DM meeldetuletused on vaikimisi sees. Kasuta seda käsku, et lülitada sisse/välja, milliseid käsklusi meelde tuletada."),
],

View File

@@ -175,6 +175,8 @@ ERR: dict[str, str] = {
"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}.",
"payout_failed": "⚠️ Tehniline viga võidu väljamaksmisel - see on logitud ja admin taastab su TipiCOINid. Vabandame!",
"db_error": "⚠️ Andmebaas ei vasta praegu. Proovi hetke pärast uuesti.",
}
# ---------------------------------------------------------------------------
@@ -241,6 +243,8 @@ STATUS_UI: dict[str, str] = {
"tasks_field": "🔄 Async tasks",
"eco_players_field": "👤 Eco players",
"members_cache_field": "📋 Liikmed (cache)",
"supply_field": "💰 Rahavaru",
"supply_val": "Kokku {total}\nMängijatel {players}\nKassas {house} ({house_pct}%)",
"log_files_field": "📂 Log files",
"log_line": "`{name}` - {size_kb} KB",
"none": "-",

View File

@@ -17,6 +17,11 @@ __all__ = [
'ITEM_DESCRIPTIONS',
'CONSUMABLES_UI',
'CONSUMABLE_DESCRIPTIONS',
'VANITY_UI',
'LOOTBOX_UI',
'BANK_UI',
'ACHIEVEMENTS_UI',
'LOTTERY_UI',
'JAILED_UI',
'SHOP_BTN',
'DAILY_UI',
@@ -240,6 +245,84 @@ CONSUMABLES_UI: dict[str, str] = {
"bought_instant": "☕ Kõik ooteajad nullitud!\nUus saldo: {balance}",
}
# ---------------------------------------------------------------------------
# Vanity shop (cosmetic badges/titles - a pure status sink, no gameplay effect)
# ---------------------------------------------------------------------------
VANITY_UI: dict[str, str] = {
"title": "👑 Staatusepood",
"desc": "Puhtalt uhkuse pärast - märgid ja tiitlid ilma igasuguse mänguefektita. Kantud märk paistab su `/profile`-l.\nSaldo: {bal}",
"line_owned": "✅ Olemas",
"line_active": "⭐ Kantud",
"entry_title": "{title}",
"footer": "Osta või kanna: /vanity <märk> · „Eemalda märk“ võtab tiitli maha",
"none_choice": "❌ Eemalda märk",
"bought": "{emoji} Ostsid tiitli **{title}** ja panid selle kohe kandma!\nUus saldo: {balance}",
"equipped": "{emoji} Kannad nüüd tiitlit **{title}**.",
"unequipped": "Märk eemaldatud - su profiil on jälle tavaline.",
}
# ---------------------------------------------------------------------------
# Lootbox (pay-to-open mystery box, a coin sink with a random reward)
# ---------------------------------------------------------------------------
LOOTBOX_UI: dict[str, str] = {
"title": "🎁 Õnnekast",
"opening": "🎁 Avan õnnekasti...",
"coins_small": "🪙 Leidsid põhjast paar münti: **+{coins}**",
"coins_medium": "💰 Korralik saak: **+{coins}**",
"coins_big": "💎 Suur õnn: **+{coins}**",
"jackpot": "🎉 **JACKPOT!** **+{coins}**",
"buff_earn": "⚡ Boonus: teenimine **×2** järgmiseks **{min} minutiks**!",
"buff_exp": "✨ Boonus: EXP **×2** järgmiseks **{min} minutiks**!",
"foot_win": "Netovõit: +{net} · Saldo: {balance}",
"foot_loss": "Netokahjum: {net} · Saldo: {balance}",
"foot_buff": "Saldo: {balance}",
}
# ---------------------------------------------------------------------------
# Bank vault (rob-proof storage; liquid vs banked coins)
# ---------------------------------------------------------------------------
BANK_UI: dict[str, str] = {
"title": "🏦 TipiPANK",
"desc": "Panka pandud mündid on **röövikindlad** (`/rob` ja `/heist` neid ei puuduta), aga neid ei saa kulutada ega panustada enne väljavõtmist ega teeni Botikoopa intressi.",
"f_liquid": "💵 Rahakotis (vaba)",
"f_bank": "🏦 Pangas (kaitstud)",
"deposited": "🏦 Panid **{amount}** panka.\n💵 Rahakotis: {balance} · 🏦 Pangas: {bank}",
"withdrawn": "💵 Võtsid **{amount}** pangast välja.\n💵 Rahakotis: {balance} · 🏦 Pangas: {bank}",
"nothing_liquid": "❌ Sul pole nii palju vaba raha rahakotis.",
"nothing_bank": "❌ Sul pole nii palju raha pangas.",
}
# ---------------------------------------------------------------------------
# Achievements (one-time milestone badges over lifetime stats)
# ---------------------------------------------------------------------------
ACHIEVEMENTS_UI: dict[str, str] = {
"title": "🏅 Saavutused",
"desc": "Iga lukust lahti saanud märk annab ühekordse müntipreemia.\nAvatud: **{earned}/{total}**",
"row_earned": "{emoji} **{name}** · +{reward}",
"row_locked": "🔒 {emoji} {name} · {progress}/{goal} · +{reward}",
"unlocked_note": "🎉 **Uued saavutused avatud:** {names}\n💰 Preemia: +{reward}",
}
# ---------------------------------------------------------------------------
# Lottery (daily draw; one weighted winner takes the pot)
# ---------------------------------------------------------------------------
LOTTERY_UI: dict[str, str] = {
"title": "🎟️ TipiLOTO",
"desc": "Osta pileteid ja võida kogu pott! Loosimine iga päev **{draw_time}**. Iga pilet = **{cost}**, rohkem pileteid = suurem võiduvõimalus.",
"f_pot": "💰 Praegune pott",
"f_players": "👥 Osalejaid",
"f_your": "🎟️ Sinu piletid",
"your_val": "{tickets} tk · võiduvõimalus **{chance}%**",
"your_none": "Sul pole veel pileteid. Osta käsuga `/lottery <kogus>`.",
"bought": "🎟️ Ostsid **{count}** piletit ({cost}).\nSul on nüüd **{tickets}** piletit selle päeva loosimises.\nSaldo: {balance}",
"max_tickets": "❌ Maksimaalne piletite arv ühes loosimises on {cap} (sul on {held}).",
"empty_pot": "🎟️ Pott on tühi - ole esimene, kes piletit ostab!",
# Draw announcement
"draw_title": "🎟️ TipiLOTO loosimine!",
"draw_win": "🎉 Võitja: {winner}\n💰 Võit: **{pot}**\n🎟️ {tickets}/{total} piletit ({chance}%)\n👥 {players} osalejat",
"draw_none": "🎟️ Täna keegi pileteid ei ostnud - loosimist ei toimunud.",
}
JAILED_UI: dict[str, str] = {
"title": "🔒 Praegu vanglas",
"empty": "Kõik on vabad! Vanglas pole kedagi.",
@@ -279,7 +362,7 @@ STATS_UI: dict[str, str] = {
"social_field": "🤝 Sotsiaalne",
"social_val": "Kingitud: {given}\nSaadud: {received}",
"records_field": "🔥 Rekordid",
"records_val": "Pikim päevane streak: **{streak}** päeva",
"records_val": "Pikim päevane streak: **{streak}** päeva\n🎁 Õnnekaste avatud: **{lootboxes}**\n🏅 Saavutusi: **{achievements}/{ach_total}**",
}
# ---------------------------------------------------------------------------

View File

@@ -110,6 +110,8 @@ TEAMSYNC_UI: dict[str, str] = {
"removed": " Tiimirolle eemaldatud: {count}",
"created": "🆕 Loodud uusi tiimirolle: {roles}",
"positioned": "📍 Eraldaja alla paigutatud: {count}",
"divider_assigned": "🏷️ Mängurolle (eraldaja) antud: {count}",
"divider_removed": " Mängurolle (eraldaja) eemaldatud: {count}",
"errors": "⚠️ Vead: {count}",
"no_changes": "✨ Kõik tiimirollid olid juba korras.",
"changes_header": "**Muudatused:**",

View File

@@ -0,0 +1,78 @@
"""Tests for the achievements system (one-time milestone rewards)."""
from core import economy
from conftest import run
UID = 4747
def _fund(fake_pb, **stats) -> None:
run(economy.get_user(UID))
rec = fake_pb.record_for(UID)
rec.update(stats)
class TestNewlyEarned:
def test_threshold_met_is_newly_earned(self, fake_pb):
_fund(fake_pb, work_count=10)
user = run(economy.get_user(UID))
assert "work_10" in economy.newly_earned(user)
def test_below_threshold_not_earned(self, fake_pb):
_fund(fake_pb, work_count=9)
user = run(economy.get_user(UID))
assert "work_10" not in economy.newly_earned(user)
def test_already_claimed_not_repeated(self, fake_pb):
_fund(fake_pb, work_count=10, achievements_earned=["work_10"])
user = run(economy.get_user(UID))
assert "work_10" not in economy.newly_earned(user)
class TestClaim:
def test_claims_and_pays_reward_once(self, fake_pb):
_fund(fake_pb, work_count=10, balance=0)
reward = economy.ACHIEVEMENTS["work_10"]["reward"]
res = run(economy.do_check_achievements(UID))
assert res["ok"] and res["new"] == ["work_10"]
assert res["reward"] == reward
assert fake_pb.record_for(UID)["balance"] == reward
assert "work_10" in fake_pb.record_for(UID)["achievements_earned"]
# Second call: nothing new, no double pay.
res2 = run(economy.do_check_achievements(UID))
assert res2["new"] == [] and res2["reward"] == 0
assert fake_pb.record_for(UID)["balance"] == reward
def test_multiple_unlocked_at_once(self, fake_pb):
_fund(fake_pb, work_count=100, total_fish_caught=25, balance=0)
res = run(economy.do_check_achievements(UID))
# work_10, work_100 and fish_25 all cross at once
assert set(res["new"]) == {"work_10", "work_100", "fish_25"}
expected = sum(economy.ACHIEVEMENTS[a]["reward"] for a in res["new"])
assert res["reward"] == expected
assert fake_pb.record_for(UID)["balance"] == expected
def test_nothing_to_claim(self, fake_pb):
_fund(fake_pb, balance=500)
res = run(economy.do_check_achievements(UID))
assert res["ok"] and res["new"] == [] and res["reward"] == 0
assert fake_pb.record_for(UID)["balance"] == 500
class TestView:
def test_view_marks_earned_and_progress(self, fake_pb):
_fund(fake_pb, work_count=5, achievements_earned=["beg_50"])
rows = economy.achievements_view(run(economy.get_user(UID)))
by_id = {r["id"]: r for r in rows}
assert by_id["beg_50"]["earned"] is True
assert by_id["work_10"]["earned"] is False
assert by_id["work_10"]["progress"] == 5 # capped at goal
assert len(rows) == len(economy.ACHIEVEMENTS)
def test_progress_capped_at_goal(self, fake_pb):
_fund(fake_pb, work_count=9999)
rows = {r["id"]: r for r in economy.achievements_view(run(economy.get_user(UID)))}
assert rows["work_10"]["progress"] == rows["work_10"]["goal"]

86
tests/test_bank.py Normal file
View File

@@ -0,0 +1,86 @@
"""Tests for the bank vault: rob-proof storage and net-worth accounting."""
from core import economy
from conftest import run
UID = 6161
ROBBER = 6162
def _fund(fake_pb, uid: int, balance: int, bank: int = 0) -> None:
run(economy.get_user(uid))
rec = fake_pb.record_for(uid)
rec["balance"] = balance
rec["bank_balance"] = bank
class TestDepositWithdraw:
def test_deposit_moves_liquid_to_bank(self, fake_pb):
_fund(fake_pb, UID, 1000)
res = run(economy.do_deposit(UID, 400))
assert res["ok"] and res["balance"] == 600 and res["bank"] == 400
rec = fake_pb.record_for(UID)
assert rec["balance"] == 600 and rec["bank_balance"] == 400
def test_withdraw_moves_bank_to_liquid(self, fake_pb):
_fund(fake_pb, UID, 100, bank=500)
res = run(economy.do_withdraw(UID, 300))
assert res["ok"] and res["balance"] == 400 and res["bank"] == 200
def test_deposit_more_than_liquid_rejected(self, fake_pb):
_fund(fake_pb, UID, 100)
res = run(economy.do_deposit(UID, 500))
assert not res["ok"] and res["reason"] == "insufficient"
assert fake_pb.record_for(UID)["balance"] == 100 # unchanged
def test_withdraw_more_than_bank_rejected(self, fake_pb):
_fund(fake_pb, UID, 0, bank=100)
res = run(economy.do_withdraw(UID, 500))
assert not res["ok"] and res["reason"] == "insufficient"
assert fake_pb.record_for(UID)["bank_balance"] == 100
def test_nonpositive_rejected(self, fake_pb):
_fund(fake_pb, UID, 1000, bank=1000)
assert run(economy.do_deposit(UID, 0))["reason"] == "invalid"
assert run(economy.do_withdraw(UID, -5))["reason"] == "invalid"
def test_banned_rejected(self, fake_pb):
_fund(fake_pb, UID, 1000)
fake_pb.record_for(UID)["eco_banned"] = True
assert run(economy.do_deposit(UID, 100))["reason"] == "banned"
def test_round_trip_conserves_coins(self, fake_pb):
_fund(fake_pb, UID, 1000)
run(economy.do_deposit(UID, 700))
run(economy.do_withdraw(UID, 700))
rec = fake_pb.record_for(UID)
assert rec["balance"] == 1000 and rec["bank_balance"] == 0
class TestRobProof:
def test_rob_cannot_touch_banked_coins(self, fake_pb, monkeypatch):
# Target keeps everything banked, only a little liquid (< rob threshold).
_fund(fake_pb, UID, 50, bank=100_000)
_fund(fake_pb, ROBBER, 1000)
# Target has < 100 liquid, so a rob is rejected as "broke" - the vault is
# invisible to /rob (which only reads balance).
res = run(economy.do_rob(ROBBER, UID))
assert not res["ok"] and res["reason"] == "broke"
assert fake_pb.record_for(UID)["bank_balance"] == 100_000 # untouched
class TestNetWorth:
def test_leaderboard_counts_bank(self, fake_pb):
_fund(fake_pb, UID, 100, bank=900) # net worth 1000
_fund(fake_pb, ROBBER, 500, bank=0) # net worth 500
board = dict((uid, worth) for uid, worth in run(economy.get_leaderboard(top_n=None)))
assert board[str(UID)] == 1000
assert board[str(ROBBER)] == 500
def test_economy_stats_counts_bank(self, fake_pb, monkeypatch):
monkeypatch.setattr(economy.house, "HOUSE_ID", None)
_fund(fake_pb, UID, 100, bank=900)
stats = run(economy.get_economy_stats())
assert stats["total_coins"] == 1000
assert stats["player_coins"] == 1000

View File

@@ -100,3 +100,10 @@ class TestEffects:
user = run(economy.get_user(UID))
assert user["last_work"] is None # cooldown wiped -> /work is ready
assert economy.store._cooldown_remaining(user, "work") is None
def test_instant_reset_commands_match_cleared_cooldowns(self):
# The Discord layer cancels reminder DMs for exactly these commands after a
# kohv, so the list must stay in lockstep with the cooldown fields it wipes.
expected = tuple(f.removeprefix("last_") for f in economy.consumables._COOLDOWN_FIELDS)
assert economy.INSTANT_RESET_COMMANDS == expected
assert economy.INSTANT_RESET_COMMANDS == ("work", "beg", "crime", "rob", "fish")

View File

@@ -35,6 +35,29 @@ class TestLevels:
assert economy.level_role_name(99) == "TipiLEGEND"
class TestEffectiveCooldown:
def test_default_when_no_item(self):
assert economy.effective_cooldown("work", []) == economy.COOLDOWNS["work"]
assert economy.effective_cooldown("daily", []) == economy.COOLDOWNS["daily"]
def test_item_reduces_cooldown(self):
assert economy.effective_cooldown("work", ["monitor"]) == timedelta(minutes=40)
assert economy.effective_cooldown("beg", ["hiirematt"]) == timedelta(minutes=3)
assert economy.effective_cooldown("daily", ["korvaklapid"]) == timedelta(hours=18)
assert economy.effective_cooldown("fish", ["ussipurk"]) == timedelta(seconds=90)
def test_unrelated_item_does_not_reduce(self):
assert economy.effective_cooldown("work", ["hiirematt"]) == economy.COOLDOWNS["work"]
def test_command_without_cooldown_returns_none(self):
assert economy.effective_cooldown("unknown", []) is None
def test_every_item_cooldown_beats_its_base(self):
# An item-reduced cooldown must always be shorter than the base.
for cmd, (item, reduced) in economy.store.ITEM_COOLDOWNS.items():
assert reduced < economy.COOLDOWNS[cmd]
class TestGambleExp:
def test_tiers(self):
assert economy.gamble_exp(0) == 0

101
tests/test_fienta.py Normal file
View File

@@ -0,0 +1,101 @@
"""Tests for the Fienta ticket parsing that feeds team-role sync.
Covers the risky bit: turning raw Fienta ticket JSON into a reliable
{discord identity -> team} + {team -> game} mapping, including which ticket
types count as team members and how the game is detected.
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
import config # noqa: E402
from core import fienta # noqa: E402
def _ticket(ttype: str, *, team="", discord="", discord_id="", nick=""):
return {
"order_id": 1,
"rows": [{
"ticket_type": {"title": ttype},
"attendee": {
fienta._F_TEAM_NAME: team,
fienta._F_DISCORD_USERNAME: discord,
fienta._F_DISCORD_USERID: discord_id,
fienta._F_DISCORD_USERNAME.replace("discord", "x"): "",
"nickname_134815": nick,
},
}],
}
CS2 = "Counter-Strike 2 Tournament - competitor ticket"
LOL = "League of Legends Tournament - competitor ticket"
def test_parse_maps_username_and_userid_to_team():
fienta.parse_tickets([
_ticket(CS2, team="KONE", discord="ar7enchik", discord_id="123"),
])
assert fienta.get_team_for_username("ar7enchik") == "KONE"
assert fienta.get_team_for_username("AR7ENCHIK") == "KONE" # case-insensitive
assert fienta.get_team_for_userid(123) == "KONE"
assert fienta.get_team_game("KONE") == "CS2"
def test_parse_detects_game_from_ticket_type():
fienta.parse_tickets([
_ticket(CS2, team="KONE", discord="a"),
_ticket(LOL, team="Ööbik", discord="b"),
])
assert fienta.get_team_game("KONE") == "CS2"
assert fienta.get_team_game("Ööbik") == "LoL"
def test_parse_excludes_non_player_ticket_types():
fienta.parse_tickets([
_ticket("Visitor's Ticket", team="", discord=""),
_ticket("Early Bird - visitor ticket", team="X", discord="ghost"),
_ticket("Counter-Strike 2 Tournament Waiting List", team="WL", discord="waiter"),
_ticket("LAN area - Access Ticket", team="", discord=""),
_ticket(CS2, team="KONE", discord="real"),
])
assert fienta.all_team_names() == {"KONE"}
assert fienta.get_team_for_username("ghost") is None
assert fienta.get_team_for_username("waiter") is None
assert fienta.get_team_for_username("real") == "KONE"
def test_parse_includes_coach_and_substitute():
fienta.parse_tickets([
_ticket("Counter-Strike 2 Coach/Manager - competitor ticket", team="KONE", discord="coach"),
_ticket("CS2 Substitute Player - competitor ticket", team="KONE", discord="sub"),
])
assert fienta.get_team_for_username("coach") == "KONE"
assert fienta.get_team_for_username("sub") == "KONE"
def test_parse_skips_tickets_without_team_or_discord():
fienta.parse_tickets([
_ticket(CS2, team="", discord="noteam"), # no team -> skipped
_ticket(CS2, team="KONE", discord=""), # team but no discord -> team known, no user
])
assert fienta.get_team_for_username("noteam") is None
assert "KONE" in fienta.all_team_names()
def test_get_team_dividers_maps_via_game(monkeypatch):
monkeypatch.setattr(config, "TEAM_DIVIDERS", {"cs2_2026": 100, "lol_2026": 200})
fienta.parse_tickets([
_ticket(CS2, team="KONE", discord="a"),
_ticket(LOL, team="Ööbik", discord="b"),
])
assert fienta.get_team_dividers() == {"KONE": 100, "Ööbik": 200}
def test_strips_leading_at_from_discord_username():
fienta.parse_tickets([_ticket(CS2, team="KONE", discord="@handle")])
assert fienta.get_team_for_username("handle") == "KONE"

View File

@@ -0,0 +1,77 @@
"""Tests for leaderboard queries, incl. the single-scan combined builder."""
from core import economy, pb_client
from conftest import run
def _seed(fake_pb, n: int) -> None:
for i in range(1, n + 1):
run(economy.get_user(1000 + i))
rec = fake_pb.record_for(1000 + i)
rec["balance"] = i * 100
rec["exp"] = i * 50
rec["season_total_exp"] = i * 10
rec["prestige_level"] = i % 3
rec["prestige_points"] = i
rec["total_wagered"] = i * 7
rec["total_fish_caught"] = n - i # inverse order, to catch sort mistakes
class TestGetAllLeaderboards:
def test_matches_individual_queries(self, fake_pb):
_seed(fake_pb, 5)
combined = run(economy.get_all_leaderboards())
assert combined["coins"] == run(economy.get_leaderboard(top_n=None))
assert combined["exp"] == run(economy.get_leaderboard_exp(top_n=None))
assert combined["season"] == run(economy.get_leaderboard_season_exp(top_n=None))
assert combined["prestige"] == run(economy.get_leaderboard_prestige(top_n=None))
assert combined["wagered"] == run(economy.get_leaderboard_wagered(top_n=None))
assert combined["fish"] == run(economy.get_leaderboard_fish(top_n=None))
def test_scans_collection_once(self, fake_pb, monkeypatch):
_seed(fake_pb, 3)
calls = {"n": 0}
real = pb_client.list_all_records
async def counting():
calls["n"] += 1
return await real()
monkeypatch.setattr(pb_client, "list_all_records", counting)
run(economy.get_all_leaderboards())
assert calls["n"] == 1 # not six
def test_fish_sorted_descending(self, fake_pb):
_seed(fake_pb, 4)
fish = run(economy.get_all_leaderboards())["fish"]
counts = [c for _, c in fish]
assert counts == sorted(counts, reverse=True)
class TestEconomyStats:
def test_totals_split_house_from_players(self, fake_pb, monkeypatch):
monkeypatch.setattr(economy.house, "HOUSE_ID", 999)
monkeypatch.setattr(economy.house, "_house_pb_id", None)
# three players + the house
for uid, bal in [(1, 100), (2, 250), (3, 0)]:
run(economy.get_user(uid))
fake_pb.record_for(uid)["balance"] = bal
run(economy.get_user(999))
fake_pb.record_for(999)["balance"] = 5000
stats = run(economy.get_economy_stats())
assert stats["total_coins"] == 100 + 250 + 0 + 5000
assert stats["house_balance"] == 5000
assert stats["player_coins"] == 350
assert stats["player_count"] == 3 # house excluded
def test_no_house_configured(self, fake_pb, monkeypatch):
monkeypatch.setattr(economy.house, "HOUSE_ID", None)
run(economy.get_user(1))
fake_pb.record_for(1)["balance"] = 42
stats = run(economy.get_economy_stats())
assert stats["total_coins"] == 42
assert stats["house_balance"] == 0
assert stats["player_coins"] == 42
assert stats["player_count"] == 1

78
tests/test_lootbox.py Normal file
View File

@@ -0,0 +1,78 @@
"""Tests for the /lootbox mystery box (pay-to-open coin sink)."""
import random
from core import economy
from conftest import run
UID = 8080
def _fund(fake_pb, amount: int) -> None:
run(economy.get_user(UID))
fake_pb.record_for(UID)["balance"] = amount
def _open_until(fake_pb, predicate):
"""Open boxes with varied seeds until `predicate(res)` holds; returns that res."""
for seed in range(1000):
random.seed(seed)
_fund(fake_pb, 100_000)
res = run(economy.do_open_lootbox(UID))
if predicate(res):
return res
raise AssertionError("outcome never occurred")
class TestOpen:
def test_costs_the_fee_and_charges_up_front(self, fake_pb):
_fund(fake_pb, 1_000)
random.seed(1)
res = run(economy.do_open_lootbox(UID))
assert res["ok"]
# balance == 1000 - cost + reward_coins
assert res["balance"] == 1_000 - economy.LOOTBOX_COST + res["reward_coins"]
def test_insufficient_rejected(self, fake_pb):
_fund(fake_pb, economy.LOOTBOX_COST - 1)
res = run(economy.do_open_lootbox(UID))
assert not res["ok"] and res["reason"] == "insufficient"
assert res["need"] == 1
assert fake_pb.record_for(UID)["balance"] == economy.LOOTBOX_COST - 1 # not charged
def test_banned_rejected(self, fake_pb):
_fund(fake_pb, 5_000)
fake_pb.record_for(UID)["eco_banned"] = True
res = run(economy.do_open_lootbox(UID))
assert not res["ok"] and res["reason"] == "banned"
def test_increments_counter(self, fake_pb):
_fund(fake_pb, 5_000)
random.seed(3)
run(economy.do_open_lootbox(UID))
run(economy.do_open_lootbox(UID))
assert run(economy.get_user(UID))["lootboxes_opened"] == 2
class TestOutcomes:
def test_coin_outcome_credits_and_net_matches(self, fake_pb):
res = _open_until(fake_pb, lambda r: r["reward_coins"] > 0)
assert res["net"] == res["reward_coins"] - economy.LOOTBOX_COST
assert res["buff_kind"] is None
def test_buff_outcome_grants_active_buff(self, fake_pb):
res = _open_until(fake_pb, lambda r: r["buff_kind"] is not None)
assert res["reward_coins"] == 0
assert res["net"] == -economy.LOOTBOX_COST
user = run(economy.get_user(UID))
# the granted buff is live
assert res["buff_kind"] in economy.active_buffs(user)
def test_never_negative_balance(self, fake_pb):
# Open exactly at the cost floor repeatedly; balance must stay >= 0.
for seed in range(30):
random.seed(seed)
_fund(fake_pb, economy.LOOTBOX_COST)
res = run(economy.do_open_lootbox(UID))
assert res["balance"] >= 0

131
tests/test_lottery.py Normal file
View File

@@ -0,0 +1,131 @@
"""Tests for the daily lottery (buy tickets, weighted winner takes the pot)."""
import datetime
import random
from zoneinfo import ZoneInfo
from core import economy
from conftest import run
TZ = ZoneInfo("Europe/Tallinn")
P = "2026-09-04" # a fixed draw period
UID = 3131
UID2 = 3132
UID3 = 3133
def _fund(fake_pb, uid: int, balance: int) -> None:
run(economy.get_user(uid))
fake_pb.record_for(uid)["balance"] = balance
class TestPeriod:
def test_before_draw_hour_is_today(self):
dt = datetime.datetime(2026, 9, 4, 20, 0, tzinfo=TZ)
assert economy.period_for(dt) == "2026-09-04"
def test_at_or_after_draw_hour_is_tomorrow(self):
dt = datetime.datetime(2026, 9, 4, economy.DRAW_HOUR, 0, tzinfo=TZ)
assert economy.period_for(dt) == "2026-09-05"
class TestBuy:
def test_buy_deducts_and_records_tickets(self, fake_pb):
_fund(fake_pb, UID, 10_000)
res = run(economy.do_buy_ticket(UID, 3, P))
assert res["ok"] and res["tickets"] == 3
assert res["cost"] == 3 * economy.TICKET_COST
assert res["balance"] == 10_000 - 3 * economy.TICKET_COST
rec = fake_pb.record_for(UID)
assert rec["lottery_tickets"] == 3 and rec["lottery_period"] == P
def test_buy_accumulates_same_period(self, fake_pb):
_fund(fake_pb, UID, 10_000)
run(economy.do_buy_ticket(UID, 2, P))
res = run(economy.do_buy_ticket(UID, 3, P))
assert res["tickets"] == 5
def test_new_period_resets_tickets(self, fake_pb):
_fund(fake_pb, UID, 10_000)
run(economy.do_buy_ticket(UID, 5, P))
res = run(economy.do_buy_ticket(UID, 1, "2026-09-05"))
assert res["tickets"] == 1 # old period's tickets dropped
def test_insufficient_rejected(self, fake_pb):
_fund(fake_pb, UID, 100)
res = run(economy.do_buy_ticket(UID, 1, P))
assert not res["ok"] and res["reason"] == "insufficient"
assert fake_pb.record_for(UID)["balance"] == 100
def test_max_tickets_enforced(self, fake_pb):
_fund(fake_pb, UID, 10_000_000)
res = run(economy.do_buy_ticket(UID, economy.MAX_TICKETS_PER_DRAW + 1, P))
assert not res["ok"] and res["reason"] == "max_tickets"
def test_nonpositive_rejected(self, fake_pb):
_fund(fake_pb, UID, 10_000)
assert run(economy.do_buy_ticket(UID, 0, P))["reason"] == "invalid"
def test_banned_rejected(self, fake_pb):
_fund(fake_pb, UID, 10_000)
fake_pb.record_for(UID)["eco_banned"] = True
assert run(economy.do_buy_ticket(UID, 1, P))["reason"] == "banned"
class TestState:
def test_pot_and_your_tickets(self, fake_pb):
_fund(fake_pb, UID, 10_000)
_fund(fake_pb, UID2, 10_000)
run(economy.do_buy_ticket(UID, 3, P))
run(economy.do_buy_ticket(UID2, 2, P))
state = run(economy.get_lottery_state(P, UID))
assert state["total_tickets"] == 5
assert state["pot"] == 5 * economy.TICKET_COST
assert state["participants"] == 2
assert state["your_tickets"] == 3
def test_other_period_not_counted(self, fake_pb):
_fund(fake_pb, UID, 10_000)
run(economy.do_buy_ticket(UID, 3, "2026-01-01"))
state = run(economy.get_lottery_state(P))
assert state["total_tickets"] == 0 and state["pot"] == 0
class TestDraw:
def test_no_participants_returns_none(self, fake_pb):
assert run(economy.do_lottery_draw(P)) is None
def test_winner_gets_whole_pot_and_coins_conserved(self, fake_pb):
_fund(fake_pb, UID, 10_000)
_fund(fake_pb, UID2, 10_000)
run(economy.do_buy_ticket(UID, 3, P)) # -600
run(economy.do_buy_ticket(UID2, 2, P)) # -400
pot = 5 * economy.TICKET_COST
total_before = sum(fake_pb.record_for(u)["balance"] for u in (UID, UID2))
random.seed(1)
res = run(economy.do_lottery_draw(P))
assert res["ok"] and res["pot"] == pot
winner, loser = (UID, UID2) if res["winner_id"] == UID else (UID2, UID)
assert fake_pb.record_for(winner)["balance"] == (
(10_000 - (3 if winner == UID else 2) * economy.TICKET_COST) + pot
)
# Coins conserved: the pot minted to the winner equals total ticket spend.
total_after = sum(fake_pb.record_for(u)["balance"] for u in (UID, UID2))
assert total_after == total_before + pot
assert fake_pb.record_for(res["winner_id"])["lottery_tickets"] == 0 # consumed
def test_more_tickets_wins_more_often(self, fake_pb):
_fund(fake_pb, UID, 10_000_000)
_fund(fake_pb, UID2, 10_000_000)
wins = {UID: 0, UID2: 0}
for seed in range(200):
# reset tickets each round to the same split
fake_pb.record_for(UID).update(lottery_tickets=9, lottery_period=P)
fake_pb.record_for(UID2).update(lottery_tickets=1, lottery_period=P)
random.seed(seed)
res = run(economy.do_lottery_draw(P))
wins[res["winner_id"]] += 1
# UID holds 90% of tickets -> should win far more often.
assert wins[UID] > wins[UID2] * 3

View File

@@ -10,6 +10,7 @@ Covers the two pure-logic fixes:
from datetime import datetime, timedelta, timezone
from core import economy
from core.pb_client import DatabaseError
from conftest import run
@@ -89,3 +90,26 @@ class TestBailIdempotency:
self._jail(fake_pb, now, balance=1000)
res = run(economy.do_bail(UID))
assert res["ok"] and res["fine"] >= economy.MIN_BAIL
class TestBlackjackPayoutSafety:
"""do_blackjack_payout must not raise on a DB failure - the stake was already
deducted in do_blackjack_bet, so a raised exception would blow up the
interaction handler and swallow the outcome. It reports db_error instead."""
async def _boom(self, *args, **kwargs):
raise DatabaseError("simulated PocketBase outage")
def test_payout_returns_db_error_when_read_fails(self, fake_pb, monkeypatch):
monkeypatch.setattr(economy.gambling, "get_user", self._boom)
res = run(economy.do_blackjack_payout(UID, payout=200, total_invested=100))
assert res == {"ok": False, "reason": "db_error"}
def test_payout_returns_db_error_when_commit_fails(self, fake_pb, monkeypatch):
run(economy.get_user(UID))
fake_pb.record_for(UID)["balance"] = 500
monkeypatch.setattr(economy.gambling, "_commit", self._boom)
res = run(economy.do_blackjack_payout(UID, payout=200, total_invested=100))
assert res == {"ok": False, "reason": "db_error"}
# The failed credit did not persist; balance is untouched (no partial win).
assert fake_pb.record_for(UID)["balance"] == 500

102
tests/test_pending_wager.py Normal file
View File

@@ -0,0 +1,102 @@
"""Tests for pending-wager escrow persistence.
Interactive games (blackjack, RPS PvP) deduct a stake up front and hold it in an
in-memory View. These tests verify the stake is recorded on the user record in
the same commit, cleared on settlement, and refunded by reconcile on restart.
"""
from core import economy
from conftest import run
UID = 555
OPP = 556
def _fund(fake_pb, uid: int, amount: int) -> None:
run(economy.get_user(uid))
fake_pb.record_for(uid)["balance"] = amount
def _pending(fake_pb, uid: int) -> dict:
return fake_pb.record_for(uid).get("pending_wager") or {}
class TestBlackjackEscrow:
def test_bet_records_pending_and_payout_clears(self, fake_pb):
_fund(fake_pb, UID, 1000)
run(economy.do_blackjack_bet(UID, 100))
pw = _pending(fake_pb, UID)
assert pw["kind"] == "blackjack" and pw["amount"] == 100
run(economy.do_blackjack_payout(UID, payout=200, total_invested=100))
assert _pending(fake_pb, UID) == {} # settled
assert fake_pb.record_for(UID)["balance"] == 1000 - 100 + 200
def test_double_split_accumulates_escrow(self, fake_pb):
_fund(fake_pb, UID, 1000)
run(economy.do_blackjack_bet(UID, 100)) # initial
run(economy.do_blackjack_bet(UID, 100)) # double / split adds a hand
assert _pending(fake_pb, UID)["amount"] == 200
def test_losing_hand_clears_escrow(self, fake_pb):
_fund(fake_pb, UID, 1000)
run(economy.do_blackjack_bet(UID, 100))
run(economy.do_blackjack_payout(UID, payout=0, total_invested=100)) # bust/loss
assert _pending(fake_pb, UID) == {}
class TestRpsEscrow:
def test_deposit_records_and_payout_forfeit_clear(self, fake_pb):
_fund(fake_pb, UID, 500)
_fund(fake_pb, OPP, 500)
run(economy.do_rps_pvp_deposit(UID, 100))
run(economy.do_rps_pvp_deposit(OPP, 100))
assert _pending(fake_pb, UID)["kind"] == "rps"
assert _pending(fake_pb, OPP)["amount"] == 100
run(economy.do_rps_pvp_payout(UID, 100)) # UID wins
run(economy.do_rps_pvp_forfeit(OPP)) # OPP loses
assert _pending(fake_pb, UID) == {}
assert _pending(fake_pb, OPP) == {}
assert fake_pb.record_for(UID)["balance"] == 500 - 100 + 200
assert fake_pb.record_for(OPP)["balance"] == 500 - 100
def test_refund_clears_escrow(self, fake_pb):
_fund(fake_pb, UID, 500)
run(economy.do_rps_pvp_deposit(UID, 100))
run(economy.do_rps_pvp_refund(UID, 100))
assert _pending(fake_pb, UID) == {}
assert fake_pb.record_for(UID)["balance"] == 500
class TestReconcile:
def test_refunds_interrupted_stakes(self, fake_pb):
# Simulate a restart: two players left mid-game with escrowed stakes.
_fund(fake_pb, UID, 400)
run(economy.do_blackjack_bet(UID, 100)) # 300 left, 100 escrowed
_fund(fake_pb, OPP, 500)
fake_pb.record_for(OPP)["balance"] = 500
run(economy.do_rps_pvp_deposit(OPP, 250)) # 250 left, 250 escrowed
refunded = run(economy.reconcile_pending_wagers())
by_uid = {uid: (amt, kind) for uid, amt, kind in refunded}
assert by_uid[UID] == (100, "blackjack")
assert by_uid[OPP] == (250, "rps")
assert fake_pb.record_for(UID)["balance"] == 400 # stake restored
assert fake_pb.record_for(OPP)["balance"] == 500
assert _pending(fake_pb, UID) == {}
assert _pending(fake_pb, OPP) == {}
def test_noop_when_nothing_pending(self, fake_pb):
_fund(fake_pb, UID, 100)
assert run(economy.reconcile_pending_wagers()) == []
assert fake_pb.record_for(UID)["balance"] == 100
def test_reconcile_is_idempotent(self, fake_pb):
_fund(fake_pb, UID, 400)
run(economy.do_blackjack_bet(UID, 100))
run(economy.reconcile_pending_wagers())
# A second run (e.g. another restart) must not double-refund.
assert run(economy.reconcile_pending_wagers()) == []
assert fake_pb.record_for(UID)["balance"] == 400

View File

@@ -13,6 +13,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
import config # noqa: E402
from core import member_sync, sheets # noqa: E402
from tests.conftest import run # noqa: E402
@@ -253,6 +254,9 @@ class FakeGuild:
self.created.append(name)
return role
def get_role(self, rid):
return next((r for r in self.roles if r.id == rid), None)
def test_sync_creates_missing_team_role_and_removes_old_one(monkeypatch):
old_team = FakeRole(1, "OldTeam")
@@ -292,6 +296,43 @@ def test_sync_uses_existing_team_role(monkeypatch):
assert genesis in member.roles
def test_sync_grants_game_divider_role(monkeypatch):
genesis = FakeRole(3, "GENESIS")
cs2_div = FakeRole(100, "====== CS2 2026 ======")
member = FakeMember(1, "kapa", roles=[])
guild = FakeGuild([genesis, cs2_div])
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: "GENESIS")
monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS"})
monkeypatch.setattr(sheets, "get_team_dividers", lambda: {"GENESIS": 100})
monkeypatch.setattr(config, "TEAM_DIVIDERS", {"cs2_2026": 100, "lol_2026": 200})
result = run(member_sync.sync_team_role(member, guild))
assert result.divider_added == "====== CS2 2026 ======"
assert cs2_div in member.roles
def test_sync_swaps_divider_role_on_game_switch(monkeypatch):
genesis = FakeRole(3, "GENESIS")
cs2_div = FakeRole(100, "CS2")
lol_div = FakeRole(200, "LoL")
member = FakeMember(1, "kapa", roles=[lol_div]) # was LoL, now on a CS2 team
guild = FakeGuild([genesis, cs2_div, lol_div])
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: "GENESIS")
monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS"})
monkeypatch.setattr(sheets, "get_team_dividers", lambda: {"GENESIS": 100})
monkeypatch.setattr(config, "TEAM_DIVIDERS", {"cs2_2026": 100, "lol_2026": 200})
result = run(member_sync.sync_team_role(member, guild))
assert result.divider_added == "CS2"
assert result.divider_removed == ["LoL"]
ids = {r.id for r in member.roles}
assert 100 in ids and 200 not in ids
def test_sync_strips_team_role_when_not_registered(monkeypatch):
old_team = FakeRole(1, "OldTeam")
member = FakeMember(1, "ghost", roles=[old_team])

78
tests/test_vanity.py Normal file
View File

@@ -0,0 +1,78 @@
"""Tests for the vanity shop (cosmetic status sink, no gameplay effect)."""
from core import economy
from conftest import run
UID = 7777
def _fund(fake_pb, amount: int) -> None:
run(economy.get_user(UID))
fake_pb.record_for(UID)["balance"] = amount
class TestBuy:
def test_buy_burns_coins_owns_and_equips(self, fake_pb):
_fund(fake_pb, 10_000)
cost = economy.VANITY["couch"]["cost"]
res = run(economy.do_vanity_select(UID, "couch"))
assert res["ok"] and res["action"] == "bought"
assert res["balance"] == 10_000 - cost
user = run(economy.get_user(UID))
assert "couch" in user["vanity_owned"]
assert user["vanity_active"] == "couch"
assert economy.vanity_badge(user) == ("🎮", "Sohvasõdur")
def test_coins_are_destroyed_not_sent_to_house(self, fake_pb, monkeypatch):
_fund(fake_pb, 10_000)
credited = []
monkeypatch.setattr(economy.house, "_credit_house", lambda amt: credited.append(amt))
run(economy.do_vanity_select(UID, "couch"))
assert credited == [] # nothing recirculated to the house
def test_insufficient_funds_rejected(self, fake_pb):
_fund(fake_pb, 100)
res = run(economy.do_vanity_select(UID, "legend"))
assert not res["ok"] and res["reason"] == "insufficient"
assert res["need"] == economy.VANITY["legend"]["cost"] - 100
assert run(economy.get_user(UID))["balance"] == 100 # unchanged
def test_banned_rejected(self, fake_pb):
_fund(fake_pb, 10_000)
fake_pb.record_for(UID)["eco_banned"] = True
res = run(economy.do_vanity_select(UID, "couch"))
assert not res["ok"] and res["reason"] == "banned"
def test_unknown_badge(self, fake_pb):
_fund(fake_pb, 10_000)
res = run(economy.do_vanity_select(UID, "nope"))
assert not res["ok"] and res["reason"] == "not_found"
class TestEquip:
def test_equip_owned_is_free(self, fake_pb):
_fund(fake_pb, 20_000)
run(economy.do_vanity_select(UID, "couch")) # buy + equip couch
run(economy.do_vanity_select(UID, "cables")) # buy + equip cables
bal_before = run(economy.get_user(UID))["balance"]
res = run(economy.do_vanity_select(UID, "couch")) # re-equip owned couch
assert res["ok"] and res["action"] == "equipped"
user = run(economy.get_user(UID))
assert user["vanity_active"] == "couch"
assert user["balance"] == bal_before # no re-charge
def test_unequip_clears_badge(self, fake_pb):
_fund(fake_pb, 10_000)
run(economy.do_vanity_select(UID, "couch"))
res = run(economy.do_vanity_select(UID, economy.vanity.NONE_ID))
assert res["ok"] and res["action"] == "unequipped"
user = run(economy.get_user(UID))
assert user["vanity_active"] is None
assert economy.vanity_badge(user) is None
assert "couch" in user["vanity_owned"] # still owned, just not worn
def test_no_badge_by_default(fake_pb):
user = run(economy.get_user(UID))
assert economy.vanity_badge(user) is None