57 Commits

Author SHA1 Message Date
Rene Arumetsa
0796dab6b5 Fix sheet error
All checks were successful
Test & Deploy / test (push) Successful in 9s
Test & Deploy / deploy (push) Successful in 8s
2026-09-11 21:37:38 +03:00
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
Rene Arumetsa
c19e67b5ab fix(teams): resolve dividers from tab name + full title, not last notice row
Section titles are stacked single-cell rows: the "TipiLAN 2026 CS2" title
sits above notice rows ("If a team withdraws..."), and parse_team_sections
kept only the LAST one, so the notice clobbered the title and resolve_divider
saw no game/year keywords -> None -> teams never positioned.

- parse_team_sections now accumulates all single-cell rows above a header, so
  the game/year title survives alongside the notices.
- _refresh_teams_sync resolves the divider from "<tab name> <section title>",
  so the game is taken reliably from the CS2/LoL worksheet name while the year
  still comes from the title, keeping year-scoped TEAM_DIVIDER_*_2026 vars.
- regression test: title survives notice rows and still resolves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPRsW4tazVtYi2jNzVQkre
2026-09-03 23:32:58 +03: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
Rene Arumetsa
d48a436e26 feat(teams): resolve team dividers by role ID instead of name
Divider placement matched the divider role by its exact Discord name, so
renaming the role in Discord silently broke positioning. Switch the
TEAM_DIVIDER_<SUFFIX> config to hold a role ID; resolve the ID to the
role's current name in apply_team_role_positions and keep the existing
name-based ordering maths downstream unchanged.

- config._parse_team_dividers now parses values as ints (rejects non-ints)
- resolve_divider / _team_divider cache / get_team_dividers return IDs
- apply_team_role_positions resolves each ID via guild.get_role once
- .env.example documents IDs and ships the CS2/LoL divider role IDs
- resolve_divider tests updated to assert IDs

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPRsW4tazVtYi2jNzVQkre
2026-09-03 22:34:34 +03:00
Rene Arumetsa
deab0f7a55 CS2 team dividers.
All checks were successful
Test & Deploy / test (push) Successful in 7s
Test & Deploy / deploy (push) Successful in 7s
2026-09-03 21:36:41 +03:00
Rene Arumetsa
a4645e19c5 Merge branch 'renkar-feat/team-roles-from-registration-sheet'
All checks were successful
Test & Deploy / test (push) Successful in 9s
Test & Deploy / deploy (push) Successful in 7s
2026-08-28 15:07:34 +03:00
Rene Arumetsa
ce1ed28904 refactor(teams): move team-role sync to the economy/community bot
The team-role feature was aimed at the wrong bot. Tournament participants
live in the economy/community guild, but team-role sync had been bolted onto
the dev bot's roster sync (sync_member), which bails out for anyone missing
from the internal member sheet - so it could never reach its actual audience.

Decouple it: keep the (roster-independent) team-sheet parsing, pull the team
wiring off the dev/member-sync path, and re-home it on the economy bot.

- core/member_sync: revert sync_member to add-only (drop team block +
  SyncResult.roles_removed); add roster-independent sync_team_role and a
  whole-guild sync_all_team_roles returning a reporting summary. Still only
  ever touches role NAMES present in the team sheet.
- commands/economy_team_commands: new admin-only /teamsync command.
- bot.py: hourly team_sync_hourly task (economy-only, no-op unless
  TEAM_SHEET_ID is set; first tick at boot covers startup load); register
  /teamsync under the economy profile; drop the dev-side startup load.
- commands/dev_member_commands: /check no longer refreshes teams or reports
  removed roles.
- strings + .env.example: TEAMSYNC_UI, CMD[teamsync], document TEAM_SHEET_ID.
- tests: retarget sync tests to sync_team_role; add sync_all_team_roles case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R6JZkyszyDFuFtk25WBbcR
2026-08-28 15:04:23 +03:00
Rene Arumetsa
52002c37fc feat(members): assign team roles from the tournament registration sheet
Match members by Discord username against the lineup nicknames in the
separate registration spreadsheet (TEAM_SHEET_ID) and give each their
team's role. One team per person: switching teams removes the old team
role, and a team with no Discord role yet is auto-created. Only role
names present in the sheet are ever touched, so organisation/field/base
roles are never at risk; the feature is a no-op when TEAM_SHEET_ID is
unset.

The sheet is not a single table (merged rows, stacked CS2/LoL sections
with different layouts), so it is parsed via raw-row scanning rather than
get_all_records. Citizenship markers like "(EST)" are used only as
player delimiters and discarded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-28 14:01:22 +03:00
567a82b9f2 Merge pull request 'feat(economy): add /consumables shop of repeatable, expiring boosts' (#5) from fix/economy-money-safety into master
All checks were successful
Test & Deploy / test (push) Successful in 5s
Test & Deploy / deploy (push) Successful in 6s
Reviewed-on: #5
2026-08-19 17:53:12 +00:00
f3c69738ef Merge pull request 'fix/economy-money-safety' (#4) from fix/economy-money-safety into master
All checks were successful
Test & Deploy / test (push) Successful in 5s
Test & Deploy / deploy (push) Successful in 7s
Reviewed-on: #4
2026-08-19 17:36:16 +00:00
Rene Arumetsa
a2e1601d0e feat(economy): add /consumables shop of repeatable, expiring boosts
The economy had many coin faucets but almost no sinks: the /shop is
one-time ownership, and gambling/rob fines route to the house (which
players drain back via jackpots and heists), so they recirculate rather
than destroy coins. Result: steady inflation.

Add a consumables shop as a true recurring sink - buying destroys the
coins and grants a temporary boost, so there's always something to spend
on after gear is maxed:

  - Energiajook XL (500) - 1h of 2x earnings on /work, /beg, /crime
  - XP jook (500)        - 1h of 2x EXP
  - Kohv (300)           - instantly clears all cooldowns

Timed buffs live in a new active_buffs field ({kind: expiry_iso}), pruned
on read; rebuying extends the timer. Effects hook where they belong:
earn_mult in income.do_work/do_beg/do_crime, exp_buff_mult in
levels.award_exp; kohv is self-contained. New /consumables command browses
the menu (with active buffs) or buys a boost. Covered by 9 tests.

Note: active_buffs is a new PocketBase field - run
scripts/sync_pb_schema.py before deploying or buffs won't persist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-19 20:32:36 +03:00
Rene Arumetsa
b9b4c7c4c7 test(strings): guard that every string is re-exported from the package
strings/ is split into domain submodules whose names are re-exported from
strings/__init__.py. Adding a constant to a submodule and forgetting to
re-export it - or shadowing a name across two submodules - would only
surface as a runtime crash in a command. This test asserts every submodule
__all__ entry is reachable as strings.NAME, that no name is defined twice,
and that strings.__all__ matches the union of the submodules. Submodules
are auto-discovered, so a new one is covered without editing the test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-19 20:02:55 +03:00
Rene Arumetsa
254992c642 docs: point core/economy references at the package submodules
core/economy has long been a package (store/income/gambling/shop/levels/
jail/heist/prestige/fishing/quests/leaderboards/house/admin) rather than a
single core/economy.py, but README.md and docs/DEV_NOTES.md still described
it as one file in ~27 places. Point every reference at the real submodule,
add a "Defined in" column to the constants quick-reference and a Module
column to the strings table, and fix the stale FISH -> FISH_CATALOGUE name.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-19 20:02:47 +03:00
Rene Arumetsa
191726721c Update docs 2026-08-19 19:53:04 +03:00
Rene Arumetsa
0ea5580e15 Refactor strings.py into modules 2026-08-19 19:12:48 +03:00
Rene Arumetsa
738144ccfa Refacotr strings.py into modules 2026-08-19 19:12:21 +03:00
Rene Arumetsa
cec5ac01a0 Fix give coins, now actually gives rewards
All checks were successful
Test & Deploy / test (push) Successful in 4s
Test & Deploy / deploy (push) Successful in 6s
2026-08-17 19:29:15 +03:00
Rene Arumetsa
3b35f82d80 Fix RequestView over-funding reentrancy (do_give double-transfer)
A follow-up reentrancy sweep of the five remaining money-moving views found
one more instance of the same bug class. The other four (quests, prestige,
fish, shop) verified clean - their underlying do_* functions are idempotent.

RequestView / FundModal (commands/economy_support_commands.py):
- on_submit read self._view.remaining, then awaited do_give, then decremented
  remaining. Because discord.py dispatches each modal submit as its own task
  and do_give is a plain non-idempotent transfer, a funder could open two
  modals and submit both before the first resolved: both read the same
  pre-decrement remaining, both passed the range check, and both transferred
  `amount` - over-funding the request (remaining goes negative) and moving up
  to the funder's whole balance.
- Fix: reserve the amount synchronously (decrement remaining BEFORE the do_give
  await, with no await in between - atomic under asyncio), and roll the
  reservation back if the transfer fails. The second concurrent submit now
  sees the reduced remaining and is rejected. Added a cheap _fund guard
  (remaining<=0 / is_finished) so a click on a funded request doesn't open a
  dead modal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 18:49:25 +03:00
5e303fe36f Merge pull request 'Fix money-safety bugs in economy (heist, blackjack, bail)' (#3) from fix/economy-money-safety into master
All checks were successful
Test & Deploy / test (push) Successful in 6s
Test & Deploy / deploy (push) Successful in 8s
Reviewed-on: #3
2026-08-10 15:35:46 +00:00
Rene Arumetsa
968356f925 Fix money-safety bugs in economy (heist, blackjack, bail)
Multi-agent audit of the money-moving economy modules surfaced three
confirmed correctness bugs. All three are fixed here with regression tests.

heist (core/economy/heist.py):
- `house = await get_user(house.HOUSE_ID)` shadowed the imported `house`
  module for the whole function, so `house.HOUSE_ID` raised UnboundLocalError
  on every successful heist (win payout was entirely dead) and on the
  fail-path compensation branch. Rename the local to `house_rec`.
- Un-shadowing exposed a latent mint: the pot was floored at 300 but the
  house was debited only min(total, balance), so a poor house paid out more
  than it lost. Cap the pot at the balance and debit exactly what is paid
  (house debit == sum of payouts). No mint, no leak.
- Add the missing `_is_jailed` import (do_heist_check referenced it unimported).

blackjack (commands/economy_games_commands.py):
- Button callbacks had no reentrancy guard; discord.py dispatches each click
  as its own task, so double-clicking Stand within the dealer-reveal window
  paid out twice (mint), and double-clicking Double/Split deducted the extra
  bet twice. Add a synchronous `_busy` guard (matching the existing RpsGame
  idiom) on all four callbacks plus a `_resolved` idempotency flag on
  settlement, so a game can only pay out once.

bail (core/economy/jail.py, commands/economy_extra_commands.py):
- do_bail only checked balance, never jail state; a double-click or a stale
  BailView from a re-run /jailbreak charged bail twice, destroying coins (bail
  is a pure sink). Make do_bail a no-op when the user is not jailed, and add a
  UI reentrancy guard + "already free" message.

Tests: 47 passed (4 new regression tests covering heist coin-conservation and
bail idempotency).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 18:31:46 +03:00
Rene Arumetsa
8d16da268d Fix eco bot service
All checks were successful
Test & Deploy / test (push) Successful in 21s
Test & Deploy / deploy (push) Successful in 7s
2026-07-27 15:52:51 +03:00
Rene Arumetsa
4dd7379e03 Fix workflow
Some checks failed
Test & Deploy / test (push) Successful in 22s
Test & Deploy / deploy (push) Failing after 7s
2026-07-27 15:22:56 +03:00
Rene Arumetsa
7fc0ccc77f Fix deploy.yml
Some checks failed
Test & Deploy / test (push) Successful in 57s
Test & Deploy / deploy (push) Failing after 5s
2026-07-27 01:04:26 +03:00
Rene Arumetsa
83a60cda55 Refator eco code. Split into modules
Some checks failed
Test & Deploy / test (push) Has been cancelled
Test & Deploy / deploy (push) Has been cancelled
2026-07-27 00:49:34 +03:00
Rene Arumetsa
d89383ee20 CI test
Some checks failed
Test & Deploy / test (push) Failing after 12m44s
Test & Deploy / deploy (push) Has been cancelled
2026-07-27 00:25:25 +03:00
Rene Arumetsa
8884cce9b5 Added a runner, ci/cd 2026-07-26 21:45:23 +03:00
Rene Arumetsa
94fe57a596 Add startup scheme 2026-07-26 21:36:26 +03:00
Rene Arumetsa
c40c871a4b Sync pb 2026-07-26 21:29:35 +03:00
Rene Arumetsa
5ba5642694 Added tests, fix README. 2026-07-26 20:32:13 +03:00
Rene Arumetsa
cb18d9b882 Added quests 2026-07-26 20:11:14 +03:00
Rene Arumetsa
0cdd8dac63 Update env variables 2026-06-21 22:49:46 +03:00
Rene Arumetsa
6101a278e7 Admin check now a dict 2026-06-21 22:40:57 +03:00
Rene Arumetsa
1e9ec56761 Update tipidice emoij to be animated 2026-06-03 18:50:06 +03:00
Rene Arumetsa
a96488fe9e Added eco server emoij ID 2026-06-03 18:45:46 +03:00
Rene Arumetsa
25cf60d2e1 Fix conflict 2026-06-03 18:38:40 +03:00
Rene Arumetsa
3939c879c9 Refacor emoijs to use application emoijs 2026-06-03 18:34:29 +03:00
Rene Arumetsa
b0e23c1a17 Change admin command permissions 2026-06-01 22:11:44 +03:00
78 changed files with 9914 additions and 3609 deletions

View File

@@ -9,6 +9,29 @@ DISCORD_TOKEN=
# Google Sheets spreadsheet ID (the long string in the sheet URL)
SHEET_ID=your-google-sheet-id-here
# Separate spreadsheet holding tournament team registrations (Team Name + lineup
# 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
# CS2_2026 matches only the 2026 CS2 block while a plain CS2 would match any
# year. The value is the Discord role ID to place those teams under (matching by
# ID means renaming the divider role never breaks positioning).
# Optional: sections that match nothing still get their roles, just unpositioned.
TEAM_DIVIDER_CS2_2026=1498736834656604251
TEAM_DIVIDER_LOL_2026=1498736949706490017
# Path to Google service account credentials JSON
GOOGLE_CREDS_PATH=credentials.json
@@ -31,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

View File

@@ -1,17 +1,35 @@
name: Deploy
name: Test & Deploy
on:
push:
branches: [master]
jobs:
test:
runs-on: linux
steps:
- name: Run test suite
run: |
REPO=$(git -C /root/tipibot remote get-url origin)
rm -rf /tmp/tipibot-ci
git clone --quiet "$REPO" /tmp/tipibot-ci
cd /tmp/tipibot-ci
git checkout --quiet ${{ github.sha }}
python3 -m venv /opt/tipibot-ci-venv
/opt/tipibot-ci-venv/bin/pip install -q -r requirements-dev.txt
/opt/tipibot-ci-venv/bin/python -m pytest tests/ -q
deploy:
runs-on: linux
needs: test
steps:
- name: Deploy
run: |
cd ~/tipibot
cd /root/tipibot
git pull
source .venv/bin/activate
pip install -r requirements.txt
sudo systemctl restart tipibot
sudo systemctl restart tipibot_dev # dev bot first, as canary
sleep 5
systemctl is-active --quiet tipibot_dev # dev crashed on startup -> abort
sudo systemctl restart tipibot_eco # eco bot only if dev survived

5
.gitignore vendored
View File

@@ -9,4 +9,7 @@ pocketbase.exe
pocketbase
pb_data/
pb_migrations/
logs/
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.

158
README.md
View File

@@ -10,8 +10,9 @@ Discord bot for the TipiLAN community. Manages member roles and nicknames via Go
2. [Member Management](#member-management)
3. [Admin Commands](#admin-commands)
4. [Birthday System](#birthday-system)
5. [TipiCOIN Economy](#tipicoin-economy)
6. [Project Structure](#project-structure)
5. [Tournament Team Roles](#tournament-team-roles)
6. [TipiCOIN Economy](#tipicoin-economy)
7. [Project Structure](#project-structure)
---
@@ -37,6 +38,7 @@ Discord bot for the TipiLAN community. Manages member roles and nicknames via Go
### 3. Google Sheet Format
Row 1 = headers (exact names). Row 2 = formula/stats row (skipped by bot). Data starts row 3.
Column order doesn't matter and extra columns (e.g. `Käepael`) are fine - the bot finds every column it reads or writes by header name.
| Column | What the bot does with it |
|---|---|
@@ -70,7 +72,7 @@ The economy system stores all player data in [PocketBase](https://pocketbase.io/
1. Download `pocketbase.exe` (Windows) from https://pocketbase.io/docs/ and place it in the project root.
2. Start PocketBase: `.\pocketbase.exe serve`
3. Open the admin UI at http://127.0.0.1:8090/_/ and create a superuser account.
4. Create two collections for profile separation: `economy_users_dev` and `economy_users_prod` - see `docs/POCKETBASE_SETUP.md` for schema notes.
4. Create two collections for profile separation: `economy_users_dev` and `economy_users_prod` - see `docs/POCKETBASE_SETUP.md` for schema notes, then run `python scripts/sync_pb_schema.py` to add every field the bot persists. Re-run it after any update that introduces new fields (PocketBase silently drops writes to fields missing from the schema).
5. Set `PB_URL`, `PB_ADMIN_EMAIL`, `PB_ADMIN_PASSWORD` in `.env`.
6. **One-time data migration** (only if you have an existing `data/economy.json`): `python scripts/migrate_to_pb.py`
@@ -107,6 +109,8 @@ cp .env.example .env
| `PB_ECONOMY_COLLECTION_DEV` | PocketBase collection used by `BOT_PROFILE=dev` |
| `PB_ECONOMY_COLLECTION_ECONOMY` | PocketBase collection used by `BOT_PROFILE=economy` |
| `PB_ECONOMY_COLLECTION` | Legacy fallback collection (optional) |
| `TEAM_SHEET_ID` | Tournament registration spreadsheet (separate from the member sheet). Unset = team sync off |
| `TEAM_DIVIDER_<GAME>_<YEAR>` | Divider role that a sheet section's teams get positioned under - see [Tournament team roles](#tournament-team-roles) |
### 6. Install & Run
@@ -168,6 +172,7 @@ Admins (bot lacks permission to modify them) are silently skipped and still mark
|---|---|---|
| `/check` | Manage Roles | Refreshes sheet data, backfills missing User IDs, syncs nicknames + roles for every member, reports stats |
| `/member @user` | Manage Roles | Shows a member's full sheet data + calculated age |
| `/teamsync` | Bot admin | Reloads the tournament registration sheet, grants/removes team roles, and repositions them under their dividers. See [Tournament Team Roles](#tournament-team-roles) |
| `/sync` | Manage Guild | Re-registers slash commands with Discord |
| `/restart` | Manage Guild | Gracefully restarts the bot process; posts ✅ in the same channel when back up |
| `/shutdown` | Manage Guild | Shuts the bot down cleanly without restarting |
@@ -220,9 +225,49 @@ If a member joins and their birthday is within `BIRTHDAY_WINDOW_DAYS` days, a bi
---
## Tournament Team Roles
Runs on the **economy** profile only, driven by the separate `TEAM_SHEET_ID` spreadsheet. It is independent of the member roster - players are matched straight from the registration sheet by Discord username, and never need a row in the member sheet.
Re-runs automatically **every hour**, and on demand via `/teamsync` (admin).
### What it does
- Grants each registered player the role named after their team, creating that role if it doesn't exist yet
- Removes team roles they no longer hold (switched teams, dropped out)
- Positions every team role directly beneath its game's divider role
- **Only role names present in the sheet are ever added, removed, or moved** - no unrelated role is at risk
### Divider placement
The registration sheet stacks several game sections per tab, each under a merged title row:
```
[merged] TipiLAN 2026 CS2 Registration Log
No | Team Name | Lineup (nickname, citizenship) | ...
1 | GENESIS | kapa (EST), neaQ (EST) | ...
```
Set one `TEAM_DIVIDER_*` variable per section to say where those teams belong:
```bash
TEAM_DIVIDER_CS2_2026="====== COUNTER-STRIKE 2 2026 ======"
TEAM_DIVIDER_LOL_2026="===== LEAGUE OF LEGENDS 2026 ====="
```
- **The key suffix** is matched against the section's title row. Every underscore-separated part must appear in it as a whole word, so `CS2_2026` matches only the 2026 CS2 block, while a plain `CS2` would match that game in any year. When several keys match, the most specific one (most parts) wins.
- **The value** is the *exact* Discord role name, decoration included. Copy it from Server Settings → Roles.
- Sections matching no key still get their team roles - they just aren't repositioned.
Teams are ordered alphabetically downwards under their divider. Every sync re-checks placement, so roles that predate this feature get pulled into place on the next run.
> The bot can only move roles **below its own** role. Drag the TipiBOT role above your dividers, or placement is skipped and reported as an error.
---
## TipiCOIN Economy
All economy data is stored in **PocketBase** (`economy_users` collection - see `core/pb_client.py`). The currency is **TipiCOIN** (⬡), displayed as a custom Discord emoji configured in `core/economy.py → COIN`.
All economy data is stored in **PocketBase** (`economy_users` collection - see `core/pb_client.py`). The currency is **TipiCOIN** (⬡), displayed as a custom Discord emoji configured in `core/economy/store.py → COIN`.
---
@@ -245,15 +290,15 @@ The house is listed at **#0** on the leaderboard. Players can attempt to rob it
| Command | Cooldown | Base payout | Notes |
|---|---|---|---|
| `/daily` | 20h | 150 ⬡ | Streak multiplier applied (see below). Kõrvaklapid reduces cooldown to 18h. LAN Pilet doubles the reward. Botikoobas adds 5% interest on your balance (capped at 500 ⬡/day). Prestige daily_plus adds +20% base per upgrade level. |
| `/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 ⬡ | XL hiirematt reduces cooldown to 3min. Mehhaaniline klaviatuur multiplies earnings ×2. |
| `/crime` | 2h | 200500 ⬡ | 60% success rate (75% with CAT6). +30% earnings with Mikrofon on win. Fail = fine + 30min jail. Mänguritool skips jail on fail. |
| `/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. |
| `/fish` | 2min | varies | Interactive minigame. Cast → wait for bite → press button within 2s → keep in inventory or sell immediately. Ussipurk reduces cooldown to 90s. |
### Daily streak
The streak increments each time you claim `/daily` within the cooldown window. Missing a day resets it to 1 **unless** you own the TipiLAN trofee item.
The streak increments each time you claim `/daily` within the cooldown window. Missing a day resets it to 1 **unless** you own the TipiLAN karikas item.
| Streak | Multiplier | Payout (base) |
|---|---|---|
@@ -262,7 +307,7 @@ The streak increments each time you claim `/daily` within the cooldown window. M
| 713 days | ×2.0 | 300 ⬡ |
| 14+ days | ×3.0 | 450 ⬡ |
> With LAN Pilet (×2 daily) and a 14-day streak (×3.0) the base payout reaches **900 ⬡**. Add Botikoobas 5% interest on top.
> With LAN pilet (×2 daily) and a 14-day streak (×3.0) the base payout reaches **900 ⬡**. Add Bot Farm 5% interest on top.
---
@@ -281,15 +326,15 @@ Every successful economy action awards EXP:
| `/beg` completed | +5 |
| `/fish` catch | +2 to +25 (varies by rarity: common 23, uncommon 67, rare 10, epic 1415, legendary 25) |
**Level formula:** `level = max(1, floor(√(total_exp ÷ 6)))`
**Level formula:** `level = max(1, floor(√(total_exp ÷ 10)))`
| Level | EXP required | Milestone |
|---|---|---|
| 1 | 0 | TipiNOOB role |
| 5 | 150 | TipiGRINDER role |
| 10 | 600 | TipiHUSTLER role · **T2 shop unlocks** |
| 20 | 2 400 | TipiCHAD role · **T3 shop unlocks** |
| 30 | 5 400 | TipiLEGEND role |
| 5 | 250 | TipiGRINDER role |
| 10 | 1 000 | TipiHUSTLER role · **T2 shop unlocks** |
| 20 | 4 000 | TipiCHAD role · **T3 shop unlocks** |
| 30 | 9 000 | TipiLEGEND role |
Use `/rank` to see your current EXP, level, progress bar to the next level, and leaderboard position.
@@ -336,10 +381,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. |
@@ -351,7 +405,7 @@ The **ECONOMY** role is granted on your first EXP award (i.e. first successful e
### Jail system
`/crime` fail (without Mänguritool) jails you for **30 minutes**. While jailed, `/work`, `/beg`, `/crime`, `/rob`, and `/give` are blocked.
`/crime` fail (without Gaming tool) jails you for **30 minutes**. While jailed, `/work`, `/beg`, `/crime`, `/rob`, and `/give` are blocked.
#### `/jailbreak`
Press the roll button - both dice are rolled simultaneously with an animated reveal. **3 attempts** per sentence. Matching values (doubles) = free instantly. If all 3 fail you pay bail:
@@ -380,6 +434,19 @@ Spend PP in `/prestigeshop`:
---
### Quests
`/quests` shows your personal quest board: **3 daily** and **2 weekly** quests with progress bars and a claim button.
- Quests track counters you're already grinding: work/beg counts, coins earned, amount wagered, fish caught, crimes succeeded, coins given, heists joined.
- Progress starts counting from the moment the quest set rolls - stats earned before that don't count.
- The daily set resets at **UTC midnight**, the weekly set on the **ISO week** rollover. Each player gets their own rotation.
- Rewards are TipiCOINs (prestige coin multiplier applies) plus EXP through the normal level-up path.
> Schema note: the quest fields live in PocketBase - after pulling this feature run `python scripts/sync_pb_schema.py` once (it reconciles both the dev and prod collections with everything the bot persists).
---
### Fishing
`/fish` is an interactive minigame with a **2-minute cooldown** (90s with Ussipurk):
@@ -407,39 +474,34 @@ All items are **permanent** once purchased **except Anticheat**, which expires a
| Item | Cost | Effect |
|---|---|---|
| Mängurihiir | 500 ⬡ | `/work` earns +50% |
| XL hiirematt | 600 ⬡ | `/beg` cooldown 5min → 3min |
| Anticheat | 750 ⬡ | Rob attempts against you fail and fine the robber. **2 uses**, then repurchase. |
| Hiirematt | 600 ⬡ | `/beg` cooldown 5min → 3min |
| Red Bull | 800 ⬡ | `/work` has 30% chance to earn ×3 |
| Kõrvaklapid | 1 200 ⬡ | `/daily` cooldown 20h → 18h |
| LAN Pilet | 1 200 ⬡ | `/daily` reward ×2 |
| Botikoobas | 1 500 ⬡ | `/daily` adds 5% interest on balance (capped at 500 ⬡/day) |
| Anticheat | 1 000 ⬡ | Rob attempts against you fail and fine the robber. **2 uses**, then repurchase. |
| 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) |
#### Tier 2 - level 10 required (TipiHUSTLER+)
| Item | Cost | Effect |
|---|---|---|
| Mehhaaniline klaviatuur | 1 800 ⬡ | `/beg` earns ×2 |
| Mehaaniline klaviatuur | 1 800 ⬡ | `/beg` earns ×2 |
| Ultralai monitor | 2 500 ⬡ | `/work` cooldown 1h → 40min |
| Mikrofon | 2 800 ⬡ | `/crime` win earns +30% |
| Eraldiseisev mikrofon | 2 800 ⬡ | `/crime` win earns +30% |
| Reguleeritav laud | 3 500 ⬡ | `/work` earns +25% (stacks with Mängurihiir → ×1.875 combined) |
| CAT6 netikaabel | 3 500 ⬡ | `/crime` success rate 60% → 75% |
| Cat6 kaabel | 3 500 ⬡ | `/crime` success rate 60% → 75% |
| Jellyfin server | 4 000 ⬡ | `/rob` success rate 45% → 60% |
#### Tier 2 - level 10 required (TipiHUSTLER+) - continued
| Item | Cost | Effect |
|---|---|---|
| Ussipurk | 3 500 ⬡ | `/fish` cooldown 2min → 90s |
#### Tier 3 - level 20 required (TipiCHAD+)
| Item | Cost | Effect |
|---|---|---|
| TipiLAN trofee | 6 000 ⬡ | Daily streak survives missed days |
| 360hz monitor | 7 500 ⬡ | Slots jackpot 10× → 15×, triple 4× → 6× |
| Mänguritool | 9 000 ⬡ | `/crime` fail never sends you to jail |
| Kalavõrk | 5 000 ⬡ | All fish caught are bumped up one rarity tier |
| TipiLAN karikas | 6 000 ⬡ | Daily streak survives missed days |
| 360Hz monitor | 7 500 ⬡ | Slots triple multipliers ×1.5 (jackpot ×25 → ×37) |
| Echolood | 8 000 ⬡ | Fishing bite window 2s → 3s |
| Gaming tool | 9 000 ⬡ | `/crime` fail never sends you to jail |
---
@@ -448,7 +510,7 @@ All items are **permanent** once purchased **except Anticheat**, which expires a
Commands that accept a coin amount (`/give`, `/roulette`, `/rps`, `/slots`, `/blackjack`) accept `"all"` as the amount to wager your entire balance.
### Custom emoji
Change `COIN` in `core/economy.py` to any Discord emoji string:
Change `COIN` in `core/economy/store.py` to any Discord emoji string:
```python
COIN = "<:tipicoin:YOUR_EMOJI_ID>"
```
@@ -474,10 +536,31 @@ Every slash command invocation is logged with the user ID, display name, and all
```
├── bot.py # Discord client, event handlers, shared helpers; wires command modules together
├── strings.py # All user-facing strings (command descriptions, help text, errors)
├── strings/ # All user-facing strings, split by domain; re-exported so `import strings` works unchanged
│ ├── __init__.py # Re-exports every name from the submodules
│ ├── common.py # System messages, embed TITLEs, ERR, CD_MSG, status/send/patchnotes/reminders
│ ├── commands.py # CMD + OPT descriptions, HELP_CATEGORIES, HELP_UI
│ ├── member.py # /check, member/birthday/channel/economy-setup UI
│ ├── economy.py # Income flavour + income/profile/shop/quests/leaderboard/request UI
│ ├── admin.py # Admin responses, season reset, prestige
│ ├── games.py # Slots, roulette, RPS, blackjack, heist, jailbreak
│ └── fishing.py # Fish catalogue, rarities, /fish UI
├── config.py # Environment variable loader
├── core/
│ ├── economy.py # TipiCOIN business logic, constants (SHOP, COOLDOWNS, EXP_REWARDS, ...)
│ ├── economy/ # TipiCOIN business logic (re-exported via core/economy/__init__.py)
│ │ ├── store.py # User records, per-user locks, time/cooldown helpers, txn log
│ │ ├── house.py # House account (fines in, heists out)
│ │ ├── levels.py # EXP, levels, vanity roles
│ │ ├── shop.py # Item catalogue + /buy
│ │ ├── income.py # /daily, /work, /beg, /crime, /rob, /give
│ │ ├── gambling.py # /roulette, /slots, /rps, /blackjack
│ │ ├── fishing.py # Fish catalogue + minigame flows
│ │ ├── quests.py # Daily/weekly quest system
│ │ ├── prestige.py # Prestige resets + upgrade shop
│ │ ├── jail.py # Jail, jailbreak, bail
│ │ ├── heist.py # Group heists against the house
│ │ ├── leaderboards.py # All leaderboard queries
│ │ └── admin.py # Admin mutations + season reset
│ ├── pb_client.py # Async PocketBase REST client (auth + CRUD for economy_users)
│ ├── sheets.py # Google Sheets read/write + in-memory cache
│ └── member_sync.py # Role/nickname/birthday sync logic
@@ -491,6 +574,7 @@ Every slash command invocation is logged with the user ID, display name, and all
│ ├── economy_income_commands.py # /daily, /work, /beg, /crime, /rob
│ ├── economy_prestige_commands.py# /prestige, /prestigeshop, /prestigebuy
│ ├── economy_profile_commands.py # /balance, /rank, /stats, /cooldowns, /leaderboard
│ ├── economy_quests_commands.py # /quests (daily/weekly quest board + claim)
│ ├── economy_support_commands.py # /shop, /buy, /give, /economysetup
│ ├── info_commands.py # /patchnotes, /help auxiliaries
│ ├── ops_admin_commands.py # /sync, /restart, /shutdown, /pause, /send, /status
@@ -501,7 +585,9 @@ Every slash command invocation is logged with the user ID, display name, and all
│ └── POCKETBASE_SETUP.md # PocketBase collection schema + setup instructions
├── scripts/
│ ├── migrate_to_pb.py # One-time legacy migration: economy.json → PocketBase
│ ├── add_stats_fields.py # Schema migration: add new fields to economy_users collection
│ ├── sync_pb_schema.py # Reconcile PB collections with the full user schema (dev + prod)
│ ├── add_stats_fields.py # Older partial schema migration (superseded by sync_pb_schema.py)
│ ├── add_quest_fields.py # Older partial schema migration (superseded by sync_pb_schema.py)
│ └── reset_pb_collections.py # Destructive: deletes & recreates economy collections (--confirm required)
├── requirements.txt # Python dependencies
├── .env.example # Template for secrets

244
bot.py
View File

@@ -21,9 +21,9 @@ 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
from core.member_sync import SyncResult, sync_all_team_roles
from commands.dev_member_commands import register_dev_member_commands
from commands.dev_member_runtime import handle_member_join, run_birthday_daily
from commands.economy_admin_commands import register_economy_admin_commands
@@ -32,8 +32,10 @@ from commands.economy_fish_commands import register_economy_fish_commands
from commands.economy_games_commands import register_economy_games_commands
from commands.economy_income_commands import register_economy_income_commands
from commands.economy_prestige_commands import register_prestige_commands
from commands.economy_quests_commands import register_economy_quests_commands
from commands.economy_profile_commands import register_economy_profile_commands
from commands.economy_support_commands import register_economy_support_commands
from commands.economy_team_commands import register_economy_team_commands
from commands.ops_channel_commands import register_ops_channel_commands
from commands.ops_admin_commands import register_ops_admin_commands
from commands.info_commands import register_info_commands
@@ -243,7 +245,6 @@ async def _award_exp(interaction: discord.Interaction, amount: int) -> None:
pass
@tree.interaction_check
async def _log_command(interaction: discord.Interaction) -> bool:
"""Log every slash command invocation and enforce allowed-channel restriction."""
if interaction.command:
@@ -286,6 +287,11 @@ async def _log_command(interaction: discord.Interaction) -> bool:
return False
# CommandTree.interaction_check is a method meant to be overridden, not a
# decorator - `@tree.interaction_check` silently registers nothing.
tree.interaction_check = _log_command
def _load_bday_log() -> dict:
try:
return json.loads(_BDAY_LOG.read_text(encoding="utf-8"))
@@ -331,11 +337,120 @@ async def before_birthday_daily():
await bot.wait_until_ready()
@tasks.loop(hours=1)
async def team_sync_hourly():
"""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 neither Fienta nor the team sheet is set.
"""
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 data: %s", e)
return
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.divider_assigned or summary.divider_removed or summary.errors):
log.info(
"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, summary.divider_assigned, summary.divider_removed,
len(summary.errors),
)
for err in summary.errors:
log.warning("team_sync_hourly: %s", err)
@team_sync_hourly.before_loop
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
# ---------------------------------------------------------------------------
_presence_index = 0
_economy_count: int = 0
_economy_count_fetched: float = 0.0
_ECONOMY_COUNT_TTL = 300 # seconds; the player count changes rarely
_PRESENCES: list = [
lambda g: discord.Activity(
type=discord.ActivityType.watching,
@@ -357,14 +472,20 @@ _PRESENCES: list = [
@tasks.loop(seconds=20)
async def _rotate_presence() -> None:
global _presence_index, _economy_count
global _presence_index, _economy_count, _economy_count_fetched
guild = bot.get_guild(config.GUILD_ID)
try:
_economy_count = await pb_client.count_records()
except Exception as e:
log.warning("Presence: failed to fetch economy count: %s", e)
if time.monotonic() - _economy_count_fetched > _ECONOMY_COUNT_TTL:
try:
_economy_count = await pb_client.count_records()
_economy_count_fetched = time.monotonic()
except Exception as e:
log.warning("Presence: failed to fetch economy count: %s", e)
activity = _PRESENCES[_presence_index % len(_PRESENCES)](guild)
await bot.change_presence(status=discord.Status.online, activity=activity)
try:
await bot.change_presence(status=discord.Status.online, activity=activity)
except Exception as e:
# gateway may be mid-reconnect; skip this rotation instead of tracebacking
log.warning("Presence update skipped: %s", e)
_presence_index += 1
@@ -381,6 +502,22 @@ async def on_ready():
"""Load sheet data and sync slash commands on startup."""
log.info("Logged in as %s (ID: %s)", bot.user, bot.user.id)
economy.set_house(bot.user.id)
_log_config_gaps()
# PocketBase silently drops writes to fields missing from the collection
# schema, so surface any drift loudly instead of letting features no-op.
try:
missing = await economy.missing_schema_fields()
if missing:
log.error(
"PocketBase collection '%s' is missing %d schema field(s): %s "
"- writes to them are silently dropped! Run scripts/sync_pb_schema.py.",
config.PB_ECONOMY_COLLECTION, len(missing), ", ".join(missing),
)
else:
log.info("PocketBase schema check passed (%s)", config.PB_ECONOMY_COLLECTION)
except Exception as e:
log.warning("Could not verify PocketBase schema: %s", e)
_apply_profile_command_filters()
@@ -404,6 +541,17 @@ 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 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)")
# Start rotating rich presence
if not _rotate_presence.is_running():
_rotate_presence.start()
@@ -412,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:
@@ -461,6 +619,10 @@ if IS_DEV_PROFILE:
has_announced_today=_has_announced_today,
mark_announced_today=_mark_announced_today,
)
else:
# Tournament team-role sync lives on the economy/community bot, where the
# registered players actually are (see commands/economy_team_commands.py).
register_economy_team_commands(tree, bot, log)
register_ops_admin_commands(
tree,
@@ -594,8 +756,7 @@ class HelpSelect(discord.ui.Select):
@tree.command(name="help", description=S.CMD["help"])
async def cmd_help(interaction: discord.Interaction):
member = interaction.user
is_admin = isinstance(member, discord.Member) and is_bot_admin(member)
is_admin = is_bot_admin(interaction.user)
await interaction.response.send_message(
embed=_help_embed("üldine"), view=HelpView(is_admin), ephemeral=True
)
@@ -660,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":
@@ -670,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
# ---------------------------------------------------------------------------
@@ -739,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)
@@ -768,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)
@@ -816,6 +962,13 @@ register_economy_fish_commands(
active_games=_active_games,
)
register_economy_quests_commands(
tree,
bot,
coin=_coin,
award_exp=_award_exp,
)
register_economy_games_commands(
tree,
coin=_coin,
@@ -860,6 +1013,25 @@ async def on_app_command_error(interaction: discord.Interaction, error: app_comm
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _log_config_gaps() -> None:
"""One startup line per unset optional config, so 'why doesn't X work on
prod' starts with a log grep instead of guesswork."""
gaps: list[str] = []
if IS_DEV_PROFILE:
if not config.SHEET_ID:
gaps.append("SHEET_ID not set - member sync/birthdays have no sheet")
if not config.BIRTHDAY_CHANNEL_ID:
gaps.append("BIRTHDAY_CHANNEL_ID not set - birthday pings disabled")
if not config.PB_ADMIN_EMAIL or not config.PB_ADMIN_PASSWORD:
gaps.append("PB_ADMIN_EMAIL/PB_ADMIN_PASSWORD not set - economy database writes will fail")
if not config.BOT_ADMIN_ROLES:
gaps.append("DISCORD_ADMIN_ROLES not set - role-based bot-admin checks disabled")
for gap in gaps:
log.warning("Config: %s", gap)
if not gaps:
log.info("Config: all expected settings present for profile '%s'", config.BOT_PROFILE)
def _log_sync_result(member: discord.Member, result: SyncResult):
if result.nickname_changed:
log.info(" → Nickname set for %s", member)

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,13 +5,19 @@ import datetime
import random
import time
from collections.abc import Awaitable, Callable, MutableSet
from zoneinfo import ZoneInfo
import discord
from discord import app_commands
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,
@@ -119,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":
@@ -269,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":
@@ -288,12 +300,12 @@ def register_economy_extra_commands(
# /jailbreak - Monopoly-style dice escape
# -----------------------------------------------------------------------
_DICE_EMOJI = [
"<:TipiYKS:1483103190491856916>",
"<:TipiKAKS:1483103215841972404>",
"<:TipiKOLM:1483103217846980781>",
"<:TipiNELI:1483103237585240114>",
"<:TipiVIIS:1483103239036469289>",
"<:TipiKUUS:1483103253163020348>",
E["TipiYKS"],
E["TipiKAKS"],
E["TipiKOLM"],
E["TipiNELI"],
E["TipiVIIS"],
E["TipiKUUS"],
]
class JailbreakView(discord.ui.View):
@@ -395,12 +407,17 @@ def register_economy_extra_commands(
def __init__(self, user_id: int):
super().__init__(timeout=60)
self.user_id = user_id
self._paying = False
@discord.ui.button(label=S.JAILBREAK_UI["bail_btn"], style=discord.ButtonStyle.danger)
async def pay_bail(self, interaction: discord.Interaction, _: discord.ui.Button):
if interaction.user.id != self.user_id:
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
return
if self._paying or self.is_finished():
await interaction.response.defer()
return
self._paying = True
res = await economy.do_bail(self.user_id)
self.clear_items()
self.stop()
@@ -413,6 +430,15 @@ def register_economy_extra_commands(
),
color=0xED4245,
)
elif not res["ok"]:
# Already released (stale view or double-click) - no charge applied.
embed = discord.Embed(
title=S.TITLE["jailbreak_bail"],
description=S.JAILBREAK_UI["bail_already_free"].format(
balance=coin(res["balance"]),
),
color=0x57F287,
)
else:
embed = discord.Embed(
title=S.TITLE["jailbreak_bail"],
@@ -503,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":
@@ -528,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
@@ -736,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 = []
@@ -850,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

@@ -9,8 +9,11 @@ import discord
from discord import app_commands
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,
@@ -115,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":
@@ -201,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"]))
@@ -277,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:
@@ -611,7 +626,7 @@ def register_economy_games_commands(
# -----------------------------------------------------------------------
# /slots
# -----------------------------------------------------------------------
_SLOTS_SPIN = "<a:TipiSLOTS:1483444233863037101>"
_SLOTS_SPIN = E["TipiSLOTS"]
_SLOTS_DELAY = 0.7
def _slots_embed(
@@ -651,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
@@ -791,6 +809,11 @@ def register_economy_games_commands(
self._doubled_hands: set[int] = set()
self._split_aces: bool = False
self.message: discord.Message | None = None
# _busy: a callback is mid-flight (prevents overlapping button tasks,
# since discord.py dispatches each click as its own task).
# _resolved: the game has paid out (makes settlement idempotent).
self._busy = False
self._resolved = False
self._refresh_buttons()
@property
@@ -863,6 +886,9 @@ def register_economy_games_commands(
return discord.Embed(title=S.TITLE["blackjack"], description=desc, color=0x5865F2)
async def _resolve_all(self, interaction: discord.Interaction) -> None:
if self._resolved:
return
self._resolved = True
active_games.discard(self.user_id)
self.clear_items()
self.stop()
@@ -917,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:
@@ -948,80 +978,117 @@ def register_economy_games_commands(
if interaction.user.id != self.user_id:
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
return
await interaction.response.defer()
self._cur_hand.append(self.deck.pop())
val = _bj_value(self._cur_hand)
if val > 21:
await self.message.edit(embed=self._cur_embed(), view=None)
await asyncio.sleep(_BJ_DEAL_DELAY)
if len(self.hands) > 1:
await self._advance_or_finish(interaction)
if self._busy or self.is_finished():
await interaction.response.defer()
return
self._busy = True
try:
await interaction.response.defer()
self._cur_hand.append(self.deck.pop())
val = _bj_value(self._cur_hand)
if val > 21:
await self.message.edit(embed=self._cur_embed(), view=None)
await asyncio.sleep(_BJ_DEAL_DELAY)
if len(self.hands) > 1:
await self._advance_or_finish(interaction)
else:
await self._resolve_all(interaction)
elif val == 21:
await self.message.edit(embed=self._cur_embed(), view=None)
await asyncio.sleep(_BJ_DEAL_DELAY * 0.5)
if len(self.hands) > 1:
await self._advance_or_finish(interaction)
else:
await self._do_dealer_reveal(interaction)
else:
await self._resolve_all(interaction)
elif val == 21:
await self.message.edit(embed=self._cur_embed(), view=None)
await asyncio.sleep(_BJ_DEAL_DELAY * 0.5)
if len(self.hands) > 1:
await self._advance_or_finish(interaction)
else:
await self._do_dealer_reveal(interaction)
else:
self._refresh_buttons()
await self.message.edit(embed=self._cur_embed(), view=self)
self._refresh_buttons()
await self.message.edit(embed=self._cur_embed(), view=self)
finally:
self._busy = False
async def _stand(self, interaction: discord.Interaction) -> None:
if interaction.user.id != self.user_id:
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
return
await interaction.response.defer()
if len(self.hands) > 1:
await self._advance_or_finish(interaction)
else:
await self._do_dealer_reveal(interaction)
if self._busy or self.is_finished():
await interaction.response.defer()
return
self._busy = True
try:
await interaction.response.defer()
if len(self.hands) > 1:
await self._advance_or_finish(interaction)
else:
await self._do_dealer_reveal(interaction)
finally:
self._busy = False
async def _double(self, interaction: discord.Interaction) -> None:
if interaction.user.id != self.user_id:
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
return
res = await economy.do_blackjack_bet(self.user_id, self.bet)
if not res["ok"]:
await interaction.response.send_message(
S.ERR["broke"].format(bal=coin(res.get("balance", 0))), ephemeral=True
)
if self._busy or self.is_finished():
await interaction.response.defer()
return
await interaction.response.defer()
self._doubled_hands.add(0)
self.bets[0] *= 2
self._cur_hand.append(self.deck.pop())
await self.message.edit(embed=self._cur_embed(), view=None)
await asyncio.sleep(_BJ_DEAL_DELAY)
await self._do_dealer_reveal(interaction)
self._busy = True
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
)
return
await interaction.response.defer()
self._doubled_hands.add(0)
self.bets[0] *= 2
self._cur_hand.append(self.deck.pop())
await self.message.edit(embed=self._cur_embed(), view=None)
await asyncio.sleep(_BJ_DEAL_DELAY)
await self._do_dealer_reveal(interaction)
finally:
self._busy = False
async def _split_hand(self, interaction: discord.Interaction) -> None:
if interaction.user.id != self.user_id:
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
return
res = await economy.do_blackjack_bet(self.user_id, self.bet)
if not res["ok"]:
await interaction.response.send_message(
S.ERR["broke"].format(bal=coin(res.get("balance", 0))), ephemeral=True
)
if self._busy or self.is_finished():
await interaction.response.defer()
return
await interaction.response.defer()
card1, card2 = self._cur_hand[0], self._cur_hand[1]
self._split_aces = card1[0] == "A"
self.hands = [[card1, self.deck.pop()], [card2, self.deck.pop()]]
self.bets = [self.bet, self.bet]
self.hand_idx = 0
await self.message.edit(embed=self._cur_embed(), view=None)
await asyncio.sleep(_BJ_DEAL_DELAY)
if self._split_aces:
await self._do_dealer_reveal(interaction)
else:
self._refresh_buttons()
await self.message.edit(embed=self._cur_embed(), view=self)
self._busy = True
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
)
return
await interaction.response.defer()
card1, card2 = self._cur_hand[0], self._cur_hand[1]
self._split_aces = card1[0] == "A"
self.hands = [[card1, self.deck.pop()], [card2, self.deck.pop()]]
self.bets = [self.bet, self.bet]
self.hand_idx = 0
await self.message.edit(embed=self._cur_embed(), view=None)
await asyncio.sleep(_BJ_DEAL_DELAY)
if self._split_aces:
await self._do_dealer_reveal(interaction)
else:
self._refresh_buttons()
await self.message.edit(embed=self._cur_embed(), view=self)
finally:
self._busy = False
async def on_timeout(self) -> None:
if self._resolved:
return
self._resolved = True
active_games.discard(self.user_id)
try:
await economy.do_blackjack_payout(self.user_id, 0, sum(self.bets))
@@ -1057,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":
@@ -1110,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

@@ -0,0 +1,108 @@
from __future__ import annotations
import asyncio
import datetime
from collections.abc import Awaitable, Callable
import discord
from discord import app_commands
from core import economy
import strings as S
_BAR_SLOTS = 8
def _bar(progress: int, goal: int) -> str:
"""Return an 8-slot ▰/▱ progress bar for a quest."""
filled = 0 if goal <= 0 else min(_BAR_SLOTS, round(_BAR_SLOTS * progress / goal))
return "" * filled + "" * (_BAR_SLOTS - filled)
def _has_claimable(view_data: dict) -> bool:
return any(
q["done"] and not q["claimed"]
for q in view_data["daily"] + view_data["weekly"]
)
def register_economy_quests_commands(
tree: app_commands.CommandTree,
bot: discord.Client,
coin: Callable[[int], str],
award_exp: Callable[[discord.Interaction, int], Awaitable[None]],
) -> None:
def _render(view_data: dict) -> discord.Embed:
embed = discord.Embed(title=S.TITLE["quests"], color=0xF4C430)
sections = (
(S.QUEST_UI["daily_header"], view_data["daily"]),
(S.QUEST_UI["weekly_header"], view_data["weekly"]),
)
for header, quests in sections:
if quests:
blocks = []
for q in quests:
desc = S.QUEST_DESCRIPTIONS.get(q["id"], q["id"])
if q["claimed"]:
status = S.QUEST_UI["completed"]
elif q["done"]:
status = S.QUEST_UI["ready"]
else:
status = S.QUEST_UI["progress"].format(progress=q["progress"], max=q["goal"])
reward = S.QUEST_UI["reward"].format(coins=f"{q['coins']:,}", exp=q["exp"])
blocks.append(
f"{_bar(q['progress'], q['goal'])} **{desc}**\n{status} · {reward}"
)
value = "\n\n".join(blocks)
else:
value = S.QUEST_UI["empty"]
embed.add_field(name=header, value=value, inline=False)
return embed
class QuestView(discord.ui.View):
def __init__(self, invoker_id: int, view_data: dict):
super().__init__(timeout=180)
self.invoker_id = invoker_id
btn = discord.ui.Button(
label=S.QUEST_UI["claim_btn"],
style=discord.ButtonStyle.success,
disabled=not _has_claimable(view_data),
)
btn.callback = self._claim
self.add_item(btn)
async def _claim(self, interaction: discord.Interaction):
if interaction.user.id != self.invoker_id:
await interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True)
return
try:
res = await economy.claim_quests(self.invoker_id)
except economy.DatabaseError:
await interaction.response.send_message(S.QUEST_UI["error"], ephemeral=True)
return
if not res["ok"]:
await interaction.response.send_message(S.QUEST_UI["nothing"], ephemeral=True)
return
new_data = await economy.get_quests(self.invoker_id)
await interaction.response.edit_message(
embed=_render(new_data), view=QuestView(self.invoker_id, new_data)
)
await interaction.followup.send(
S.QUEST_UI["claimed_msg"].format(
count=res["claimed"], coins=coin(res["coins"]), exp=res["exp"]
),
ephemeral=True,
)
if res["exp"]:
asyncio.create_task(award_exp(interaction, res["exp"]))
@tree.command(name="quests", description=S.CMD["quests"])
async def cmd_quests(interaction: discord.Interaction):
await interaction.response.defer()
try:
data = await economy.get_quests(interaction.user.id)
except economy.DatabaseError:
await interaction.followup.send(S.QUEST_UI["error"], ephemeral=True)
return
await interaction.followup.send(embed=_render(data), view=QuestView(interaction.user.id, data))

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,
@@ -38,15 +41,24 @@ def register_economy_support_commands(
)
return
# Reserve the amount synchronously (no await between the range check
# and the decrement) so two concurrent modal submits can't both fund
# the same request. discord.py runs each submit as its own task and
# do_give is not idempotent, so without this reservation a funder
# could double-submit and transfer `amount` more than once.
self._view.remaining -= amount
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
)
return
self._view.remaining -= amount
funded_line = S.REQUEST_UI["funded_line"].format(
name=interaction.user.display_name,
amount=coin(amount),
@@ -84,6 +96,9 @@ def register_economy_support_commands(
self.add_item(self.fund_btn)
async def _fund(self, interaction: discord.Interaction):
if self.remaining <= 0 or self.is_finished():
await interaction.response.send_message(S.ERR["request_closed"], ephemeral=True)
return
if interaction.user.id == self.requester.id:
await interaction.response.send_message(S.ERR["request_self_fund"], ephemeral=True)
return
@@ -154,6 +169,263 @@ def register_economy_support_commands(
await interaction.response.send_message(embed=embed, view=view)
view.message = await interaction.original_response()
# -- /consumables -------------------------------------------------------
def _consumables_embed(user_data: dict) -> discord.Embed:
embed = discord.Embed(
title=S.CONSUMABLES_UI["title"],
description=S.CONSUMABLES_UI["desc"].format(bal=coin(user_data["balance"])),
color=0xF4C430,
)
active = economy.active_buffs(user_data)
if active:
lines = [
S.CONSUMABLES_UI["buff_line"].format(
name=S.CONSUMABLES_UI[f"kind_{kind}"],
time=economy.format_td(economy.buff_remaining(user_data, kind)),
)
for kind in active
]
buff_value = "\n".join(lines)
else:
buff_value = S.CONSUMABLES_UI["active_none"]
embed.add_field(name=S.CONSUMABLES_UI["active_header"], value=buff_value, inline=False)
for cons in economy.CONSUMABLES.values():
embed.add_field(
name=f"{cons['emoji']} {cons['name']} · {cons['cost']} {economy.COIN}",
value=cons["description"],
inline=False,
)
return embed
@tree.command(name="consumables", description=S.CMD["consumables"])
@app_commands.describe(ese=S.OPT["consumable_ese"])
@app_commands.choices(
ese=[
app_commands.Choice(name=f"{c['name']} ({c['cost']} TipiCOINi)", value=cid)
for cid, c in economy.CONSUMABLES.items()
]
)
async def cmd_consumables(
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=_consumables_embed(data), ephemeral=True
)
return
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":
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
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"
desc = S.CONSUMABLES_UI[key].format(
time=economy.format_td(res["remaining"]),
balance=coin(res["balance"]),
)
embed = discord.Embed(
title=S.CONSUMABLES_UI["bought_title"].format(emoji=cons["emoji"], name=cons["name"]),
description=desc,
color=0x57F287,
)
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

@@ -0,0 +1,98 @@
"""Tournament team-role sync for the economy/community guild.
The tournament participants live in the *economy* (community) guild, not the
internal dev guild, so team-role assignment runs here rather than as part of the
member-roster sync in :mod:`commands.dev_member_commands`. This is deliberately
roster-INDEPENDENT: it matches Discord usernames straight against the separate
registration spreadsheet (``TEAM_SHEET_ID``) and never touches the member sheet.
"""
from __future__ import annotations
import logging
import discord
from discord import app_commands
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
def register_economy_team_commands(
tree: app_commands.CommandTree,
bot: discord.Client,
log: logging.Logger,
) -> None:
@tree.command(name="teamsync", description=S.CMD["teamsync"])
@app_commands.guild_only()
@bot_admin_check()
async def cmd_teamsync(interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
guild = interaction.guild
if guild is None:
await interaction.followup.send(S.ERR["guild_only"], ephemeral=True)
return
try:
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 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, 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),
)
def _format_summary(summary) -> str:
lines = [
S.TEAMSYNC_UI["done"],
S.TEAMSYNC_UI["scanned"].format(count=summary.scanned),
S.TEAMSYNC_UI["assigned"].format(count=summary.assigned),
S.TEAMSYNC_UI["removed"].format(count=summary.removed),
]
if summary.created:
# A team can be created only once, but the same role could surface for
# several members in one run - de-dupe for the report.
unique = list(dict.fromkeys(summary.created))
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)))
text = "\n".join(lines)
if summary.changes:
shown = summary.changes[:20]
text += "\n\n" + S.TEAMSYNC_UI["changes_header"] + "\n" + "\n".join(shown)
if len(summary.changes) > 20:
text += "\n" + S.TEAMSYNC_UI["changes_more"].format(count=len(summary.changes) - 20)
else:
text += "\n\n" + S.TEAMSYNC_UI["no_changes"]
return text

View File

@@ -13,8 +13,9 @@ from pathlib import Path
import discord
from discord import app_commands
import strings as S
from core import economy
from core.admin import bot_admin_check
import strings as S
def register_ops_admin_commands(
@@ -44,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(
@@ -81,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}")

103
config.py
View File

@@ -23,8 +23,18 @@ DISCORD_TOKEN = (
) or _LEGACY_DISCORD_TOKEN
SHEET_ID = os.getenv("SHEET_ID")
# Separate spreadsheet holding the tournament team registrations (Team Name +
# lineup of Discord usernames). Optional: when unset, team-role sync is a no-op.
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)
@@ -42,6 +52,76 @@ 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...".
Multiple admin roles per guild are colon-separated; guild entries are comma-separated.
Repeating a guild_id across entries merges its roles.
"""
result: dict[int, set[int]] = {}
for entry in raw.split(","):
entry = entry.strip()
if not entry:
continue
parts = entry.split(":")
if len(parts) < 2 or not all(p.strip() for p in parts):
raise SystemExit(
f"DISCORD_ADMIN_ROLES: expected 'guild_id:role_id[:role_id...]', got {entry!r}"
)
guild_id = int(parts[0].strip())
result.setdefault(guild_id, set()).update(int(p.strip()) for p in parts[1:])
return result
BOT_ADMIN_ROLES: dict[int, set[int]] = _parse_admin_roles(os.getenv("DISCORD_ADMIN_ROLES", ""))
_TEAM_DIVIDER_PREFIX = "TEAM_DIVIDER_"
def _parse_team_dividers() -> dict[str, int]:
"""Collect TEAM_DIVIDER_<SUFFIX> env vars into {suffix: divider role ID}.
The suffix says which sheet sections the divider covers, the value is the
Discord role ID their teams get positioned under:
TEAM_DIVIDER_CS2_2026=1498736834656604251
Matching by ID (not name) means renaming the divider role in Discord never
breaks positioning. Every underscore-separated part of the suffix must appear
in the section's title row, so `CS2_2026` matches only "TipiLAN 2026 CS2
Registration Log" while a plain `CS2` would match that section in any year.
Defining a var is what switches positioning on for those sections; teams
whose section matches nothing are still granted their role, just never moved.
"""
dividers: dict[str, int] = {}
for key, value in os.environ.items():
if not key.startswith(_TEAM_DIVIDER_PREFIX):
continue
suffix = key[len(_TEAM_DIVIDER_PREFIX):].strip().lower()
raw = value.strip()
if not suffix or not raw:
continue
try:
dividers[suffix] = int(raw)
except ValueError:
raise SystemExit(
f"{key}: expected a Discord role ID (integer), got {raw!r}"
)
return dividers
TEAM_DIVIDERS: dict[str, int] = _parse_team_dividers()
PB_URL = os.getenv("PB_URL", "http://127.0.0.1:8090")
PB_ADMIN_EMAIL = os.getenv("PB_ADMIN_EMAIL", "")
PB_ADMIN_PASSWORD = os.getenv("PB_ADMIN_PASSWORD", "")
@@ -58,26 +138,3 @@ PB_ECONOMY_COLLECTION_ECONOMY = (
PB_ECONOMY_COLLECTION = (
PB_ECONOMY_COLLECTION_ECONOMY if BOT_PROFILE == "economy" else PB_ECONOMY_COLLECTION_DEV
)
def _parse_admin_roles() -> dict[int, int]:
"""Parse BOT_ADMIN_ROLES env var (format: guild_id:role_id,guild_id:role_id)."""
raw = os.getenv("BOT_ADMIN_ROLES", "").strip()
if not raw:
return {}
result: dict[int, int] = {}
for pair in raw.split(","):
pair = pair.strip()
if not pair:
continue
parts = pair.split(":")
if len(parts) != 2:
continue
try:
result[int(parts[0].strip())] = int(parts[1].strip())
except ValueError:
continue
return result
BOT_ADMIN_ROLES: dict[int, int] = _parse_admin_roles()

View File

@@ -6,22 +6,22 @@ from discord import app_commands
import config
def is_bot_admin(member: discord.Member) -> bool:
"""Return True if the member has the configured bot-admin role for their guild."""
role_id = config.BOT_ADMIN_ROLES.get(member.guild.id)
if role_id is None:
def is_bot_admin(member: discord.abc.User | None) -> bool:
"""True when the member has any of the configured admin roles for their guild."""
if not isinstance(member, discord.Member) or member.guild is None:
return False
return any(r.id == role_id for r in member.roles)
admin_role_ids = config.BOT_ADMIN_ROLES.get(member.guild.id)
if not admin_role_ids:
return False
return any(r.id in admin_role_ids for r in member.roles)
def bot_admin_check():
"""Slash-command check decorator: raises MissingPermissions if not a bot admin."""
def predicate(interaction: discord.Interaction) -> bool:
member = interaction.user
if not isinstance(member, discord.Member):
raise app_commands.MissingPermissions(["bot_admin"])
if not is_bot_admin(member):
raise app_commands.MissingPermissions(["bot_admin"])
return True
"""Slash-command decorator that gates execution behind ``is_bot_admin``."""
async def predicate(interaction: discord.Interaction) -> bool:
if is_bot_admin(interaction.user):
return True
raise app_commands.MissingPermissions(["bot_admin_role"])
return app_commands.check(predicate)

File diff suppressed because it is too large Load Diff

42
core/economy/__init__.py Normal file
View File

@@ -0,0 +1,42 @@
"""TipiCOIN economy - data layer and business logic.
Storage: PocketBase (see core/pb_client.py). All public async functions are the
single source of truth for mutations; see store.py for the locking rules.
This package re-exports everything so callers keep using `from core import
economy` + attribute access.
"""
from ..pb_client import DatabaseError
from .store import *
from .store import (
_commit, _default_user, _is_jailed, _locked_by, _now, _parse_dt,
_prestige_mult, _txn, _user_lock, _user_locks,
)
from .house import *
from .house import _credit_house, _house_record_id
from .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
from .income import *
from .jail import *
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.)
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"]}

166
core/economy/admin.py Normal file
View File

@@ -0,0 +1,166 @@
"""Admin mutations and the season reset."""
from __future__ import annotations
from datetime import timedelta
from .. import pb_client
from .store import _commit, _default_user, _locked_by, _now, _txn, get_user
from .levels import get_level
from .shop import SHOP
async def do_season_reset(top_n: int = 10) -> list[tuple[str, int, int]]:
"""Snapshot top_n by EXP, then full wipe: EXP, balance, items, item_uses.
Returns top list (uid, exp, level) captured before the reset."""
records = await pb_client.list_all_records()
top = sorted(
((r["user_id"], r.get("exp", 0)) for r in records if r.get("user_id")),
key=lambda x: x[1],
reverse=True,
)[:top_n]
reset_fields = {
"exp": 0,
"balance": 0,
"bank_balance": 0,
"items": [],
"item_uses": {},
"last_daily": None,
"last_work": None,
"last_beg": None,
"last_crime": None,
"last_rob": None,
"last_fish": None,
"daily_streak": 0,
"last_streak_date": None,
"season_total_exp": 0,
}
for record in records:
await pb_client.update_record(record["id"], reset_fields)
return [(uid, exp, get_level(exp)) for uid, exp in top]
# ---------------------------------------------------------------------------
# Admin actions
# ---------------------------------------------------------------------------
@_locked_by(0)
async def do_admin_coins(target_id: int, amount: int, admin_id: int, reason: str) -> dict:
"""Give (positive) or take (negative) coins from a user. Balance is floored at 0."""
user = await get_user(target_id)
user["balance"] = max(0, user["balance"] + amount)
await _commit(target_id, user)
verb = f"+{amount}" if amount >= 0 else str(amount)
_txn("ADMIN_COINS", admin=admin_id, target=target_id, amount=verb, reason=reason, bal=user["balance"])
return {"ok": True, "balance": user["balance"], "change": amount}
@_locked_by(0)
async def do_admin_jail(target_id: int, minutes: int, admin_id: int, reason: str) -> dict:
"""Manually jail a user for `minutes` minutes."""
user = await get_user(target_id)
user["jailed_until"] = (_now() + timedelta(minutes=minutes)).isoformat()
user["jailbreak_used"] = False
await _commit(target_id, user)
_txn("ADMIN_JAIL", admin=admin_id, target=target_id, minutes=minutes, reason=reason)
return {"ok": True, "jailed_until": user["jailed_until"]}
@_locked_by(0)
async def do_admin_unjail(target_id: int, admin_id: int) -> dict:
"""Remove jail from a user."""
user = await get_user(target_id)
user["jailed_until"] = None
user["jailbreak_used"] = False
await _commit(target_id, user)
_txn("ADMIN_UNJAIL", admin=admin_id, target=target_id)
return {"ok": True}
@_locked_by(0)
async def do_admin_ban(target_id: int, admin_id: int, reason: str) -> dict:
"""Ban a user from all economy commands."""
user = await get_user(target_id)
user["eco_banned"] = True
await _commit(target_id, user)
_txn("ADMIN_BAN", admin=admin_id, target=target_id, reason=reason)
return {"ok": True}
@_locked_by(0)
async def do_admin_unban(target_id: int, admin_id: int) -> dict:
"""Lift an economy ban."""
user = await get_user(target_id)
user["eco_banned"] = False
await _commit(target_id, user)
_txn("ADMIN_UNBAN", admin=admin_id, target=target_id)
return {"ok": True}
@_locked_by(0)
async def do_admin_reset(target_id: int, admin_id: int) -> dict:
"""Wipe a user's economy data back to defaults."""
user = await get_user(target_id)
fresh = _default_user()
fresh["_pb_id"] = user.get("_pb_id") # type: ignore[typeddict-unknown-key]
await _commit(target_id, fresh)
_txn("ADMIN_RESET", admin=admin_id, target=target_id)
return {"ok": True}
async def do_admin_inspect(target_id: int) -> dict:
"""Return the user's full raw economy data."""
user = await get_user(target_id)
return {"ok": True, "data": dict(user)}
@_locked_by(0)
async def do_admin_exp(target_id: int, amount: int, admin_id: int, reason: str) -> dict:
"""Give (positive) or take (negative) EXP from a user. EXP is floored at 0."""
user = await get_user(target_id)
old_exp = user.get("exp", 0)
old_level = get_level(old_exp)
user["exp"] = max(0, old_exp + amount)
user["season_total_exp"] = max(0, user.get("season_total_exp", 0) + amount)
new_level = get_level(user["exp"])
await _commit(target_id, user)
verb = f"+{amount}" if amount >= 0 else str(amount)
_txn("ADMIN_EXP", admin=admin_id, target=target_id, amount=verb, reason=reason, exp=user["exp"])
return {
"ok": True,
"exp": user["exp"],
"change": amount,
"old_level": old_level,
"new_level": new_level,
"level_changed": new_level != old_level,
}
@_locked_by(0)
async def do_admin_item(target_id: int, item_id: str, action: str, admin_id: int) -> dict:
"""Give or remove an item. action='give'|'remove'. Returns ok/reason."""
if item_id not in SHOP:
return {"ok": False, "reason": "invalid_item"}
user = await get_user(target_id)
items: list = list(user.get("items") or [])
item_uses: dict = dict(user.get("item_uses") or {})
if action == "give":
if item_id not in items:
items.append(item_id)
if item_id == "anticheat":
item_uses["anticheat"] = 2
user["items"] = items
user["item_uses"] = item_uses
await _commit(target_id, user)
_txn("ADMIN_ITEM_GIVE", admin=admin_id, target=target_id, item=item_id)
return {"ok": True, "action": "given", "item_id": item_id}
elif action == "remove":
if item_id not in items:
return {"ok": False, "reason": "not_owned"}
items.remove(item_id)
item_uses.pop(item_id, None)
user["items"] = items
user["item_uses"] = item_uses
await _commit(target_id, user)
_txn("ADMIN_ITEM_REMOVE", admin=admin_id, target=target_id, item=item_id)
return {"ok": True, "action": "removed", "item_id": item_id}
return {"ok": False, "reason": "invalid_action"}

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"]}

170
core/economy/consumables.py Normal file
View File

@@ -0,0 +1,170 @@
"""Consumables: repeatable, expiring boosts - a recurring coin sink.
Unlike SHOP items (permanent, bought once), consumables are bought over and
over. Buying either activates a timed buff (stored in user["active_buffs"] as
{kind: expiry_iso}) or applies an instant effect, and the coins are destroyed -
so this keeps the shop relevant, and the economy draining, after a player has
maxed out permanent gear.
Effect hooks live where the effect belongs:
- "earn" buff -> income.do_work / do_beg / do_crime (via earn_mult)
- "exp" buff -> levels.award_exp (via exp_buff_mult)
- instant kohv -> handled entirely here (wipes cooldown timestamps)
"""
from __future__ import annotations
from datetime import timedelta
from typing import TypedDict
import strings
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
emoji: str
cost: int
description: str
kind: str # active_buffs key ("earn"/"exp"), or "instant"
duration_min: int # buff lifetime in minutes (0 for instant effects)
CONSUMABLES: dict[str, Consumable] = {
"energy_xl": {
"name": "Energiajook XL",
"emoji": E["TipiBULL"],
"cost": 500,
"description": strings.CONSUMABLE_DESCRIPTIONS["energy_xl"],
"kind": "earn",
"duration_min": 60,
},
"xp_potion": {
"name": "XP jook",
"emoji": "",
"cost": 500,
"description": strings.CONSUMABLE_DESCRIPTIONS["xp_potion"],
"kind": "exp",
"duration_min": 60,
},
"kohv": {
"name": "Kohv",
"emoji": "",
"cost": 300,
"description": strings.CONSUMABLE_DESCRIPTIONS["kohv"],
"kind": "instant",
"duration_min": 0,
},
}
# Multiplier granted while a buff of each kind is active.
_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)
# ---------------------------------------------------------------------------
def active_buffs(user) -> dict[str, str]:
"""Return {kind: expiry_iso} for buffs that have not expired yet."""
buffs = user.get("active_buffs") or {}
now = _now()
out: dict[str, str] = {}
for kind, expiry in buffs.items():
dt = _parse_dt(expiry)
if dt is not None and dt > now:
out[kind] = expiry
return out
def buff_remaining(user, kind: str) -> timedelta | None:
"""Remaining time on a buff kind, or None if it is inactive."""
expiry = active_buffs(user).get(kind)
if expiry is None:
return None
return _parse_dt(expiry) - _now()
def earn_mult(user) -> float:
"""Earnings multiplier from an active 'earn' buff (1.0 if none)."""
return _BUFF_MULT["earn"] if "earn" in active_buffs(user) else 1.0
def exp_buff_mult(user) -> float:
"""EXP multiplier from an active 'exp' buff (1.0 if none)."""
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
# ---------------------------------------------------------------------------
@_locked_by(0)
async def do_buy_consumable(user_id: int, cons_id: str) -> dict:
if cons_id not in CONSUMABLES:
return {"ok": False, "reason": "not_found"}
try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
cons = CONSUMABLES[cons_id]
if user["balance"] < cons["cost"]:
return {"ok": False, "reason": "insufficient", "need": cons["cost"] - user["balance"]}
user["balance"] -= cons["cost"]
extended = False
if cons["kind"] == "instant":
for field in _COOLDOWN_FIELDS:
user[field] = None
else:
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"])
return {
"ok": True,
"consumable": cons,
"balance": user["balance"],
"instant": cons["kind"] == "instant",
"extended": extended,
"remaining": None if cons["kind"] == "instant" else buff_remaining(user, cons["kind"]),
}

193
core/economy/fishing.py Normal file
View File

@@ -0,0 +1,193 @@
"""Fishing minigame: catalogue, rolls, catch/sell flows."""
from __future__ import annotations
import random
from ..pb_client import DatabaseError
from .store import (
_cooldown_remaining, _commit, _is_jailed, _locked_by, _now,
_prestige_mult, _txn, effective_cooldown, get_user,
)
# ---------------------------------------------------------------------------
# Fish catalogue
# ---------------------------------------------------------------------------
FISH_CATALOGUE: dict[str, dict] = {
# id: { rarity, weight=(min_g, max_g), coins=(min, max), exp }
"sarj": {"rarity": "common", "weight": (50, 500), "coins": (3, 18), "exp": 3},
"ahven": {"rarity": "common", "weight": (80, 700), "coins": (5, 22), "exp": 3},
"koger": {"rarity": "common", "weight": (100, 800), "coins": (5, 20), "exp": 3},
"viidikas": {"rarity": "common", "weight": (10, 120), "coins": (2, 8), "exp": 2},
"latikas": {"rarity": "uncommon", "weight": (300, 2500), "coins": (20, 70), "exp": 6},
"karpkala": {"rarity": "uncommon", "weight": (500, 4000), "coins": (25, 80), "exp": 7},
"linask": {"rarity": "uncommon", "weight": (200, 2000), "coins": (18, 60), "exp": 6},
"haug": {"rarity": "rare", "weight": (500, 6000), "coins": (50, 180), "exp": 10},
"angerjas": {"rarity": "rare", "weight": (200, 1800), "coins": (40, 120), "exp": 10},
"siig": {"rarity": "rare", "weight": (200, 2000), "coins": (45, 130), "exp": 10},
"forell": {"rarity": "epic", "weight": (400, 4500), "coins": (100, 280), "exp": 15},
"koha": {"rarity": "epic", "weight": (600, 7000), "coins": (120, 300), "exp": 15},
"tougjas": {"rarity": "epic", "weight": (400, 4000), "coins": (90, 250), "exp": 14},
"lohe": {"rarity": "legendary","weight": (1500, 12000), "coins": (250, 700), "exp": 25},
"vimb": {"rarity": "legendary","weight": (200, 1200), "coins": (200, 600), "exp": 25},
}
FISH_RARITY_WEIGHTS: dict[str, int] = {
"junk": 15,
"common": 45,
"uncommon": 22,
"rare": 12,
"epic": 5,
"legendary": 1,
}
def roll_fish(rarity_bump: bool = False) -> tuple[str, int]:
"""Roll a random fish. Returns (fish_id, weight_grams) or ('junk', 0).
rarity_bump=True (kalavork item) shifts each catch one tier up.
"""
rarity_pool = list(FISH_RARITY_WEIGHTS.keys())
weights = list(FISH_RARITY_WEIGHTS.values())
chosen_rarity = random.choices(rarity_pool, weights=weights)[0]
if chosen_rarity == "junk":
return ("junk", 0)
if rarity_bump:
order = ["common", "uncommon", "rare", "epic", "legendary"]
idx = order.index(chosen_rarity) if chosen_rarity in order else 0
chosen_rarity = order[min(idx + 1, len(order) - 1)]
fish_of_rarity = [k for k, v in FISH_CATALOGUE.items() if v["rarity"] == chosen_rarity]
if not fish_of_rarity:
return ("junk", 0)
fish_id = random.choice(fish_of_rarity)
fish = FISH_CATALOGUE[fish_id]
weight = random.randint(fish["weight"][0], fish["weight"][1])
return (fish_id, weight)
# ---------------------------------------------------------------------------
# /fish
# ---------------------------------------------------------------------------
@_locked_by(0)
async def do_fish_start(user_id: int) -> dict:
"""Check cooldown + jail, set cooldown. Call before starting the fishing minigame."""
try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
if jail := _is_jailed(user):
return {"ok": False, "reason": "jailed", "remaining": jail}
fish_cd = effective_cooldown("fish", user["items"])
if cd := _cooldown_remaining(user, "fish", override_cd=fish_cd):
return {"ok": False, "reason": "cooldown", "remaining": cd}
user["last_fish"] = _now().isoformat()
await _commit(user_id, user)
return {"ok": True}
@_locked_by(0)
async def do_fish_resolve(user_id: int, fish_id: str, weight: int) -> dict:
"""Add catch to inventory + update fish_book. Returns catch info incl. pre-calculated value."""
user = await get_user(user_id)
if fish_id == "junk":
_txn("FISH_JUNK", user=user_id)
return {"ok": True, "type": "junk", "coins": 0, "exp": 0}
if fish_id not in FISH_CATALOGUE:
return {"ok": False, "reason": "invalid_fish"}
fish = FISH_CATALOGUE[fish_id]
min_c, max_c = fish["coins"]
w_min, w_max = fish["weight"]
weight_ratio = (weight - w_min) / max(1, w_max - w_min)
base_coins = int(min_c + weight_ratio * (max_c - min_c))
coin_mult, _ = _prestige_mult(user)
value = int(base_coins * coin_mult)
exp = fish["exp"]
book: dict = user.get("fish_book") or {}
prev_count = book.get(fish_id, 0)
book[fish_id] = prev_count + 1
user["fish_book"] = book
user["total_fish_caught"] = user.get("total_fish_caught", 0) + 1
inv: list = list(user.get("fish_inventory") or [])
inv.append({"fish_id": fish_id, "weight": weight, "value": value})
user["fish_inventory"] = inv
await _commit(user_id, user)
_txn("FISH", user=user_id, fish=fish_id, weight=weight, value=value)
return {
"ok": True,
"type": "fish",
"fish_id": fish_id,
"weight": weight,
"value": value,
"exp": exp,
"is_new": prev_count == 0,
"total_caught": book[fish_id],
}
@_locked_by(0)
async def do_fish_sell(user_id: int, indices: list[int] | None = None) -> dict:
"""Sell fish from inventory. indices=None sells all. Returns coins earned."""
user = await get_user(user_id)
inv: list = list(user.get("fish_inventory") or [])
if not inv:
return {"ok": False, "reason": "empty"}
if indices is None:
to_sell = inv
remaining = []
else:
sell_idx = {
(i if i >= 0 else len(inv) + i)
for i in indices
}
sell_idx = {i for i in sell_idx if 0 <= i < len(inv)}
to_sell = [inv[i] for i in sorted(sell_idx)]
keep_idx = set(range(len(inv))) - sell_idx
remaining = [inv[i] for i in sorted(keep_idx)]
if not to_sell:
return {"ok": False, "reason": "empty"}
total_coins = sum(entry["value"] for entry in to_sell)
user["fish_inventory"] = remaining
user["balance"] = user.get("balance", 0) + total_coins
user["lifetime_earned"] = user.get("lifetime_earned", 0) + total_coins
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
await _commit(user_id, user)
_txn("FISH_SELL", user=user_id, count=len(to_sell), coins=f"+{total_coins}", bal=user["balance"])
return {
"ok": True,
"coins": total_coins,
"count": len(to_sell),
"balance": user["balance"],
}
async def do_fishbook(user_id: int) -> dict:
"""Return the user's fish book data including per-species inventory counts."""
user = await get_user(user_id)
book: dict = user.get("fish_book") or {}
inv: list = user.get("fish_inventory") or []
inv_counts: dict[str, int] = {}
for entry in inv:
fid = entry.get("fish_id", "")
inv_counts[fid] = inv_counts.get(fid, 0) + 1
return {
"ok": True,
"book": book,
"inv_counts": inv_counts,
"total_fish_caught": user.get("total_fish_caught", 0),
"unique_caught": len(book),
"total_species": len(FISH_CATALOGUE),
}

324
core/economy/gambling.py Normal file
View File

@@ -0,0 +1,324 @@
"""Casino games: roulette, slots, RPS bets and escrow, blackjack."""
from __future__ import annotations
import random
from ..pb_client import DatabaseError
from ..emoji import EMOJI as E
from .store import (
_commit, _is_jailed, _locked_by, _log, _txn, add_pending_wager,
clear_pending_wager, get_user,
)
from .house import _credit_house
# ---------------------------------------------------------------------------
# /roulette
# ---------------------------------------------------------------------------
@_locked_by(0)
async def do_roulette(user_id: int, bet: int, colour: str) -> dict:
try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
if jail := _is_jailed(user):
return {"ok": False, "reason": "jailed", "remaining": jail}
if user["balance"] < bet:
return {"ok": False, "reason": "insufficient"}
# Wheel: 18 red, 18 black, 1 green (37 slots - real roulette proportions)
result = random.choices(["punane", "must", "roheline"], weights=[18, 18, 1])[0]
won = result == colour
mult = 14 if colour == "roheline" else 1
change = bet * mult if won else -bet
user["balance"] = max(0, user["balance"] + change)
user["total_wagered"] = user.get("total_wagered", 0) + bet
if won:
user["lifetime_earned"] = user.get("lifetime_earned", 0) + abs(change)
user["biggest_win"] = max(user.get("biggest_win", 0), abs(change))
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
else:
user["lifetime_lost"] = user.get("lifetime_lost", 0) + bet
user["biggest_loss"] = max(user.get("biggest_loss", 0), bet)
await _commit(user_id, user)
if not won:
await _credit_house(bet)
_txn("ROULETTE_" + ("WIN" if won else "LOSE"), user=user_id, bet=bet, colour=colour, result=result, mult=mult, bal=user["balance"])
return {
"ok": True, "won": won,
"result": result, "change": abs(change), "mult": mult,
"balance": user["balance"],
}
# ---------------------------------------------------------------------------
# /rps (bet resolution)
# ---------------------------------------------------------------------------
@_locked_by(0)
async def do_game_bet(user_id: int, bet: int, outcome: str) -> dict:
"""Settle a simple win/tie/lose bet. outcome: 'win' | 'tie' | 'lose'."""
try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
if jail := _is_jailed(user):
return {"ok": False, "reason": "jailed", "remaining": jail}
if user["balance"] < bet:
return {"ok": False, "reason": "insufficient"}
user["total_wagered"] = user.get("total_wagered", 0) + bet
if outcome == "win":
user["balance"] += bet
user["lifetime_earned"] = user.get("lifetime_earned", 0) + bet
user["biggest_win"] = max(user.get("biggest_win", 0), bet)
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
elif outcome == "lose":
user["balance"] = max(0, user["balance"] - bet)
user["lifetime_lost"] = user.get("lifetime_lost", 0) + bet
user["biggest_loss"] = max(user.get("biggest_loss", 0), bet)
# tie: no change
await _commit(user_id, user)
if outcome == "lose" and bet > 0:
await _credit_house(bet)
_txn("RPS_" + outcome.upper(), user=user_id, bet=bet, bal=user["balance"])
return {"ok": True, "balance": user["balance"]}
# ---------------------------------------------------------------------------
# /rps PvP escrow (deposit/payout/refund)
# ---------------------------------------------------------------------------
@_locked_by(0)
async def do_rps_pvp_deposit(user_id: int, bet: int) -> dict:
"""Hold `bet` coins from a player as escrow for a PvP RPS duel."""
try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
if jail := _is_jailed(user):
return {"ok": False, "reason": "jailed", "remaining": jail}
if user["balance"] < bet:
return {"ok": False, "reason": "insufficient"}
user["balance"] -= bet
user["total_wagered"] = user.get("total_wagered", 0) + bet
add_pending_wager(user, "rps", bet) # escrow survives a restart
try:
await _commit(user_id, user)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
_txn("RPS_PVP_DEPOSIT", user=user_id, bet=bet, bal=user["balance"])
return {"ok": True, "balance": user["balance"]}
@_locked_by(0)
async def do_rps_pvp_payout(winner_id: int, bet: int) -> dict:
"""Credit the duel winner with 2*bet (their stake back + opponent's)."""
try:
user = await get_user(winner_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
payout = bet * 2
user["balance"] = user.get("balance", 0) + payout
user["lifetime_earned"] = user.get("lifetime_earned", 0) + bet
user["biggest_win"] = max(user.get("biggest_win", 0), bet)
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
clear_pending_wager(user) # winner's escrow settled
try:
await _commit(winner_id, user)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
_txn("RPS_PVP_PAYOUT", user=winner_id, payout=f"+{payout}", bal=user["balance"])
return {"ok": True, "balance": user["balance"]}
@_locked_by(0)
async def do_rps_pvp_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)."""
try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
user["balance"] = user.get("balance", 0) + bet
user["total_wagered"] = max(0, user.get("total_wagered", 0) - bet)
clear_pending_wager(user) # escrow returned
try:
await _commit(user_id, user)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
_txn("RPS_PVP_REFUND", user=user_id, bet=bet, bal=user["balance"])
return {"ok": True, "balance": user["balance"]}
# ---------------------------------------------------------------------------
# /slots
# ---------------------------------------------------------------------------
_SLOTS_SYMBOLS: list[tuple[str, int]] = [
(E["TipiHEART"], 27),
(E["TipiFIRE"], 22),
(E["TipiTROLL"], 18),
(E["TipICRY"], 15),
(E["TipiSKULL"], 10),
(E["TipiKARIKAS"], 8),
]
_SLOTS_JACKPOT = E["TipiKARIKAS"]
_SLOTS_TRIPLE_MULT: dict[str, int] = {
E["TipiHEART"]: 4,
E["TipiFIRE"]: 5,
E["TipiTROLL"]: 7,
E["TipICRY"]: 10,
E["TipiSKULL"]: 15,
E["TipiKARIKAS"]: 25, # jackpot
}
def _spin() -> str:
symbols, weights = zip(*_SLOTS_SYMBOLS)
return random.choices(list(symbols), weights=list(weights), k=1)[0]
@_locked_by(0)
async def do_slots(user_id: int, bet: int) -> dict:
try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
if jail := _is_jailed(user):
return {"ok": False, "reason": "jailed", "remaining": jail}
if user["balance"] < bet:
return {"ok": False, "reason": "insufficient"}
reels = [_spin(), _spin(), _spin()]
a, b, c = reels
has_360 = "monitor_360" in user["items"]
if a == b == c:
tier = "jackpot" if a == _SLOTS_JACKPOT else "triple"
base_mult = _SLOTS_TRIPLE_MULT.get(a, 4)
mult = int(base_mult * 1.5) if has_360 else base_mult
change = bet * (mult - 1)
elif a == b or b == c or a == c:
tier = "pair"
change = bet // 2
else:
tier = "miss"
change = -bet
user["balance"] = max(0, user["balance"] + change)
user["total_wagered"] = user.get("total_wagered", 0) + bet
if tier in ("jackpot", "triple", "pair"):
user["lifetime_earned"] = user.get("lifetime_earned", 0) + change
user["biggest_win"] = max(user.get("biggest_win", 0), change)
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
if tier == "jackpot":
user["slots_jackpots"] = user.get("slots_jackpots", 0) + 1
else:
user["lifetime_lost"] = user.get("lifetime_lost", 0) + bet
user["biggest_loss"] = max(user.get("biggest_loss", 0), bet)
await _commit(user_id, user)
if tier == "miss":
await _credit_house(bet)
_txn("SLOTS_" + tier.upper(), user=user_id, bet=bet, change=change, bal=user["balance"])
return {
"ok": True,
"reels": reels,
"tier": tier,
"change": change,
"balance": user["balance"],
}
# ---------------------------------------------------------------------------
# /blackjack
# ---------------------------------------------------------------------------
@_locked_by(0)
async def do_blackjack_bet(user_id: int, bet: int) -> dict:
"""Deduct the initial blackjack bet. Returns ok/fail."""
try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
if jail := _is_jailed(user):
return {"ok": False, "reason": "jailed", "remaining": jail}
if user["balance"] < bet:
return {"ok": False, "reason": "insufficient", "balance": user["balance"]}
user["balance"] -= bet
# 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.
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
net = payout - total_invested
if net > 0:
user["lifetime_earned"] = user.get("lifetime_earned", 0) + net
user["biggest_win"] = max(user.get("biggest_win", 0), net)
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
elif net < 0:
user["lifetime_lost"] = user.get("lifetime_lost", 0) + abs(net)
user["biggest_loss"] = max(user.get("biggest_loss", 0), abs(net))
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)
_txn("BLACKJACK", user=user_id, payout=f"{payout:+}", net=f"{net:+}", bal=user["balance"])
return {"ok": True, "balance": user["balance"]}

103
core/economy/heist.py Normal file
View File

@@ -0,0 +1,103 @@
"""Bank heist: group robbery of the house."""
from __future__ import annotations
import random
from .. import pb_client
from ..pb_client import DatabaseError
from . import house
from .store import HEIST_JAIL, _commit, _is_jailed, _now, _txn, _user_lock, get_user
from .house import _credit_house, _refund_house_safe, _debit_house_safe
# ---------------------------------------------------------------------------
# /heist
# ---------------------------------------------------------------------------
async def do_heist_check(user_id: int) -> dict:
"""Check whether a user is eligible to join a heist."""
try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
if jail := _is_jailed(user):
return {"ok": False, "reason": "jailed", "remaining": jail}
return {"ok": True}
async def do_heist_resolve(user_ids: list[int], success: bool) -> dict:
"""Apply heist outcome to all participants. On win, steals from house.
Per-user commit failures attempt to compensate the house so the economy
stays balanced. If compensation also fails, a CRITICAL log is emitted.
"""
now = _now()
payout_each = 0
failed_users: list[int] = []
if success and house.HOUSE_ID is not None:
# NB: use a distinct local name - assigning to `house` here would shadow
# the imported module for the whole function and break `house.HOUSE_ID`.
try:
house_rec = await get_user(house.HOUSE_ID)
pct = random.uniform(0.20, 0.55)
# Never promise more than the house actually holds: the desired pot
# is capped at the current balance, and each share is floored, so the
# amount debited equals the amount paid out (no minting, no leak).
pot = min(max(300, int(house_rec["balance"] * pct)), house_rec["balance"])
pot = max(0, pot)
payout_each = pot // len(user_ids)
debit = payout_each * len(user_ids)
# Atomic decrement instead of a full record commit, so concurrent
# _credit_house increments aren't lost.
if debit > 0:
await pb_client.update_record(house_rec["_pb_id"], {"balance-": debit}) # type: ignore[typeddict-item]
except DatabaseError:
return {"ok": False, "reason": "db_error"}
_txn("HEIST_HOUSE", change=f"-{debit}", house_bal=house_rec["balance"] - debit)
for uid in user_ids:
async with _user_lock(uid):
try:
user = await get_user(uid)
except DatabaseError:
failed_users.append(uid)
if success and payout_each > 0:
await _refund_house_safe(payout_each, "heist_win_compensate", uid)
continue
user["last_heist"] = now.isoformat()
user["heists_joined"] = user.get("heists_joined", 0) + 1
fine_credited = False
if success:
user["balance"] += payout_each
user["heists_won"] = user.get("heists_won", 0) + 1
user["lifetime_earned"] = user.get("lifetime_earned", 0) + payout_each
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
_txn("HEIST_WIN", user=uid, change=f"+{payout_each}", bal=user["balance"])
else:
fine = max(150, min(1000, int(user["balance"] * 0.15)))
user["balance"] = max(0, user["balance"] - fine)
user["jailed_until"] = (now + HEIST_JAIL).isoformat()
user["jailbreak_used"] = False
user["times_jailed"] = user.get("times_jailed", 0) + 1
user["lifetime_lost"] = user.get("lifetime_lost", 0) + fine
_txn("HEIST_FAIL", user=uid, fine=f"-{fine}", jailed_until=user["jailed_until"], bal=user["balance"])
if fine > 0:
try:
await _credit_house(fine)
fine_credited = True
except DatabaseError:
pass # user commit will still be attempted; if both fail, no economy effect
try:
await _commit(uid, user)
except DatabaseError:
failed_users.append(uid)
if success and payout_each > 0:
await _refund_house_safe(payout_each, "heist_win_compensate", uid)
elif not success and fine_credited:
await _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}

92
core/economy/house.py Normal file
View File

@@ -0,0 +1,92 @@
"""House account: the bot's own balance, fed by fines and lost bets."""
from __future__ import annotations
from .. import pb_client
from ..pb_client import DatabaseError
from .store import _log, _now, get_user, _commit
# ---------------------------------------------------------------------------
# House account (bot user)
# ---------------------------------------------------------------------------
HOUSE_ID: int | None = None
_house_pb_id: str | None = None
def set_house(user_id: int) -> None:
"""Register the bot's Discord user ID as the house account."""
global HOUSE_ID, _house_pb_id
if HOUSE_ID != user_id:
_house_pb_id = None
HOUSE_ID = user_id
async def _house_record_id() -> str | None:
"""PocketBase record id of the house account (cached; creates the record on first use)."""
global _house_pb_id
if HOUSE_ID is None:
return None
if _house_pb_id is None:
house = await get_user(HOUSE_ID)
_house_pb_id = house.get("_pb_id") # type: ignore[typeddict-item]
return _house_pb_id
async def _credit_house(amount: int) -> None:
"""Add `amount` coins to the house via an atomic PocketBase increment.
Deliberately lock-free: callers hold per-user locks, so this must never
acquire one itself (see the locking rules above)."""
if amount <= 0:
return
record_id = await _house_record_id()
if record_id is None:
return
await pb_client.update_record(record_id, {"balance+": amount})
async def get_heist_global_cd() -> float:
"""Return unix timestamp until which no new heist can start. Persisted on house record."""
if HOUSE_ID is None:
return 0.0
house = await get_user(HOUSE_ID)
return float(house.get("heist_global_cd_until") or 0)
async def set_heist_global_cd(until: float) -> None:
"""Persist heist global cooldown expiry to the house account in PocketBase."""
record_id = await _house_record_id()
if record_id is None:
return
await pb_client.update_record(record_id, {"heist_global_cd_until": until})
async def _refund_house_safe(amount: int, context: str, related_uid: int) -> None:
"""Best-effort refund of `amount` to the house. Logs critical if it fails."""
if HOUSE_ID is None or amount <= 0:
return
try:
await _credit_house(amount)
except DatabaseError as exc:
_log.critical(
"House compensation failed (%s, related uid %s, amount %s): %s",
context, related_uid, amount, exc,
)
async def _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:
record_id = await _house_record_id()
if record_id:
await pb_client.update_record(record_id, {"balance-": amount})
except DatabaseError as exc:
_log.critical(
"House debit compensation failed (%s, related uid %s, amount %s): %s",
context, uid, amount, exc,
)

406
core/economy/income.py Normal file
View File

@@ -0,0 +1,406 @@
"""Income and social commands: daily, work, beg, crime, rob, give."""
from __future__ import annotations
import random
from datetime import date
import strings
from ..pb_client import DatabaseError
from . import house
from .store import (
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
# ---------------------------------------------------------------------------
# /daily
# ---------------------------------------------------------------------------
@_locked_by(0)
async def do_daily(user_id: int) -> dict:
try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
daily_cd = effective_cooldown("daily", user["items"])
if cd := _cooldown_remaining(user, "daily", override_cd=daily_cd):
return {"ok": False, "reason": "cooldown", "remaining": cd}
today = _now().date()
last_str = user.get("last_streak_date")
last_date = date.fromisoformat(last_str) if last_str else None
if last_date is None:
streak = 1
elif (today - last_date).days == 1:
streak = user["daily_streak"] + 1
elif "karikas" in user["items"]:
streak = user["daily_streak"] # karikas: streak survives missed days
else:
streak = 1 # streak broken
# Streak multiplier tiers
if streak >= 14:
streak_mult = 3.0
elif streak >= 7:
streak_mult = 2.0
elif streak >= 3:
streak_mult = 1.5
else:
streak_mult = 1.0
vip = "lan_pass" in user["items"]
vip_mult = 2.0 if vip else 1.0
daily_plus_level = (user.get("prestige_upgrades") or {}).get("daily_plus", 0)
base = int(150 * (1.0 + daily_plus_level * PRESTIGE_SHOP["daily_plus"]["effect"]))
earned = int(base * streak_mult * vip_mult)
if "korvaklapid" in user["items"]:
earned += 25
coin_mult, _ = _prestige_mult(user)
earned = int(earned * coin_mult)
# Investor interest (capped at 500/day to prevent runaway wealth)
interest = 0
if "gaming_laptop" in user["items"] and user["balance"] > 0:
interest = min(int(user["balance"] * 0.05), 500)
earned += interest
user["balance"] += earned
user["last_daily"] = _now().isoformat()
user["daily_streak"] = streak
user["last_streak_date"] = today.isoformat()
user["lifetime_earned"] = user.get("lifetime_earned", 0) + earned
user["best_daily_streak"] = max(user.get("best_daily_streak", 0), streak)
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
await _commit(user_id, user)
_txn("DAILY", user=user_id, earned=f"+{earned}", streak=streak, bal=user["balance"])
return {
"ok": True,
"earned": earned,
"interest": interest,
"streak": streak,
"streak_mult": streak_mult,
"vip": vip,
"balance": user["balance"],
}
# ---------------------------------------------------------------------------
# /work
# ---------------------------------------------------------------------------
_WORK_JOBS = strings.WORK_JOBS
@_locked_by(0)
async def do_work(user_id: int) -> dict:
try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
work_cd = 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):
return {"ok": False, "reason": "jailed", "remaining": jail}
job, job_mult = random.choice(_WORK_JOBS)
base = random.randint(15, 75)
worker_mult = 1.5 if "gaming_hiir" in user["items"] else 1.0
desk_mult = 1.25 if "reguleeritav_laud" in user["items"] else 1.0
lucky = False
if "energiajook" in user["items"] and random.random() < 0.30:
lucky = True
work_plus_level = (user.get("prestige_upgrades") or {}).get("work_plus", 0)
work_plus_mult = 1.0 + work_plus_level * PRESTIGE_SHOP["work_plus"]["effect"]
coin_mult, _ = _prestige_mult(user)
earned = int(base * job_mult * worker_mult * desk_mult * (3.0 if lucky else 1.0) * work_plus_mult * coin_mult * earn_mult(user))
user["balance"] += earned
user["last_work"] = _now().isoformat()
user["work_count"] = user.get("work_count", 0) + 1
user["lifetime_earned"] = user.get("lifetime_earned", 0) + earned
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
await _commit(user_id, user)
_txn("WORK", user=user_id, earned=f"+{earned}", lucky=lucky, bal=user["balance"])
return {
"ok": True,
"earned": earned,
"job": job,
"lucky": lucky,
"hiir": worker_mult > 1.0,
"laud": desk_mult > 1.0,
"balance": user["balance"],
}
# ---------------------------------------------------------------------------
# /beg
# ---------------------------------------------------------------------------
_BEG_LINES = strings.BEG_LINES
_BEG_JAIL_LINES = strings.BEG_JAIL_LINES
@_locked_by(0)
async def do_beg(user_id: int) -> dict:
try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
beg_cd = effective_cooldown("beg", user["items"])
if cd := _cooldown_remaining(user, "beg", override_cd=beg_cd):
return {"ok": False, "reason": "cooldown", "remaining": cd}
jailed = bool(_is_jailed(user))
beg_mult = 2 if "klaviatuur" in user["items"] else 1
coin_mult, _ = _prestige_mult(user)
earned = int(random.randint(10, 40) * beg_mult * coin_mult * earn_mult(user))
user["balance"] += earned
user["last_beg"] = _now().isoformat()
user["beg_count"] = user.get("beg_count", 0) + 1
user["lifetime_earned"] = user.get("lifetime_earned", 0) + earned
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
await _commit(user_id, user)
_txn("BEG", user=user_id, earned=f"+{earned}", jailed=jailed, bal=user["balance"])
return {
"ok": True,
"earned": earned,
"text": random.choice(_BEG_JAIL_LINES if jailed else _BEG_LINES),
"klaviatuur": beg_mult > 1,
"jailed": jailed,
"balance": user["balance"],
}
# ---------------------------------------------------------------------------
# /crime
# ---------------------------------------------------------------------------
_CRIME_WIN = strings.CRIME_WIN
_CRIME_LOSE = strings.CRIME_LOSE
@_locked_by(0)
async def do_crime(user_id: int) -> dict:
try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
if cd := _cooldown_remaining(user, "crime"):
return {"ok": False, "reason": "cooldown", "remaining": cd}
if jail := _is_jailed(user):
return {"ok": False, "reason": "jailed", "remaining": jail}
user["last_crime"] = _now().isoformat()
win_chance = 0.75 if "cat6" in user["items"] else 0.60
user["crimes_attempted"] = user.get("crimes_attempted", 0) + 1
if random.random() < win_chance:
earned = random.randint(200, 500)
if "mikrofon" in user["items"]:
earned = int(earned * 1.3)
earned = int(earned * earn_mult(user))
user["balance"] += earned
user["crimes_succeeded"] = user.get("crimes_succeeded", 0) + 1
user["lifetime_earned"] = user.get("lifetime_earned", 0) + earned
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
await _commit(user_id, user)
_txn("CRIME_WIN", user=user_id, earned=f"+{earned}", bal=user["balance"])
return {
"ok": True, "success": True,
"earned": earned, "text": random.choice(_CRIME_WIN),
"mikrofon": "mikrofon" in user["items"],
"balance": user["balance"],
}
else:
fine = random.randint(50, 150)
user["balance"] = max(0, user["balance"] - fine)
jailed = "gaming_tool" not in user["items"]
if jailed:
user["jailed_until"] = (_now() + JAIL_DURATION).isoformat()
user["jailbreak_used"] = False
user["times_jailed"] = user.get("times_jailed", 0) + 1
user["lifetime_lost"] = user.get("lifetime_lost", 0) + fine
await _commit(user_id, user)
await _credit_house(fine)
_txn("CRIME_FAIL", user=user_id, fine=f"-{fine}", jailed=jailed, bal=user["balance"])
return {
"ok": True, "success": False,
"fine": fine, "text": random.choice(_CRIME_LOSE),
"jailed": jailed,
"balance": user["balance"],
}
# ---------------------------------------------------------------------------
# /rob
# ---------------------------------------------------------------------------
@_locked_by(0, 1)
async def do_rob(robber_id: int, target_id: int) -> dict:
try:
robber = await get_user(robber_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if robber.get("eco_banned"):
return {"ok": False, "reason": "banned"}
try:
target = await get_user(target_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if cd := _cooldown_remaining(robber, "rob"):
return {"ok": False, "reason": "cooldown", "remaining": cd}
target_jailed = bool(_is_jailed(target))
if jail := _is_jailed(robber):
if not target_jailed:
return {"ok": False, "reason": "jailed", "remaining": jail}
elif target_jailed:
return {"ok": False, "reason": "target_jailed"}
is_house = (house.HOUSE_ID is not None and target_id == house.HOUSE_ID)
if is_house and target["balance"] < 50:
return {"ok": False, "reason": "broke"}
if not is_house and target["balance"] < 100:
return {"ok": False, "reason": "broke"}
robber["last_rob"] = _now().isoformat()
if "anticheat" in target["items"] and not is_house:
fine = random.randint(100, 200)
robber["balance"] = max(0, robber["balance"] - fine)
# Decrement anticheat uses
uses = target.get("item_uses", {}).get("anticheat", 2) - 1
if "item_uses" not in target:
target["item_uses"] = {}
if uses <= 0:
target["items"] = [i for i in target["items"] if i != "anticheat"]
target["item_uses"].pop("anticheat", None)
else:
target["item_uses"]["anticheat"] = uses
robber["lifetime_lost"] = robber.get("lifetime_lost", 0) + fine
await _commit(robber_id, robber)
await _commit(target_id, target)
await _credit_house(fine)
_txn("ROB_BLOCKED", robber=robber_id, victim=target_id, fine=f"-{fine}", robber_bal=robber["balance"], ac_uses_left=uses)
return {"ok": True, "success": False, "reason": "valvur", "fine": fine}
# Robbing the house has lower success (35%) but jackpot chance
success_chance = 0.35 if is_house else (0.60 if "jellyfin" in robber["items"] else 0.45)
if random.random() < success_chance:
jackpot = is_house and random.random() < 0.10
if jackpot:
pct = 0.40
elif is_house:
pct = random.uniform(0.05, 0.15)
else:
pct = random.uniform(0.10, 0.25)
stolen = max(10, min(int(target["balance"] * pct), target["balance"]))
target["balance"] -= stolen
prev_lifetime_earned = robber.get("lifetime_earned", 0)
prev_biggest_win = robber.get("biggest_win", 0)
prev_peak_balance = robber.get("peak_balance", 0)
robber["balance"] += stolen
robber["lifetime_earned"] = prev_lifetime_earned + stolen
robber["biggest_win"] = max(prev_biggest_win, stolen)
robber["peak_balance"] = max(prev_peak_balance, robber["balance"])
try:
await _commit(robber_id, robber)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
try:
await _commit(target_id, target)
except DatabaseError:
robber["balance"] -= stolen
robber["lifetime_earned"] = prev_lifetime_earned
robber["biggest_win"] = prev_biggest_win
robber["peak_balance"] = prev_peak_balance
try:
await _commit(robber_id, robber)
except DatabaseError as exc2:
_log.critical(
"do_rob rollback failed for robber %s after target commit failed: %s",
robber_id, exc2,
)
return {"ok": False, "reason": "db_error"}
_txn("ROB_WIN", robber=robber_id, victim=target_id, stolen=f"+{stolen}", jackpot=jackpot, robber_bal=robber["balance"], victim_bal=target["balance"])
return {"ok": True, "success": True, "stolen": stolen, "balance": robber["balance"], "jackpot": jackpot}
else:
fine = random.randint(100, 250)
robber["balance"] = max(0, robber["balance"] - fine)
robber["lifetime_lost"] = robber.get("lifetime_lost", 0) + fine
robber["biggest_loss"] = max(robber.get("biggest_loss", 0), fine)
await _commit(robber_id, robber)
await _credit_house(fine)
_txn("ROB_FAIL", robber=robber_id, victim=target_id, fine=f"-{fine}", robber_bal=robber["balance"])
return {"ok": True, "success": False, "reason": "caught", "fine": fine, "balance": robber["balance"]}
# ---------------------------------------------------------------------------
# /give
# ---------------------------------------------------------------------------
@_locked_by(0, 1)
async def do_give(giver_id: int, receiver_id: int, amount: int) -> dict:
try:
giver = await get_user(giver_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if giver.get("eco_banned"):
return {"ok": False, "reason": "banned"}
if rem := _is_jailed(giver):
return {"ok": False, "reason": "jailed", "remaining": rem}
if giver["balance"] < amount:
return {"ok": False, "reason": "insufficient"}
try:
receiver = await get_user(receiver_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
giver["balance"] -= amount
receiver["balance"] += amount
giver["total_given"] = giver.get("total_given", 0) + amount
receiver["total_received"] = receiver.get("total_received", 0) + amount
try:
await _commit(giver_id, giver)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
try:
await _commit(receiver_id, receiver)
except DatabaseError:
giver["balance"] += amount
giver["total_given"] = max(0, giver.get("total_given", 0) - amount)
try:
await _commit(giver_id, giver)
except DatabaseError as exc2:
_log.critical(
"do_give rollback failed for giver %s after receiver commit failed: %s",
giver_id, exc2,
)
return {"ok": False, "reason": "db_error"}
_txn("GIVE", from_=giver_id, to=receiver_id, amount=amount, from_bal=giver["balance"], to_bal=receiver["balance"])
return {
"ok": True,
"giver_balance": giver["balance"],
"receiver_balance": receiver["balance"],
}

85
core/economy/jail.py Normal file
View File

@@ -0,0 +1,85 @@
"""Jail, jailbreak and bail."""
from __future__ import annotations
import random
from datetime import timedelta
from .store import (
_commit, _is_jailed, _locked_by, _now, _txn, get_all_users_raw, get_user,
)
@_locked_by(0)
async def do_spam_jail(user_id: int) -> None:
"""Jail a user for 30 minutes due to suspected automated command spam."""
user = await get_user(user_id)
user["jailed_until"] = (_now() + timedelta(minutes=30)).isoformat()
user["jailbreak_used"] = False
user["times_jailed"] = user.get("times_jailed", 0) + 1
await _commit(user_id, user)
_txn("SPAM_JAIL", user=user_id, until=user["jailed_until"])
# ---------------------------------------------------------------------------
# /jailbreak (Monopoly-style dice rolls)
# ---------------------------------------------------------------------------
@_locked_by(0)
async def set_jailbreak_used(user_id: int) -> None:
"""Mark that the user has consumed their dice attempt for this jail sentence."""
user = await get_user(user_id)
user["jailbreak_used"] = True
await _commit(user_id, user)
@_locked_by(0)
async def do_jail_free(user_id: int) -> dict:
"""Remove jail status after rolling doubles."""
user = await get_user(user_id)
user["jailed_until"] = None
user["jailbreak_used"] = False
await _commit(user_id, user)
_txn("JAIL_FREE", user=user_id, method="doubles")
return {"ok": True, "balance": user["balance"]}
MIN_BAIL = 350
@_locked_by(0)
async def do_bail(user_id: int) -> dict:
"""Charge bail fine after exhausting jailbreak rolls and free the user.
Fine = 20-30% of current balance, floored at 350. If balance < 350, stay jailed."""
user = await get_user(user_id)
# Idempotency guard: only an actively-jailed user can be charged bail. The
# first successful call clears jailed_until, so a rapid second click or a
# stale BailView from a re-run /jailbreak becomes a no-op instead of a
# second fine (bail is a pure sink - a double charge destroys coins).
if not _is_jailed(user):
return {"ok": False, "reason": "not_jailed", "balance": user["balance"]}
if user["balance"] < MIN_BAIL:
return {"ok": False, "reason": "broke", "balance": user["balance"]}
pct = random.uniform(0.20, 0.30)
fine = max(MIN_BAIL, int(user["balance"] * pct))
user["balance"] = max(0, user["balance"] - fine)
user["jailed_until"] = None
user["jailbreak_used"] = False
user["lifetime_lost"] = user.get("lifetime_lost", 0) + fine
user["total_bail_paid"] = user.get("total_bail_paid", 0) + fine
await _commit(user_id, user)
_txn("BAIL_PAID", user=user_id, fine=f"-{fine}", pct=f"{pct:.0%}", bal=user["balance"])
return {"ok": True, "fine": fine, "balance": user["balance"]}
# ---------------------------------------------------------------------------
# /jailed
# ---------------------------------------------------------------------------
async def do_get_jailed() -> list[tuple[int, timedelta]]:
"""Return [(user_id, remaining)] for every user currently in jail."""
all_users = await get_all_users_raw()
result: list[tuple[int, timedelta]] = []
for uid_str, user in all_users.items():
if rem := _is_jailed(user):
result.append((int(uid_str), rem))
result.sort(key=lambda x: x[1])
return result

View File

@@ -0,0 +1,146 @@
"""Leaderboard queries over the full collection."""
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, 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"], _net_worth(r)) for r in records if r.get("user_id")),
key=lambda x: x[1],
reverse=True,
)
return result if top_n is None else result[:top_n]
async def get_leaderboard_exp(top_n: int | None = 10) -> list[tuple[str, int, int]]:
"""Return top_n (user_id_str, exp, level) sorted by EXP descending."""
records = await pb_client.list_all_records()
result = sorted(
((r["user_id"], r.get("exp", 0)) for r in records if r.get("user_id")),
key=lambda x: x[1],
reverse=True,
)
entries = [(uid, exp, get_level(exp)) for uid, exp in result]
return entries if top_n is None else entries[:top_n]
# ---------------------------------------------------------------------------
# Extended leaderboards
# ---------------------------------------------------------------------------
async def get_leaderboard_season_exp(top_n: int | None = 10) -> list[tuple[str, int, int]]:
"""Return (user_id, season_total_exp, prestige_level) sorted by season EXP."""
records = await pb_client.list_all_records()
result = sorted(
(
(r["user_id"], r.get("season_total_exp", 0), r.get("prestige_level", 0))
for r in records if r.get("user_id")
),
key=lambda x: x[1],
reverse=True,
)
return result if top_n is None else result[:top_n]
async def get_leaderboard_prestige(top_n: int | None = 10) -> list[tuple[str, int, int]]:
"""Return (user_id, prestige_level, prestige_points) sorted by prestige_level then PP."""
records = await pb_client.list_all_records()
result = sorted(
(
(r["user_id"], r.get("prestige_level", 0), r.get("prestige_points", 0))
for r in records if r.get("user_id")
),
key=lambda x: (x[1], x[2]),
reverse=True,
)
return result if top_n is None else result[:top_n]
async def get_leaderboard_wagered(top_n: int | None = 10) -> list[tuple[str, int]]:
"""Return (user_id, total_wagered) sorted descending."""
records = await pb_client.list_all_records()
result = sorted(
((r["user_id"], r.get("total_wagered", 0)) for r in records if r.get("user_id")),
key=lambda x: x[1],
reverse=True,
)
return result if top_n is None else result[:top_n]
async def get_leaderboard_fish(top_n: int | None = 10) -> list[tuple[str, int]]:
"""Return (user_id, total_fish_caught) sorted descending."""
records = await pb_client.list_all_records()
result = sorted(
((r["user_id"], r.get("total_fish_caught", 0)) for r in records if r.get("user_id")),
key=lambda x: x[1],
reverse=True,
)
return result if top_n is None else result[:top_n]
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,
}

81
core/economy/levels.py Normal file
View File

@@ -0,0 +1,81 @@
"""EXP, levels and vanity role thresholds."""
from __future__ import annotations
import math
from .store import _locked_by, _prestige_mult, get_user, _commit
from .consumables import exp_buff_mult
# ---------------------------------------------------------------------------
# EXP / Level system
# ---------------------------------------------------------------------------
# EXP awarded per successful action
EXP_REWARDS: dict[str, int] = {
"daily": 50,
"work": 25,
"beg": 5,
"crime_win": 15,
"rob_win": 15,
"gamble_win": 10,
"heist_win": 25,
}
def gamble_exp(bet: int) -> int:
"""Scale EXP for a gambling win by bet size.
Returns 0 for bets < 10 coins to close micro-bet EXP farming.
10-99 → 5, 100-999 → 10, 1 000-9 999 → 15, 10 000+ → 20, 100 000+ → 25 (cap).
"""
return min(25, max(0, int(math.log10(max(1, bet))) * 5))
ECONOMY_ROLE = "ECONOMY"
# Vanity role milestones: (min_level, role_name) - highest first
LEVEL_ROLES: list[tuple[int, str]] = [
(30, "TipiLEGEND"),
(20, "TipiCHAD"),
(10, "TipiHUSTLER"),
(5, "TipiGRINDER"),
(1, "TipiNOOB"),
]
def get_level(exp: int) -> int:
"""Level = max(1, floor(sqrt(exp/10))).
Level 5 @ 250 EXP, 10 @ 1000, 20 @ 4000, 30 @ 9000."""
return max(1, int(math.sqrt(max(0, exp) / 10)))
def exp_for_level(level: int) -> int:
"""Minimum cumulative EXP to reach this level.
Recurrence: exp_for_level(L) = L*20 - 10 + exp_for_level(L-1), base 0.
Closed form: 10*level^2."""
if level <= 1:
return 0
return 10 * level * level
def level_role_name(level: int) -> str:
"""Return the vanity role name for a given level."""
for threshold, name in LEVEL_ROLES:
if level >= threshold:
return name
return LEVEL_ROLES[-1][1]
@_locked_by(0)
async def award_exp(user_id: int, amount: int) -> dict:
"""Add EXP to a user. Applies prestige exp_mult. Returns old_level, new_level, total exp."""
user = await get_user(user_id)
_, exp_mult = _prestige_mult(user)
gained = max(1, int(amount * exp_mult * exp_buff_mult(user)))
old_exp = user.get("exp", 0)
new_exp = old_exp + gained
old_level = get_level(old_exp)
new_level = get_level(new_exp)
user["exp"] = new_exp
user["season_total_exp"] = user.get("season_total_exp", 0) + gained
await _commit(user_id, user)
return {"old_level": old_level, "new_level": new_level, "exp": new_exp, "gained": gained}

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,
}

106
core/economy/prestige.py Normal file
View File

@@ -0,0 +1,106 @@
"""Prestige resets and the prestige upgrade shop."""
from __future__ import annotations
from ..pb_client import DatabaseError
from ..emoji import EMOJI as E
from .store import PRESTIGE_SHOP, _commit, _locked_by, _txn, get_user
from .levels import get_level
PRESTIGE_ROLE = "TipiPRESTIGE"
PRESTIGE_MIN_LEVEL = 30 # minimum level required to prestige
# ---------------------------------------------------------------------------
# /prestige
# ---------------------------------------------------------------------------
@_locked_by(0)
async def do_prestige(user_id: int) -> dict:
"""Prestige: requires level 30, earns PP, resets balance/exp/items/cooldowns."""
try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
exp = user.get("exp", 0)
level = get_level(exp)
if level < PRESTIGE_MIN_LEVEL:
return {"ok": False, "reason": "level_too_low", "level": level, "required": PRESTIGE_MIN_LEVEL}
pp_earned = max(1, exp // 1000)
new_prestige_level = user.get("prestige_level", 0) + 1
# Preserve: fish_book, fish_inventory, lifetime stats, prestige_points, season_total_exp, prestige_upgrades
user["balance"] = 0
user["exp"] = 0
user["items"] = []
user["item_uses"] = {}
user["last_daily"] = None
user["last_work"] = None
user["last_beg"] = None
user["last_crime"] = None
user["last_rob"] = None
user["last_fish"] = None
user["last_heist"] = None
user["daily_streak"] = 0
user["last_streak_date"] = None
user["jailed_until"] = None
user["jailbreak_used"] = False
user["prestige_level"] = new_prestige_level
user["prestige_points"] = user.get("prestige_points", 0) + pp_earned
await _commit(user_id, user)
_txn("PRESTIGE", user=user_id, pp_earned=pp_earned, prestige=new_prestige_level, old_exp=exp)
return {
"ok": True,
"pp_earned": pp_earned,
"prestige_level": new_prestige_level,
"prestige_points": user["prestige_points"],
"old_exp": exp,
}
@_locked_by(0)
async def do_prestige_buy(user_id: int, upgrade_id: str) -> dict:
"""Spend PP to buy a prestige upgrade level."""
if upgrade_id not in PRESTIGE_SHOP:
return {"ok": False, "reason": "not_found"}
try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
upgrade = PRESTIGE_SHOP[upgrade_id]
upgrades: dict = user.get("prestige_upgrades") or {}
current_level = upgrades.get(upgrade_id, 0)
if current_level >= upgrade["max_level"]:
return {"ok": False, "reason": "maxed", "max": upgrade["max_level"]}
pp = user.get("prestige_points", 0)
cost = upgrade["pp_cost"]
if pp < cost:
return {"ok": False, "reason": "insufficient_pp", "have": pp, "need": cost}
upgrades[upgrade_id] = current_level + 1
user["prestige_upgrades"] = upgrades
user["prestige_points"] = pp - cost
await _commit(user_id, user)
_txn("PRESTIGE_BUY", user=user_id, upgrade=upgrade_id,
new_level=upgrades[upgrade_id], pp_left=user["prestige_points"])
return {
"ok": True,
"upgrade_id": upgrade_id,
"new_level": upgrades[upgrade_id],
"max_level": upgrade["max_level"],
"pp_remaining": user["prestige_points"],
}

159
core/economy/quests.py Normal file
View File

@@ -0,0 +1,159 @@
"""Daily/weekly quests tracked from lifetime counters."""
from __future__ import annotations
import random
from typing import TypedDict
from .store import (
UserData, _commit, _locked_by, _log, _now, _prestige_mult, get_user,
)
# ---------------------------------------------------------------------------
# Quest system
# ---------------------------------------------------------------------------
# Quests reuse the monotonic lifetime counters already tracked on each user.
# Progress = (current counter value) - (snapshot taken when the quest was rolled).
# Reset is lazy & per-user: the active set is regenerated the first time a user
# interacts after the day / ISO-week rolls over (mirrors the streak-date logic).
# Rotation is seeded by user id + period key, so each player gets their own set.
class QuestDef(TypedDict):
stat: str # UserData counter field the quest tracks
goal: int
coins: int
exp: int
QUESTS_DAILY: dict[str, QuestDef] = {
"work3": {"stat": "work_count", "goal": 3, "coins": 150, "exp": 20},
"beg5": {"stat": "beg_count", "goal": 5, "coins": 100, "exp": 15},
"wager500": {"stat": "total_wagered", "goal": 500, "coins": 150, "exp": 20},
"fish2": {"stat": "total_fish_caught", "goal": 2, "coins": 150, "exp": 20},
"crime1": {"stat": "crimes_succeeded", "goal": 1, "coins": 200, "exp": 25},
"earn1000": {"stat": "lifetime_earned", "goal": 1000, "coins": 150, "exp": 20},
"give200": {"stat": "total_given", "goal": 200, "coins": 250, "exp": 20},
}
QUESTS_WEEKLY: dict[str, QuestDef] = {
"work20": {"stat": "work_count", "goal": 20, "coins": 1000, "exp": 100},
"fish15": {"stat": "total_fish_caught", "goal": 15, "coins": 1200, "exp": 100},
"wager5000": {"stat": "total_wagered", "goal": 5000, "coins": 1000, "exp": 100},
"crime5": {"stat": "crimes_succeeded", "goal": 5, "coins": 1200, "exp": 120},
"heist1": {"stat": "heists_joined", "goal": 1, "coins": 800, "exp": 80},
"earn10000": {"stat": "lifetime_earned", "goal": 10000, "coins": 1500, "exp": 150},
}
DAILY_QUEST_COUNT = 3
WEEKLY_QUEST_COUNT = 2
def _period_keys() -> tuple[str, str]:
"""Return (day_key, week_key) for the current UTC time."""
today = _now().date()
iso = today.isocalendar()
return today.isoformat(), f"{iso[0]}-W{iso[1]:02d}"
def _pick_quests(pool: dict[str, QuestDef], count: int, seed: str) -> list[str]:
"""Deterministically choose `count` quest ids from `pool` for a period."""
rng = random.Random(seed)
return rng.sample(sorted(pool.keys()), min(count, len(pool)))
def _new_quest_block(
user: UserData, user_id: int, pool: dict[str, QuestDef], count: int,
period_val: str, period_field: str
) -> dict:
chosen = _pick_quests(pool, count, f"{user_id}:{period_field}:{period_val}")
return {
period_field: period_val,
"quests": {
qid: {"snap": int(user.get(pool[qid]["stat"], 0) or 0), "claimed": False}
for qid in chosen
},
}
def _ensure_quests(user: UserData, user_id: int) -> bool:
"""Roll fresh daily/weekly quest sets if their period elapsed.
Mutates `user` in place; returns True if anything changed (caller commits)."""
changed = False
day_key, week_key = _period_keys()
if (user.get("quest_daily") or {}).get("date") != day_key:
user["quest_daily"] = _new_quest_block(user, user_id, QUESTS_DAILY, DAILY_QUEST_COUNT, day_key, "date")
changed = True
if (user.get("quest_weekly") or {}).get("week") != week_key:
user["quest_weekly"] = _new_quest_block(user, user_id, QUESTS_WEEKLY, WEEKLY_QUEST_COUNT, week_key, "week")
changed = True
return changed
def _quest_progress(user: UserData, pool: dict[str, QuestDef], qid: str, state: dict) -> int:
cur = int(user.get(pool[qid]["stat"], 0) or 0)
return max(0, cur - int(state.get("snap", 0)))
def _quest_view(user: UserData) -> dict:
def build(pool: dict[str, QuestDef], block: dict) -> list[dict]:
out: list[dict] = []
for qid, state in (block.get("quests") or {}).items():
if qid not in pool:
continue
d = pool[qid]
prog = min(d["goal"], _quest_progress(user, pool, qid, state))
out.append({
"id": qid, "goal": d["goal"], "coins": d["coins"], "exp": d["exp"],
"progress": prog, "done": prog >= d["goal"], "claimed": bool(state.get("claimed")),
})
return out
return {
"daily": build(QUESTS_DAILY, user.get("quest_daily") or {}),
"weekly": build(QUESTS_WEEKLY, user.get("quest_weekly") or {}),
}
@_locked_by(0)
async def get_quests(user_id: int) -> dict:
"""Return the user's active quests, rolling new sets if the period elapsed."""
user = await get_user(user_id)
if _ensure_quests(user, user_id):
saved = await _commit(user_id, user)
if saved is not None and "quest_daily" not in saved:
_log.warning(
"PocketBase collection has no quest fields - quest state is not "
"persisted and progress will stay at 0. Run scripts/add_quest_fields.py."
)
return _quest_view(user)
@_locked_by(0)
async def claim_quests(user_id: int) -> dict:
"""Grant coins for every completed-but-unclaimed quest and mark them claimed.
Coins (with prestige coin_mult) are paid here; EXP is returned raw for the
caller to award via the shared award_exp path (keeps level-up notices)."""
user = await get_user(user_id)
_ensure_quests(user, user_id)
coin_mult, _ = _prestige_mult(user)
total_coins = total_exp = claimed = 0
for pool, block in (
(QUESTS_DAILY, user.get("quest_daily") or {}),
(QUESTS_WEEKLY, user.get("quest_weekly") or {}),
):
for qid, state in (block.get("quests") or {}).items():
if qid not in pool or state.get("claimed"):
continue
if _quest_progress(user, pool, qid, state) < pool[qid]["goal"]:
continue
total_coins += pool[qid]["coins"]
total_exp += pool[qid]["exp"]
state["claimed"] = True
claimed += 1
if not claimed:
return {"ok": False, "reason": "nothing"}
coins_awarded = int(total_coins * coin_mult)
user["balance"] += coins_awarded
user["lifetime_earned"] = user.get("lifetime_earned", 0) + coins_awarded
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
await _commit(user_id, user)
return {"ok": True, "claimed": claimed, "coins": coins_awarded, "exp": total_exp, "balance": user["balance"]}

208
core/economy/shop.py Normal file
View File

@@ -0,0 +1,208 @@
"""Shop catalogue and purchases."""
from __future__ import annotations
from typing import TypedDict
import strings
from ..pb_client import DatabaseError
from ..emoji import EMOJI as E
from .store import _locked_by, _txn, get_user, _commit
from .levels import get_level
# ---------------------------------------------------------------------------
# Shop catalogue
# ---------------------------------------------------------------------------
class ShopItem(TypedDict):
name: str
emoji: str
cost: int
description: str
SHOP: dict[str, ShopItem] = {
"gaming_hiir": {
"name": "Mängurihiir",
"emoji": E["TipiHIIR"],
"cost": 500,
"description": strings.ITEM_DESCRIPTIONS["gaming_hiir"],
},
"hiirematt": {
"name": "Hiirematt",
"emoji": E["TipiMATT"],
"cost": 600,
"description": strings.ITEM_DESCRIPTIONS["hiirematt"],
},
"korvaklapid": {
"name": "K\u00f5rvaklapid",
"emoji": E["TipiKLAPID"],
"cost": 1200,
"description": strings.ITEM_DESCRIPTIONS["korvaklapid"],
},
"lan_pass": {
"name": "LAN pilet",
"emoji": E["TipiPILET"],
"cost": 1200,
"description": strings.ITEM_DESCRIPTIONS["lan_pass"],
},
"energiajook": {
"name": "Red Bull",
"emoji": E["TipiBULL"],
"cost": 800,
"description": strings.ITEM_DESCRIPTIONS["energiajook"],
},
"gaming_laptop": {
"name": "Bot Farm",
"emoji": E["TipiLAP"],
"cost": 1500,
"description": strings.ITEM_DESCRIPTIONS["gaming_laptop"],
},
"anticheat": {
"name": "Anticheat",
"emoji": E["TipiVAC"],
"cost": 1000,
"description": strings.ITEM_DESCRIPTIONS["anticheat"],
},
# ----- Tier 2 -----
"reguleeritav_laud": {
"name": "Reguleeritav laud",
"emoji": E["TipiLAUD"],
"cost": 3500,
"description": strings.ITEM_DESCRIPTIONS["reguleeritav_laud"],
},
"jellyfin": {
"name": "Jellyfin server",
"emoji": E["TipiSERVER"],
"cost": 4000,
"description": strings.ITEM_DESCRIPTIONS["jellyfin"],
},
"mikrofon": {
"name": "Eraldiseisev mikrofon",
"emoji": E["TipiMIC"],
"cost": 2800,
"description": strings.ITEM_DESCRIPTIONS["mikrofon"],
},
"klaviatuur": {
"name": "Mehaaniline klaviatuur",
"emoji": E["TipiKLAVA"],
"cost": 1800,
"description": strings.ITEM_DESCRIPTIONS["klaviatuur"],
},
"monitor": {
"name": "Ultralai monitor",
"emoji": E["TipiMONITOR"],
"cost": 2500,
"description": strings.ITEM_DESCRIPTIONS["monitor"],
},
"cat6": {
"name": "Cat6 kaabel",
"emoji": E["TipiCAT"],
"cost": 3500,
"description": strings.ITEM_DESCRIPTIONS["cat6"],
},
# ----- Tier 3 -----
"monitor_360": {
"name": "360Hz monitor",
"emoji": E["TipiMONITOR2"],
"cost": 7500,
"description": strings.ITEM_DESCRIPTIONS["monitor_360"],
},
"karikas": {
"name": "TipiLAN karikas",
"emoji": E["TipiKARIKAS"],
"cost": 6000,
"description": strings.ITEM_DESCRIPTIONS["karikas"],
},
"gaming_tool": {
"name": "Gaming tool",
"emoji": E["TipiTOOL"],
"cost": 9000,
"description": strings.ITEM_DESCRIPTIONS["gaming_tool"],
},
# ----- Fishing items -----
"ussipurk": {
"name": "Ussipurk",
"emoji": "🪣",
"cost": 3500,
"description": strings.ITEM_DESCRIPTIONS["ussipurk"],
},
"kalavork": {
"name": "Kalavõrk",
"emoji": "🪝",
"cost": 5000,
"description": strings.ITEM_DESCRIPTIONS["kalavork"],
},
"echolood": {
"name": "Echolood",
"emoji": "📡",
"cost": 8000,
"description": strings.ITEM_DESCRIPTIONS["echolood"],
},
}
# Tier grouping (used by /shop pagination)
SHOP_TIERS: dict[int, list[str]] = {
1: ["gaming_hiir", "hiirematt", "korvaklapid", "lan_pass", "energiajook", "anticheat", "gaming_laptop"],
2: ["reguleeritav_laud", "jellyfin", "mikrofon", "klaviatuur", "monitor", "cat6", "ussipurk"],
3: ["monitor_360", "karikas", "gaming_tool", "kalavork", "echolood"],
}
# Minimum level required to purchase Tier 2 / Tier 3 shop items
SHOP_LEVEL_REQ: dict[str, int] = {
"reguleeritav_laud": 10,
"jellyfin": 10,
"mikrofon": 10,
"klaviatuur": 10,
"monitor": 10,
"cat6": 10,
"ussipurk": 10,
"monitor_360": 20,
"karikas": 20,
"gaming_tool": 20,
"kalavork": 20,
"echolood": 20,
}
# ---------------------------------------------------------------------------
# /buy
# ---------------------------------------------------------------------------
@_locked_by(0)
async def do_buy(user_id: int, item_id: str) -> dict:
if item_id not in SHOP:
return {"ok": False, "reason": "not_found"}
try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
item = SHOP[item_id]
if item_id in user["items"]:
# Allow repurchase of anticheat if uses are depleted
if item_id == "anticheat" and user.get("item_uses", {}).get("anticheat", 2) <= 0:
user["items"] = [i for i in user["items"] if i != "anticheat"]
user.get("item_uses", {}).pop("anticheat", None)
else:
return {"ok": False, "reason": "owned"}
min_level = SHOP_LEVEL_REQ.get(item_id, 0)
if min_level > 0:
user_level = get_level(user.get("exp", 0))
if user_level < min_level:
return {"ok": False, "reason": "level_required", "min_level": min_level, "user_level": user_level}
if user["balance"] < item["cost"]:
return {"ok": False, "reason": "insufficient", "need": item["cost"] - user["balance"]}
user["balance"] -= item["cost"]
user["items"].append(item_id)
if item_id == "anticheat":
if "item_uses" not in user:
user["item_uses"] = {}
user["item_uses"]["anticheat"] = 2
await _commit(user_id, user)
_txn("BUY", user=user_id, item=item_id, cost=f"-{item['cost']}", bal=user["balance"])
return {"ok": True, "item": item, "balance": user["balance"]}

480
core/economy/store.py Normal file
View File

@@ -0,0 +1,480 @@
"""Shared foundation: user records, locks, time, cooldowns, txn log."""
from __future__ import annotations
import asyncio
import functools
import logging
from datetime import datetime, timedelta, timezone
from typing import TypedDict
import aiohttp
from .. import pb_client
from ..pb_client import DatabaseError
from ..emoji import EMOJI as E
def _clock() -> datetime:
"""Actual time source - a seam so tests can freeze time everywhere at once."""
return datetime.now(tz=timezone.utc)
def _now() -> datetime:
return _clock()
_txn_log = logging.getLogger("tipiCOIN.txn")
def _txn(event: str, **fields) -> None:
"""Log a single economy transaction to the transactions logger."""
body = " ".join(f"{k}={v}" for k, v in fields.items())
_txn_log.info("%-16s %s", event, body)
# Per-profile emoji values live in core/emoji.py; add new IDs there.
COIN = E["TipiCOIN"]
PP_EMOJI = E["TipiFIRE"]
# ---------------------------------------------------------------------------
# Prestige shop catalogue
# ---------------------------------------------------------------------------
class PrestigeItem(TypedDict):
emoji: str
max_level: int
pp_cost: int
effect: float
PRESTIGE_SHOP: dict[str, PrestigeItem] = {
"coin_mult": {
"emoji": E["TipiCOIN"],
"max_level": 5,
"pp_cost": 5,
"effect": 0.08,
},
"exp_mult": {
"emoji": "",
"max_level": 5,
"pp_cost": 5,
"effect": 0.08,
},
"daily_plus": {
"emoji": "📅",
"max_level": 3,
"pp_cost": 7,
"effect": 0.20,
},
"work_plus": {
"emoji": "💼",
"max_level": 3,
"pp_cost": 7,
"effect": 0.20,
},
}
# ---------------------------------------------------------------------------
# Cooldowns
# ---------------------------------------------------------------------------
COOLDOWNS: dict[str, timedelta] = {
"daily": timedelta(hours=20),
"work": timedelta(hours=1),
"beg": timedelta(minutes=5),
"crime": timedelta(hours=2),
"rob": timedelta(hours=2),
"fish": timedelta(minutes=2),
}
# 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)
# ---------------------------------------------------------------------------
# User schema
# ---------------------------------------------------------------------------
class UserData(TypedDict, total=False):
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
last_beg: str | None
last_crime: str | None
last_rob: str | None
last_heist: str | None
daily_streak: int
last_streak_date: str | None # ISO date "YYYY-MM-DD"
items: list[str]
item_uses: dict # {item_id: remaining_uses} for consumables
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
eco_banned: bool # if True, user cannot use any economy commands
# Lifetime statistics
peak_balance: int
lifetime_earned: int
lifetime_lost: int
work_count: int
beg_count: int
total_wagered: int
biggest_win: int
biggest_loss: int
slots_jackpots: int
crimes_attempted: int
crimes_succeeded: int
times_jailed: int
total_bail_paid: int
heists_joined: int
heists_won: int
total_given: int
total_received: int
best_daily_streak: int
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
prestige_points: int
season_total_exp: int # cumulative EXP this season (survives prestige resets)
prestige_upgrades: dict # {upgrade_id: level}
# Fishing system
last_fish: str | None
fish_book: dict # {fish_id: times_caught}
total_fish_caught: int
fish_inventory: list # [{fish_id, weight, value}] - survives prestige
# Quest system
quest_daily: dict # {"date": "YYYY-MM-DD", "quests": {qid: {snap, claimed}}}
quest_weekly: dict # {"week": "YYYY-Www", "quests": {qid: {snap, claimed}}}
# 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,
"last_beg": None,
"last_crime": None,
"last_rob": None,
"last_heist": None,
"daily_streak": 0,
"last_streak_date": None,
"items": [],
"item_uses": {},
"active_buffs": {},
"vanity_owned": [],
"vanity_active": None,
"jailed_until": None,
"jailbreak_used": False,
"reminders": ["daily", "work", "beg", "crime", "rob"],
"eco_banned": False,
# ── Lifetime stats ──────────────────────────────────────────────────
"peak_balance": 0,
"lifetime_earned": 0,
"lifetime_lost": 0,
"work_count": 0,
"beg_count": 0,
"total_wagered": 0,
"biggest_win": 0,
"biggest_loss": 0,
"slots_jackpots": 0,
"crimes_attempted": 0,
"crimes_succeeded": 0,
"times_jailed": 0,
"total_bail_paid": 0,
"heists_joined": 0,
"heists_won": 0,
"total_given": 0,
"total_received": 0,
"best_daily_streak": 0,
"lootboxes_opened": 0,
"achievements_earned": [],
"lottery_tickets": 0,
"lottery_period": None,
"heist_global_cd_until": 0.0,
# ── Prestige ─────────────────────────────────────────────────────────
"prestige_level": 0,
"prestige_points": 0,
"season_total_exp": 0,
"prestige_upgrades": {},
# ── Fishing ──────────────────────────────────────────────────────────
"last_fish": None,
"fish_book": {},
"total_fish_caught": 0,
"fish_inventory": [],
# ── Quests ───────────────────────────────────────────────────────────
"quest_daily": {},
"quest_weekly": {},
# ── Interactive-game escrow (blackjack / RPS PvP) ────────────────────
"pending_wager": {},
}
# ---------------------------------------------------------------------------
# Persistence (PocketBase backend)
# ---------------------------------------------------------------------------
_log = logging.getLogger("tipiCOIN.economy")
# ---------------------------------------------------------------------------
# Per-user write locks
# ---------------------------------------------------------------------------
# Every mutation is a read-modify-write cycle (get_user → mutate → _commit);
# without serialization, two concurrent commands for the same user overwrite
# each other's commit. Locking rules that keep this deadlock-free:
# - a decorated function must never call another decorated function
# - house balance changes go through _credit_house (an atomic PocketBase
# increment, no lock), so they are safe while holding user locks
_user_locks: dict[int, asyncio.Lock] = {}
def _user_lock(user_id: int) -> asyncio.Lock:
lock = _user_locks.get(user_id)
if lock is None:
lock = _user_locks[user_id] = asyncio.Lock()
return lock
def _locked_by(*arg_positions: int):
"""Serialize the decorated function per user id found at the given
positional-argument indices. Multiple ids are acquired in sorted order so
two-user functions (do_give, do_rob) cannot deadlock each other."""
def decorator(fn):
@functools.wraps(fn)
async def wrapper(*args, **kwargs):
locks = [_user_lock(uid) for uid in sorted({args[pos] for pos in arg_positions})]
for lock in locks:
await lock.acquire()
try:
return await fn(*args, **kwargs)
finally:
for lock in reversed(locks):
lock.release()
return wrapper
return decorator
# ---------------------------------------------------------------------------
# Public helpers
# ---------------------------------------------------------------------------
async def missing_schema_fields() -> list[str]:
"""Compare the live PocketBase collection schema against every field the
bot persists. PocketBase silently drops writes to undeclared fields, so
any name returned here means broken features without error messages."""
live = await pb_client.get_collection_fields()
expected = set(_default_user()) | {"user_id"}
return sorted(expected - live)
async def get_all_users_raw() -> dict[str, "UserData"]:
"""Return a snapshot of all user records."""
records = await pb_client.list_all_records()
result: dict[str, UserData] = {}
for record in records:
uid = record.get("user_id", "")
if not uid:
continue
user = _default_user()
for key in list(user.keys()):
if key in record:
user[key] = record[key] # type: ignore[literal-required]
user["_pb_id"] = record["id"] # type: ignore[typeddict-unknown-key]
result[uid] = user
return result
def _parse_dt(s: str | None) -> datetime | None:
if not s:
return None
dt = datetime.fromisoformat(s)
# Ensure timezone-aware
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
def _cooldown_remaining(
user: UserData, action: str, override_cd: timedelta | None = None
) -> timedelta | None:
"""Return remaining cooldown, or None if the action is ready."""
last = _parse_dt(user.get(f"last_{action}"))
if last is None:
return None
cd = override_cd if override_cd is not None else COOLDOWNS[action]
remaining = cd - (_now() - last)
return remaining if remaining.total_seconds() > 0 else None
def _is_jailed(user: UserData) -> timedelta | None:
"""Return remaining jail time, or None if free."""
until = _parse_dt(user.get("jailed_until"))
if until is None:
return None
remaining = until - _now()
return remaining if remaining.total_seconds() > 0 else None
def jailed_remaining(user: UserData) -> timedelta | None:
"""Public wrapper - return remaining jail time, or None if free."""
return _is_jailed(user)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def format_td(td: timedelta) -> str:
"""Human-readable timedelta: '1t 23m' / '45m 12s' / '8s'."""
total = int(td.total_seconds())
h, rem = divmod(total, 3600)
m, s = divmod(rem, 60)
if h:
return f"{h}t {m}m"
if m:
return f"{m}m {s}s"
return f"{s}s"
async def get_user(user_id: int) -> UserData:
"""Fetch user data from PocketBase, creating a default record if first seen."""
uid = str(user_id)
try:
record = await pb_client.get_record(uid)
if record is None:
default = _default_user()
default["user_id"] = uid # type: ignore[typeddict-unknown-key]
record = await pb_client.create_record(default)
except (aiohttp.ClientError, asyncio.TimeoutError, RuntimeError) as exc:
_log.error("PocketBase unreachable for user %s: %s", user_id, exc)
raise DatabaseError(f"Database unavailable: {exc}") from exc
user = _default_user()
for key in list(user.keys()):
if key in record:
user[key] = record[key] # type: ignore[literal-required]
user["_pb_id"] = record["id"] # type: ignore[typeddict-unknown-key]
return user
def _prestige_mult(user: UserData) -> tuple[float, float]:
"""Return (coin_mult, exp_mult) based on prestige upgrades. Both ≥1.0."""
upgrades: dict = user.get("prestige_upgrades") or {} # type: ignore[assignment]
coin_level = upgrades.get("coin_mult", 0)
exp_level = upgrades.get("exp_mult", 0)
return (
1.0 + coin_level * PRESTIGE_SHOP["coin_mult"]["effect"],
1.0 + exp_level * PRESTIGE_SHOP["exp_mult"]["effect"],
)
# ---------------------------------------------------------------------------
# Internal write helper
# ---------------------------------------------------------------------------
async def _commit(user_id: int, user: UserData) -> dict | None:
"""Persist the full user record. Returns the record as PocketBase stored it
(fields absent from the collection schema are silently dropped by PB)."""
record_id = user.get("_pb_id") # type: ignore[typeddict-item]
clean = {k: v for k, v in user.items() if k != "_pb_id"}
clean["user_id"] = str(user_id)
try:
if record_id:
return await pb_client.update_record(record_id, clean)
else:
_log.warning("_commit for user %s had no _pb_id; creating new record", user_id)
created = await pb_client.create_record(clean)
user["_pb_id"] = created["id"] # type: ignore[typeddict-unknown-key]
return created
except (aiohttp.ClientError, asyncio.TimeoutError, RuntimeError) as exc:
_log.error("_commit failed for user %s: %s", user_id, exc)
raise DatabaseError(f"Failed to persist user {user_id}: {exc}") from exc
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
@_locked_by(0)
async def do_set_reminders(user_id: int, commands: list[str]) -> None:
"""Overwrite the user's reminder list with the given command names."""
user = await get_user(user_id)
user["reminders"] = list(commands)
await _commit(user_id, user)

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"]}

97
core/emoji.py Normal file
View File

@@ -0,0 +1,97 @@
"""Custom Discord emoji registry, keyed by symbolic name and resolved per BOT_PROFILE.
Emojis are uploaded as application emojis via the Discord Developer Portal
and are scoped to a single bot application. Dev and production are separate
applications, so the same logical emoji has a different ID in each — hence
two dicts below, selected by BOT_PROFILE.
To add a new emoji: upload it to both applications in the dev portal, grab
the two IDs, and add one line to each dict.
"""
from __future__ import annotations
from config import BOT_PROFILE
_DEV: dict[str, str] = {
"TipiCOIN": "<:TipiCOIN:1483000209188589628>",
"TipiFIRE": "<:TipiFIRE:1483431381668335687>",
"TipiHIIR": "<:TipiHIIR:1483004306012504128>",
"TipiMATT": "<:TipiMATT:1483387697132208128>",
"TipiKLAPID": "<:TipiKLAPID:1483387694083084349>",
"TipiPILET": "<:TipiPILET:1483004308353060904>",
"TipiBULL": "<:TipiBULL:1483004310924300409>",
"TipiLAP": "<:TipiLAP:1483004307161874566>",
"TipiVAC": "<:TipiVAC:1483004309510819860>",
"TipiLAUD": "<:TipiLAUD:1483387695576125440>",
"TipiSERVER": "<:TipiSERVER:1483387701032910969>",
"TipiMIC": "<:TipiMIC:1483387698499551313>",
"TipiKLAVA": "<:TipiKLAVA:1483014339228078140>",
"TipiMONITOR": "<:TipiMONITOR:1483014340327243908>",
"TipiCAT": "<:TipiCAT:1483014337663602718>",
"TipiMONITOR2": "<:TipiMONITOR2:1483387699514839162>",
"TipiKARIKAS": "<:TipiKARIKAS:1483014841148112977>",
"TipiTOOL": "<:TipiTOOL:1483014341648187613>",
"TipiHEART": "<:TipiHEART:1483431377561976853>",
"TipiTROLL": "<:TipiTROLL:1483431380166774895>",
"TipICRY": "<:TipICRY:1483431288852709387>",
"TipiSKULL": "<:TipiSKULL:1483431378929451028>",
"TipiDICE": "<a:TipiDICE:1485923107108556950>",
"TipiYKS": "<:TipiYKS:1483103190491856916>",
"TipiKAKS": "<:TipiKAKS:1483103215841972404>",
"TipiKOLM": "<:TipiKOLM:1483103217846980781>",
"TipiNELI": "<:TipiNELI:1483103237585240114>",
"TipiVIIS": "<:TipiVIIS:1483103239036469289>",
"TipiKUUS": "<:TipiKUUS:1483103253163020348>",
"TipiSLOTS": "<a:TipiSLOTS:1483444233863037101>",
}
# Production application emoji IDs (from the TipiBOT application's dev-portal
# Emojis tab). The display name in <:name:id> is cosmetic — Discord resolves
# by ID — so we keep the same logical names as _DEV even when the prod portal
# uses a different upload name (e.g. prod's "TipiFIVE" → key "TipiVIIS").
_ECONOMY: dict[str, str] = {
"TipiCOIN": "<:TipiCOIN:1511754551747940485>",
"TipiFIRE": "<:TipiFIRE:1511754615761272862>",
"TipiHIIR": "<:TipiHIIR:1511754556105822218>",
"TipiMATT": "<:TipiMATT:1511754595448131665>",
"TipiKLAPID": "<:TipiKLAPID:1511754589798666433>",
"TipiPILET": "<:TipiPILET:1511754560593727652>",
"TipiBULL": "<:TipiBULL:1511754564179595264>",
"TipiLAP": "<:TipiLAP:1511754558970269907>",
"TipiVAC": "<:TipiVAC:1511754562413924442>",
"TipiLAUD": "<:TipiLAUD:1511754592759713985>",
"TipiSERVER": "<:TipiSERVER:1511754601186066534>",
"TipiMIC": "<:TipiMIC:1511754597016801310>",
"TipiKLAVA": "<:TipiKLAVA:1511754567648542780>",
"TipiMONITOR": "<:TipiMONITOR:1511754570722971868>",
"TipiCAT": "<:TipiCAT:1511754566092193853>",
"TipiMONITOR2": "<:TipiMONITOR2:1511754598732402689>",
"TipiKARIKAS": "<:TipiKARIKAS:1511754574405435502>",
"TipiTOOL": "<:TipiTOOL:1511754572522061874>",
"TipiHEART": "<:TipiHEART:1511754608299737139>",
"TipiTROLL": "<:TipiTROLL:1511754612775063832>",
"TipICRY": "<:TipICRY:1511754603308515368>",
"TipiSKULL": "<:TipiSKULL:1511754610195566622>",
"TipiDICE": "<a:TipiDICE:1511753607119376504>",
"TipiYKS": "<:TipiYKS:1511754576368373951>",
"TipiKAKS": "<:TipiKAKS:1511754577928523997>",
"TipiKOLM": "<:TipiKOLM:1511754581078442005>",
"TipiNELI": "<:TipiNELI:1511754582571880509>",
"TipiVIIS": "<:TipiVIIS:1511754584182227005>",
"TipiKUUS": "<:TipiKUUS:1511754586262736977>",
"TipiSLOTS": "<a:TipiSLOTS:1511754521188106431>",
}
_EMOJI_SETS = {
"dev": _DEV,
"economy": _ECONOMY,
}
EMOJI: dict[str, str] = _EMOJI_SETS[BOT_PROFILE]
_missing = set(_DEV) - set(EMOJI)
if _missing:
raise RuntimeError(f"Emoji set {BOT_PROFILE!r} missing keys: {sorted(_missing)}")

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")
@@ -48,7 +71,6 @@ class SyncResult:
"""Tracks what happened during a sync operation."""
nickname_changed: bool = False
roles_added: list[str] = field(default_factory=list)
roles_removed: list[str] = field(default_factory=list)
birthday_soon: bool = False
birthday_today: bool = False
not_found: bool = False
@@ -57,7 +79,36 @@ class SyncResult:
@property
def changed(self) -> bool:
return self.nickname_changed or self.roles_added or self.roles_removed
return self.nickname_changed or self.roles_added
@dataclass
class TeamSyncResult:
"""What happened when syncing one member's tournament team role."""
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 or self.divider_added or self.divider_removed)
@dataclass
class TeamSyncSummary:
"""Aggregate outcome of a whole-guild team-role sync."""
scanned: int = 0
assigned: int = 0
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)
def _format_nickname(full_name: str) -> str:
@@ -224,6 +275,272 @@ async def sync_member(
return result
async def sync_team_role(
member: discord.Member,
guild: discord.Guild,
) -> TeamSyncResult:
"""Give one member their tournament team role from the registration sheet.
Roster-INDEPENDENT: unlike :func:`sync_member` this does not touch the
internal member sheet at all. It matches the member's Discord username
against the team sheet caches (populated by ``sheets.refresh_teams``) and:
* 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);
* 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 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 = resolve_team(member)
all_teams = all_managed_team_names()
if not all_teams:
return result # feature switched off (no Fienta token and no team sheet)
desired: discord.Role | None = None
if team_name:
desired = discord.utils.get(guild.roles, name=team_name)
if desired is None:
try:
desired = await guild.create_role(name=team_name, reason="Team sync: uus tiim")
result.created = team_name
log.info("Created team role %r for %s", team_name, member)
except discord.Forbidden:
result.errors.append(f"Tiimirolli '{team_name}' loomiseks puudub õigus")
except discord.HTTPException as e:
result.errors.append(f"Tiimirolli '{team_name}' loomine ebaõnnestus: {e}")
# Team roles held but no longer registered for (switched teams / dropped out).
to_remove = [r for r in member.roles if r.name in all_teams and r.name != team_name]
if desired is not None and desired not in member.roles:
try:
await member.add_roles(desired, reason="Team sync")
result.added = desired.name
except discord.Forbidden:
log.debug("No permission to add team role for %s, skipping", member)
except discord.HTTPException as e:
result.errors.append(f"Tiimirolli viga kasutajale {member}: {e}")
if to_remove:
try:
await member.remove_roles(*to_remove, reason="Team sync: tiim vahetus")
result.removed = [r.name for r in to_remove]
except discord.Forbidden:
log.debug("No permission to remove team roles for %s, skipping", member)
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
def plan_team_positions(
ordered_names: list[str],
managed: set[str],
placements: dict[str, list[str]],
) -> dict[str, int]:
"""Work out the new position of every team role that needs to move.
``ordered_names`` is every role name ascending by Discord position, so index
0 is the bottom of the role list (@everyone) and the last entry is the top.
``placements`` maps a divider role name to the team roles that belong under
it. Roles in ``managed`` are never moved (integration-managed roles, and
anything at or above the bot's own role, cannot be repositioned).
Teams are pulled out of the list and re-inserted immediately below their
divider, sorted so they read alphabetically top-to-bottom in the Discord UI.
Everything else keeps its relative order; a role only appears in the result
when its position actually changed. Roles are matched by name, mirroring the
rest of the team sync - with duplicate role names the lowest one wins.
"""
present = set(ordered_names)
placeable: dict[str, list[str]] = {}
for divider, teams in placements.items():
if divider not in present or divider in managed:
continue
# Descending here because the list is bottom-up: reversing it renders
# alphabetically downwards from the divider.
block = sorted({t for t in teams if t in present and t not in managed}, reverse=True)
if block:
placeable[divider] = block
if not placeable:
return {}
movable = {t for block in placeable.values() for t in block}
remaining = [n for n in ordered_names if n not in movable]
for divider, block in placeable.items():
idx = remaining.index(divider)
remaining[idx:idx] = block
old_pos = {name: i for i, name in enumerate(ordered_names)}
return {
name: i
for i, name in enumerate(remaining)
if name not in managed and old_pos.get(name) != i
}
async def apply_team_role_positions(
guild: discord.Guild,
log: logging.Logger = log,
) -> tuple[int, list[str]]:
"""Move every team role directly beneath its configured divider role.
Driven by the ``TEAM_DIVIDER_*`` config: teams whose sheet section matched
one are placed under that divider role, the rest are left exactly where they
are. Dividers are configured by role ID, so a rename never breaks placement;
the ID is resolved to the role's current name here, and the ordering maths
downstream is name-based. Returns ``(roles_moved, errors)``; a no-op returns
``(0, [])``.
"""
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] = []
# Resolve each configured divider ID to its role once, then key placements by
# 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_divider_ids.items():
if divider_id not in resolved:
resolved[divider_id] = guild.get_role(divider_id)
if resolved[divider_id] is None:
errors.append(f"Eraldajarolli ID {divider_id} ei leitud serverist")
divider = resolved[divider_id]
if divider is not None:
placements.setdefault(divider.name, []).append(team)
if not placements:
return 0, errors
by_name: dict[str, discord.Role] = {}
for role in sorted(guild.roles, key=lambda r: r.position):
by_name.setdefault(role.name, role)
# The bot can only reorder roles strictly below its own highest role.
bot_top = max((r.position for r in guild.me.roles), default=0)
for divider in list(placements):
role = by_name.get(divider)
if role is None:
errors.append(f"Eraldajarolli '{divider}' ei leitud serverist")
placements.pop(divider)
elif role.position >= bot_top:
errors.append(f"Eraldaja '{divider}' on boti rollist kõrgemal - ei saa liigutada")
placements.pop(divider)
if not placements:
return 0, errors
ordered = sorted(guild.roles, key=lambda r: r.position)
ordered_names = [r.name for r in ordered]
managed = {r.name for r in ordered if r.managed or r.position >= bot_top}
plan = plan_team_positions(ordered_names, managed, placements)
if not plan:
return 0, errors # already in the right place
positions = {by_name[n]: p for n, p in plan.items() if n in by_name}
try:
await guild.edit_role_positions(positions=positions)
except discord.Forbidden:
errors.append("Tiimirollide järjestamiseks puudub õigus")
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
async def sync_all_team_roles(
guild: discord.Guild,
log: logging.Logger = log,
) -> TeamSyncSummary:
"""Run :func:`sync_team_role` for every human member of ``guild``.
Assumes the team caches are already fresh (caller runs ``refresh_teams``
first). Returns an aggregate summary for reporting.
"""
summary = TeamSyncSummary()
for member in guild.members:
if member.bot:
continue
summary.scanned += 1
res = await sync_team_role(member, guild)
if res.created:
summary.created.append(res.created)
if res.errors:
summary.errors.extend(res.errors)
if res.added:
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
# positioned in the same sweep rather than waiting for the next one.
summary.positioned, position_errors = await apply_team_role_positions(guild, log)
summary.errors.extend(position_errors)
return summary
async def announce_birthday(
member: discord.Member,
bot: discord.Client,

View File

@@ -147,6 +147,12 @@ async def update_record(record_id: str, data: dict[str, Any]) -> dict[str, Any]:
)
async def get_collection_fields() -> set[str]:
"""Return the field names defined on the economy collection's schema."""
data = await _request("GET", f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}")
return {f["name"] for f in data.get("fields", [])}
async def count_records() -> int:
"""Return the total number of records in the collection (single cheap request)."""
data = await _request(

View File

@@ -7,12 +7,17 @@ Pure-cache helpers (get_cache, find_*) remain sync.
"""
import asyncio
import logging
import re
from dataclasses import dataclass, field
import gspread
from google.oauth2.service_account import Credentials
import config
log = logging.getLogger(__name__)
# Scopes needed: read + write to Sheets
SCOPES = [
"https://www.googleapis.com/auth/spreadsheets",
@@ -35,6 +40,11 @@ SCOPES = [
# - Roll : role value - maps to a Discord role
# - Discordis synced? : TRUE/FALSE - bot writes this after confirming sync
# - Groupi lisatud? : group membership flag (managed externally)
#
# The owner adds/reorders columns freely (e.g. "Käepael" was inserted after
# "Nimi"), so NEVER derive a write position from EXPECTED_HEADERS - resolve it
# from the live header row via _col_index(). Positional writes used to land one
# column to the left and overwrote "Roll" with the TRUE/FALSE synced flag.
# ---------------------------------------------------------------------------
EXPECTED_HEADERS = [
@@ -57,6 +67,9 @@ _worksheet: gspread.Worksheet | None = None
# In-memory cache: list of dicts (one per row)
_cache: list[dict] = []
# Live header row (row 1) as last read from the sheet; source of truth for column positions
_headers: list[str] = []
def _get_worksheet() -> gspread.Worksheet:
"""Authenticate and return the first worksheet of the configured sheet."""
@@ -68,19 +81,44 @@ def _get_worksheet() -> gspread.Worksheet:
return _worksheet
def _ensure_headers(ws: gspread.Worksheet) -> None:
"""If the sheet is empty or missing headers, write them (headers are in row 1)."""
def _ensure_headers(ws: gspread.Worksheet) -> list[str]:
"""Read the live header row and warn if a column the bot relies on is missing.
The production sheet is owner-managed and its header row (row 1) is a
protected range, so the bot must NOT write to it — attempting to do so
raises `APIError [400]: You are trying to edit a protected cell or object`
and aborts the whole refresh. We only log a mismatch so it can be fixed
by hand. Extra or reordered columns are fine: reads map by header name and
writes resolve positions from the returned row.
"""
existing = ws.row_values(1)
if existing != EXPECTED_HEADERS:
for col_idx, header in enumerate(EXPECTED_HEADERS, start=1):
if col_idx > len(existing) or existing[col_idx - 1] != header:
ws.update_cell(1, col_idx, header)
missing = [h for h in EXPECTED_HEADERS if h not in existing]
if missing:
log.warning(
"Sheet header row is missing expected columns %s (found %s). "
"Not writing to the (protected) header row; fix it manually.",
missing,
existing,
)
return existing
def _col_index(ws: gspread.Worksheet, column_name: str) -> int | None:
"""1-based position of `column_name` in the live header row, or None if absent."""
global _headers
if not _headers:
_headers = _ensure_headers(ws)
try:
return _headers.index(column_name) + 1
except ValueError:
log.error("Column %r not in sheet header row; refusing to write", column_name)
return None
def _refresh_sync() -> list[dict]:
global _cache
global _cache, _headers
ws = _get_worksheet()
_ensure_headers(ws)
_headers = _ensure_headers(ws)
# head=1: row 1 is the header; row 2 is a formula/stats row - skip it
records = ws.get_all_records(head=1)
_cache = records[1:] # drop the formula row (row 2) from the cache
@@ -142,9 +180,8 @@ def _update_cell_for_member_sync(
if row_idx is None:
return False
try:
col_idx = EXPECTED_HEADERS.index(column_name) + 1
except ValueError:
col_idx = _col_index(ws, column_name)
if col_idx is None:
return False
ws.update([[value]], gspread.utils.rowcol_to_a1(row_idx, col_idx),
@@ -171,7 +208,9 @@ async def update_cell_for_member(
def _batch_set_synced_sync(updates: list[tuple[int, bool]]) -> None:
ws = _worksheet or _get_worksheet()
col_idx = EXPECTED_HEADERS.index("Discordis synced?") + 1
col_idx = _col_index(ws, "Discordis synced?")
if col_idx is None:
return
cells = []
for discord_id, synced in updates:
row_idx = _row_index_for_member(discord_id=discord_id)
@@ -222,15 +261,263 @@ async def update_username(discord_id: int, new_username: str) -> bool:
def _add_new_member_row_sync(username: str, discord_id: int) -> None:
ws = _worksheet or _get_worksheet()
row = [""] * len(EXPECTED_HEADERS)
row[EXPECTED_HEADERS.index("Discord")] = username
row[EXPECTED_HEADERS.index("User ID")] = str(discord_id)
row[EXPECTED_HEADERS.index("Discordis synced?")] = "FALSE"
values = {"Discord": username, "User ID": str(discord_id), "Discordis synced?": "FALSE"}
cols = {name: _col_index(ws, name) for name in values}
missing = [name for name, col in cols.items() if col is None]
if missing:
raise RuntimeError(f"Sheet header row is missing columns {missing}")
row = [""] * len(_headers)
for name, col in cols.items():
row[col - 1] = values[name]
ws.append_row(row, value_input_option="USER_ENTERED")
new_entry = {h: row[i] for i, h in enumerate(EXPECTED_HEADERS)}
_cache.append(new_entry)
_cache.append(dict(zip(_headers, row)))
async def add_new_member_row(username: str, discord_id: int) -> None:
"""Append a new row pre-filled with Discord username and User ID (non-blocking)."""
await asyncio.to_thread(_add_new_member_row_sync, username, discord_id)
# ===========================================================================
# Team registration sheet (a SEPARATE spreadsheet, config.TEAM_SHEET_ID)
# ---------------------------------------------------------------------------
# Unlike the member roster this sheet is NOT a single clean table: it stacks
# several game sections (CS2, LoL, ...) - each with merged title/description
# rows, its own header row, and a block of team rows - across one or more tabs.
# So we read raw cell values (get_all_values) and scan for header rows rather
# than relying on get_all_records, which requires one rectangular table.
#
# Each team's players live in one "Lineup" cell, comma-joined, every nickname
# suffixed with a citizenship marker like "(EST)". Those nicknames ARE the
# players' Discord usernames; the citizenship is used only as a delimiter
# (a nickname may itself contain commas) and then discarded.
# ===========================================================================
# Matches a citizenship marker such as "(EST)" / "(LAT)". Used to split a
# lineup cell into individual players, then thrown away.
_CITIZENSHIP_RE = re.compile(r"\(\s*[A-Za-z]{2,4}\s*\)")
_TEAM_NAME_HEADER = "team name"
_LINEUP_HEADER_PREFIX = "lineup"
# Team-sheet caches (mirrors the member-roster cache above)
_team_roster: dict[str, list[str]] = {} # team name -> [Discord username, ...]
_team_by_username: dict[str, str] = {} # normalized username -> team name
_team_names: set[str] = set() # universe of all team names
_team_divider: dict[str, int] = {} # team name -> divider role ID
@dataclass(frozen=True)
class TeamSection:
"""One game's block of teams within a tab, plus the title row above it.
The title ("TipiLAN 2026 CS2 Registration Log") is the only thing that says
which game and year a block belongs to - the header and team rows below it
carry neither - so it is what :func:`resolve_divider` matches against.
"""
title: str = ""
rosters: dict[str, list[str]] = field(default_factory=dict)
def parse_lineup(cell: str) -> list[str]:
"""Split one 'Lineup' cell into player nicknames (Discord usernames).
Players are delimited by their trailing citizenship marker, e.g.
'TFT (EST), nqmm (EST), sn1rk (EST)' -> ['TFT', 'nqmm', 'sn1rk']
Splitting on the marker (not on commas) keeps nicknames that themselves
contain commas or semicolons intact. Falls back to comma-splitting when a
cell carries no citizenship markers at all.
"""
cell = str(cell).strip()
if not cell:
return []
names: list[str] = []
last = 0
matched = False
for m in _CITIZENSHIP_RE.finditer(cell):
matched = True
chunk = cell[last:m.start()].strip().strip(",;").strip()
if chunk:
names.append(chunk)
last = m.end()
if not matched:
return [p.strip() for p in cell.split(",") if p.strip()]
tail = cell[last:].strip().strip(",;").strip() # stray name after last marker
if tail:
names.append(tail)
return names
def _find_col(row: list, matches) -> int | None:
for idx, cell in enumerate(row):
if matches(str(cell)):
return idx
return None
def _cell(row: list, idx: int) -> str:
return str(row[idx]) if 0 <= idx < len(row) else ""
def _merged_title(row: list) -> str | None:
"""Return a row's lone non-empty cell - a merged section title - else None.
A fully blank row returns None rather than "", so blank separators between
sections do not wipe the title we are holding for the next header row.
"""
values = [str(c).strip() for c in row if str(c).strip()]
return values[0] if len(values) == 1 else None
def parse_team_sections(rows: list[list]) -> list[TeamSection]:
"""Extract each game's block of teams from a tab's raw rows, with its title.
Scans for every header row that has both a 'Team Name' and a 'Lineup...'
column, then reads the rows beneath it (using that section's own column
positions) until the team-name column goes blank or a new header appears.
Handles multiple stacked sections with differing layouts in one tab, and
tags each with the most recent merged title row seen above it.
"""
sections: list[TeamSection] = []
title = ""
i, n = 0, len(rows)
while i < n:
name_col = _find_col(rows[i], lambda c: c.strip().lower() == _TEAM_NAME_HEADER)
lineup_col = _find_col(rows[i], lambda c: c.strip().lower().startswith(_LINEUP_HEADER_PREFIX))
if name_col is None or lineup_col is None:
if (text := _merged_title(rows[i])) is not None:
# Accumulate every single-cell row above the header, not just the
# last one: sheets stack the "TipiLAN 2026 CS2" title above notice
# rows ("If a team withdraws..."), and the later notices must not
# clobber the title whose keywords resolve_divider needs.
title = f"{title} {text}".strip() if title else text
i += 1
continue
rosters: dict[str, list[str]] = {}
i += 1 # move past the header into the data block
while i < n:
team = _cell(rows[i], name_col).strip()
if not team or team.lower() == _TEAM_NAME_HEADER:
break # blank team-name (or a new header) ends this section
players = parse_lineup(_cell(rows[i], lineup_col))
if players:
rosters.setdefault(team, []).extend(players)
i += 1
if rosters:
sections.append(TeamSection(title=title, rosters=rosters))
title = "" # consumed - do not leak it onto the next section
return sections
def parse_team_rosters(rows: list[list]) -> dict[str, list[str]]:
"""Flatten every section in a tab into {team_name: [nickname, ...]}."""
rosters: dict[str, list[str]] = {}
for section in parse_team_sections(rows):
for team, players in section.rosters.items():
rosters.setdefault(team, []).extend(players)
return rosters
def resolve_divider(title: str, dividers: dict[str, int] | None = None) -> int | None:
"""Return the divider role ID configured for a section title, if any.
A ``TEAM_DIVIDER_<SUFFIX>`` entry matches when every underscore-separated
part of its suffix appears as a whole word in the title, so ``CS2`` matches
a CS2 section from any year while ``CS2_2026`` matches only the 2026 one.
The most specific match (most parts) wins, which lets a year-scoped entry
override a general one for the same game.
"""
if dividers is None:
dividers = config.TEAM_DIVIDERS
haystack = title.lower()
best_id: int | None = None
best_parts = 0
for suffix, role_id in dividers.items():
parts = [p for p in suffix.split("_") if p]
if not parts or len(parts) <= best_parts:
continue
if all(re.search(rf"\b{re.escape(p)}\b", haystack) for p in parts):
best_id, best_parts = role_id, len(parts)
return best_id
def build_username_index(rosters: dict[str, list[str]]) -> dict[str, str]:
"""Invert {team: [usernames]} into {normalized username: team}.
If the same username appears on two teams the last one wins and a warning
is logged (a person is expected to be on exactly one team).
"""
index: dict[str, str] = {}
for team, players in rosters.items():
for player in players:
key = player.strip().lower()
if not key:
continue
if key in index and index[key] != team:
log.warning(
"Player %r appears on both %r and %r; using %r",
player, index[key], team, team,
)
index[key] = team
return index
def _refresh_teams_sync() -> dict[str, list[str]]:
global _team_roster, _team_by_username, _team_names, _team_divider
creds = Credentials.from_service_account_file(config.GOOGLE_CREDS_PATH, scopes=SCOPES)
client = gspread.authorize(creds)
spreadsheet = client.open_by_key(config.TEAM_SHEET_ID)
rosters: dict[str, list[str]] = {}
dividers: dict[str, int] = {}
for ws in spreadsheet.worksheets():
for section in parse_team_sections(ws.get_all_values()):
# Match on the tab name too ("CS2"/"LoL"): it names the game reliably
# even when the game/year title row is shadowed by notice rows, while
# the section title still supplies the year for year-scoped dividers.
divider = resolve_divider(f"{ws.title} {section.title}")
for team, players in section.rosters.items():
rosters.setdefault(team, []).extend(players)
if divider:
dividers[team] = divider
_team_roster = rosters
_team_by_username = build_username_index(rosters)
_team_names = set(rosters)
_team_divider = dividers
return rosters
async def refresh_teams() -> dict[str, list[str]]:
"""Reload the team registration sheet into the in-memory team caches.
No-op returning {} when TEAM_SHEET_ID is not configured, so the whole
feature can be left switched off without touching sync behaviour.
"""
if not config.TEAM_SHEET_ID:
return {}
return await asyncio.to_thread(_refresh_teams_sync)
def get_team_for_username(username: str) -> str | None:
"""Return the team a Discord username is registered on, or None."""
return _team_by_username.get(str(username).strip().lower())
def all_team_names() -> set[str]:
"""Every team name known from the registration sheet (the role universe)."""
return set(_team_names)
def get_team_rosters() -> dict[str, list[str]]:
"""Current {team: [usernames]} cache (mainly for diagnostics/tests)."""
return _team_roster
def get_team_dividers() -> dict[str, int]:
"""Current {team: divider role ID} cache.
Only teams whose section title matched a configured TEAM_DIVIDER_* entry
appear here, so an empty dict means positioning is switched off.
"""
return dict(_team_divider)

View File

@@ -9,17 +9,17 @@ The codebase is split into **`core/`** (domain logic), **`commands/`** (Discord
| File | Purpose |
|---|---|
| `bot.py` | Discord client, event handlers (`on_ready`, `on_member_join`, ...), background tasks (presence rotation, daily birthday loop), shared helpers (`_award_exp`, `_maybe_remind`, `_parse_amount`, `_PAUSED`), and `register_*_commands(...)` wiring for every command module |
| `strings.py` | **Single source of truth for all user-facing text.** Edit here to change any message. |
| `strings/` | **Single source of truth for all user-facing text**, split into domain submodules (`common`, `commands`, `member`, `economy`, `admin`, `games`, `fishing`) and re-exported via `strings/__init__.py` so `import strings` / `strings.NAME` works unchanged. Edit the relevant submodule to change any message. |
| `config.py` | Environment variables (TOKEN, GUILD_ID, PB_URL, etc.) |
### `core/` - domain logic, no Discord coupling
| File | Purpose |
|---|---|
| `core/economy.py` | All economy business logic (`do_daily`, `do_work`, ...), data model, constants (SHOP, COOLDOWNS, LEVEL_ROLES, EXP_REWARDS, JAIL_DURATION, ...) |
| `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 |
| `core/member_sync.py` | Birthday/member sync helpers, plus tournament team-role sync + divider placement |
### `commands/` - one slash-command group per file
@@ -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` |
@@ -57,20 +57,20 @@ Pick the `commands/economy_*_commands.py` file that matches the new command's ca
Checklist - do all of these, in order:
1. **`core/economy.py`** - add the `do_<cmd>` async function with cooldown check, logic, `_commit`, and `_txn` logging
2. **`core/economy.py`** - add the cooldown to `COOLDOWNS` dict if it has one
3. **`core/economy.py`** - add the EXP reward to `EXP_REWARDS` dict
4. **`strings.py` `CMD`** - add the slash command description
5. **`strings.py` `OPT`** - add any parameter descriptions
6. **`strings.py` `TITLE`** - add embed title(s) for success/fail states
7. **`strings.py` `ERR`** - add any error messages (banned, cooldown uses `CD_MSG`, jailed uses `CD_MSG["jailed"]`)
8. **`strings.py` `CD_MSG`** - add cooldown message if command has a cooldown
9. **`strings.py` `HELP_CATEGORIES["tipibot"]["fields"]`** - add the command to the help embed
10. **`commands/economy_<group>_commands.py`** - inside `register_*_commands`, add `@tree.command(name="<cmd>", ...)` `cmd_<name>`; handle all `res["reason"]` cases
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. 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
6. **`strings/common.py` `TITLE`** - add embed title(s) for success/fail states
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. 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.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)
13. **`strings/common.py` `REMINDER_OPTS`** - add a reminder option if the command needs one
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
---
@@ -78,13 +78,13 @@ Checklist - do all of these, in order:
Checklist:
1. **`core/economy.py` `SHOP`** - add the item dict `{name, emoji, cost, description: strings.ITEM_DESCRIPTIONS["key"]}`
2. **`core/economy.py` `SHOP_TIERS`** - add the key to the correct tier list (1/2/3)
3. **`core/economy.py` `SHOP_LEVEL_REQ`** - add minimum level if it is T2 (≥10) or T3 (≥20)
4. **`strings.py` `ITEM_DESCRIPTIONS`** - add the item description (Estonian flavour + English effect)
5. **`strings.py` `HELP_CATEGORIES["shop"]["fields"]`** - add display entry (sorted by cost)
1. **`core/economy/shop.py` `SHOP`** - add the item dict `{name, emoji, cost, description: strings.ITEM_DESCRIPTIONS["key"]}`
2. **`core/economy/shop.py` `SHOP_TIERS`** - add the key to the correct tier list (1/2/3)
3. **`core/economy/shop.py` `SHOP_LEVEL_REQ`** - add minimum level if it is T2 (≥10) or T3 (≥20)
4. **`strings/economy.py` `ITEM_DESCRIPTIONS`** - add the item description (Estonian flavour + English effect)
5. **`strings/commands.py` `HELP_CATEGORIES["shop"]["fields"]`** - add display entry (sorted by cost)
6. If the item modifies a cooldown:
- **`core/economy.py`** - add the `if "item" in user["items"]` branch in the relevant `do_<cmd>` function
- **`core/economy/<area>.py`** - add the `if "item" in user["items"]` branch in the relevant `do_<cmd>` function
- **`bot.py` `_maybe_remind`** - add `elif cmd == "<cmd>" and "<item>" in items:` branch with the new delay
- **`commands/economy_profile_commands.py` `cmd_cooldowns`** - add the item annotation to the relevant status line
@@ -92,7 +92,7 @@ Checklist:
## Adding a New Level Role
1. **`core/economy.py` `LEVEL_ROLES`** - add `(min_level, "RoleName")` in descending level order (highest first)
1. **`core/economy/levels.py` `LEVEL_ROLES`** - add `(min_level, "RoleName")` in descending level order (highest first)
2. **`bot.py` `_ensure_level_role`** - no changes needed (uses `LEVEL_ROLES` dynamically)
3. Run **`/economysetup`** in the server to create the role and set its position
@@ -100,8 +100,8 @@ Checklist:
## Adding a New Admin Command
1. **`strings.py` `CMD`** - add `"[Admin] ..."` description
2. **`strings.py` `HELP_CATEGORIES["admin"]["fields"]`** - add the entry
1. **`strings/commands.py` `CMD`** - add `"[Admin] ..."` description
2. **`strings/commands.py` `HELP_CATEGORIES["admin"]["fields"]`** - add the entry
3. **`commands/economy_admin_commands.py`** (or `commands/ops_admin_commands.py` for non-economy ops) - add the handler with `@app_commands.default_permissions(manage_guild=True)` and `@app_commands.guild_only()`
---
@@ -110,13 +110,13 @@ Checklist:
### Storage
All economy state is stored in **PocketBase** (`economy_users` collection). `core/pb_client.py` owns all reads/writes. Each `do_*` function in `core/economy.py` calls `get_user()` → mutates the local dict → calls `_commit()`. `_commit` does a `PATCH` to PocketBase.
All economy state is stored in **PocketBase** (`economy_users` collection). `core/pb_client.py` owns all reads/writes. Each `do_*` function in `core/economy/` calls `get_user()` (from `store.py`) → mutates the local dict → calls `_commit()`. `_commit` does a `PATCH` to PocketBase.
### Currency & Income Sources
| Command | Cooldown | Base Earn | Notes |
|---|---|---|---|
| `/daily` | 20h (18h w/ korvaklapid) | 150⬡ | ×streak multiplier, ×2 w/ lan_pass, +5% interest w/ gaming_laptop |
| `/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 |
@@ -141,10 +141,41 @@ Commands that accept a coin amount (`/give`, `/roulette`, `/rps`, `/slots`, `/bl
- `/jailbreak`: 3 dice rolls, need doubles to escape free. On fail - bail = 20-30% of balance, min 350⬡. If balance < 350⬡, player stays jailed until timer.
- **Blocked while jailed**: `/work`, `/beg`, `/crime`, `/rob`, `/give` (checked in `do_*` functions via `_is_jailed`)
### EXP Rewards (from `EXP_REWARDS` in `core/economy.py`)
### EXP Rewards (from `EXP_REWARDS` in `core/economy/levels.py`)
EXP is awarded on every successful command use. Level formula: `level = max(1, floor(sqrt(exp / 6)))` (see `get_level` / `exp_for_level`). Thresholds: Level 5 = 150 EXP, Level 10 = 600, Level 20 = 2 400, Level 30 = 5 400.
Gambling EXP is bet-scaled via `gamble_exp(bet)`; fish EXP is per-species in `FISH` (common 23, uncommon 67, rare 10, epic 1415, legendary 25).
Gambling EXP is bet-scaled via `gamble_exp(bet)`; fish EXP is per-species in `FISH_CATALOGUE` (common 23, uncommon 67, rare 10, epic 1415, legendary 25).
---
## Tournament Team Roles
Economy profile only. Roster-independent: matches Discord usernames straight against `TEAM_SHEET_ID`, never the member sheet. Entry points are `/teamsync` (`commands/economy_team_commands.py`) and the hourly `team_sync_hourly` task in `bot.py`; both call `sheets.refresh_teams()` then `member_sync.sync_all_team_roles()`.
### Pipeline
| Step | Where | Notes |
|---|---|---|
| Split a tab into per-game blocks | `sheets.parse_team_sections` | Returns `TeamSection(title, rosters)`. The merged title row above each header is the **only** thing identifying game + year |
| Flatten to `{team: [players]}` | `sheets.parse_team_rosters` | Thin wrapper over `parse_team_sections` |
| Section title → divider role name | `sheets.resolve_divider` | Matches `config.TEAM_DIVIDERS`; most specific key wins |
| Cache | `sheets._refresh_teams_sync` | Populates `_team_roster`, `_team_by_username`, `_team_names`, `_team_divider` |
| Grant/remove roles per member | `member_sync.sync_team_role` | Only names in `all_team_names()` are ever touched |
| Compute new role positions | `member_sync.plan_team_positions` | **Pure** - list of names ascending + managed set + placements → `{name: new position}`. Unit-tested without Discord |
| Apply | `member_sync.apply_team_role_positions` | One `guild.edit_role_positions` call per sync |
### Adding a game to the divider config
1. `.env` - add `TEAM_DIVIDER_<GAME>_<YEAR>="<exact Discord role name>"`. No code change; `config._parse_team_dividers` discovers any var with that prefix at startup.
2. Restart the bot (env is read once at import).
### Position maths gotchas
- Discord positions are **ascending from the bottom** (`@everyone` = 0), so "under the divider in the UI" means a *lower* number. `plan_team_positions` therefore inserts each block reverse-sorted.
- The bot can only reorder roles strictly below its own highest role. Dividers at or above it are skipped with an error rather than attempted.
- `managed` roles (integration/bot/booster) are never emitted in the plan.
- Roles are matched **by name** throughout the feature; with duplicate names the lowest-positioned one wins.
- Only roles whose position actually changed are submitted, so a settled guild costs zero API calls.
---
@@ -179,49 +210,51 @@ Role assignment:
| T3 | 20 | monitor_360, karikas, gaming_tool |
Shop display is sorted by cost (ascending) within each tier.
The `SHOP_LEVEL_REQ` dict in `core/economy.py` controls per-item lock thresholds.
The `SHOP_LEVEL_REQ` dict in `core/economy/shop.py` controls per-item lock thresholds.
---
## strings.py Organisation
## strings/ Organisation
Imported as `import strings as S` everywhere. Dicts are read from `bot.py` and from every `commands/*.py` module.
Imported as `import strings as S` everywhere. `strings/` is a package: the names below live in domain submodules and are re-exported from `strings/__init__.py`, so `S.CMD`, `S.ERR`, etc. resolve unchanged regardless of which submodule they live in. Dicts are read from `bot.py` and from every `commands/*.py` module. Edit the submodule shown in the **Module** column.
| Section | Dict | Typical usage |
|---|---|---|
| Flavour text | `WORK_JOBS`, `BEG_LINES`, `CRIME_WIN`, `CRIME_LOSE` | Randomised descriptions |
| Command descriptions | `CMD["key"]` | `@tree.command(description=S.CMD["key"])` |
| Parameter descriptions | `OPT["key"]` | `@app_commands.describe(param=S.OPT["key"])` |
| Help embed | `HELP_CATEGORIES["cat"]` | `cmd_help` (in `bot.py`) |
| Banned message | `MSG_BANNED` | All banned checks |
| Maintenance mode | `MSG_MAINTENANCE` | Shown when `_PAUSED=True` in `bot.py` (toggled by `/pause` in `commands/ops_admin_commands.py`) |
| Reminder options | `REMINDER_OPTS` | `RemindersSelect` dropdown |
| Slots outcomes | `SLOTS_TIERS["tier"]``(title, color)` | `cmd_slots` (in `commands/economy_games_commands.py`) |
| Embed titles | `TITLE["key"]` | `discord.Embed(title=S.TITLE["key"])` |
| Error messages | `ERR["key"]` | `send_message(S.ERR["key"])` - use `.format(**kwargs)` for dynamic parts |
| Cooldown messages | `CD_MSG["cmd"].format(ts=cd_ts(...))` | Cooldown responses (`cd_ts` helper passed in by `bot.py`) |
| Shop UI | `SHOP_UI["key"]` | `_shop_embed` (in `commands/economy_support_commands.py`) |
| Item descriptions | `ITEM_DESCRIPTIONS["item_key"]` | `core/economy.py` `SHOP[key]["description"]` |
| Patch notes UI | `PATCHNOTES_UI["key"]` | `commands/info_commands.py` (`/patchnotes`) |
| Section | Dict | Module | Typical usage |
|---|---|---|---|
| Flavour text | `WORK_JOBS`, `BEG_LINES`, `CRIME_WIN`, `CRIME_LOSE` | `economy.py` | Randomised descriptions |
| Command descriptions | `CMD["key"]` | `commands.py` | `@tree.command(description=S.CMD["key"])` |
| Parameter descriptions | `OPT["key"]` | `commands.py` | `@app_commands.describe(param=S.OPT["key"])` |
| Help embed | `HELP_CATEGORIES["cat"]` | `commands.py` | `cmd_help` (in `bot.py`) |
| Banned message | `MSG_BANNED` | `common.py` | All banned checks |
| Maintenance mode | `MSG_MAINTENANCE` | `common.py` | Shown when `_PAUSED=True` in `bot.py` (toggled by `/pause` in `commands/ops_admin_commands.py`) |
| Reminder options | `REMINDER_OPTS` | `common.py` | `RemindersSelect` dropdown |
| Slots outcomes | `SLOTS_TIERS["tier"]``(title, color)` | `games.py` | `cmd_slots` (in `commands/economy_games_commands.py`) |
| Embed titles | `TITLE["key"]` | `common.py` | `discord.Embed(title=S.TITLE["key"])` |
| Error messages | `ERR["key"]` | `common.py` | `send_message(S.ERR["key"])` - use `.format(**kwargs)` for dynamic parts |
| Cooldown messages | `CD_MSG["cmd"].format(ts=cd_ts(...))` | `common.py` | Cooldown responses (`cd_ts` helper passed in by `bot.py`) |
| Shop UI | `SHOP_UI["key"]` | `economy.py` | `_shop_embed` (in `commands/economy_support_commands.py`) |
| Item descriptions | `ITEM_DESCRIPTIONS["item_key"]` | `economy.py` | `core/economy/shop.py` `SHOP[key]["description"]` |
| Patch notes UI | `PATCHNOTES_UI["key"]` | `common.py` | `commands/info_commands.py` (`/patchnotes`) |
---
## Constants Location Quick-Reference
| Constant | File | Description |
All are re-exported from `core/economy/__init__.py`, so code reads them as `economy.<NAME>` regardless of which submodule defines them. Edit the file in the **Defined in** column.
| Constant | Defined in | Description |
|---|---|---|
| `SHOP` | `core/economy.py` | All shop items (name, emoji, cost, description) |
| `SHOP_TIERS` | `core/economy.py` | Which items are in T1/T2/T3 |
| `SHOP_LEVEL_REQ` | `core/economy.py` | Min level per item |
| `COOLDOWNS` | `core/economy.py` | Base cooldown per command |
| `JAIL_DURATION` | `core/economy.py` | How long jail lasts |
| `LEVEL_ROLES` | `core/economy.py` | `[(min_level, "RoleName"), ...]` highest first |
| `ECONOMY_ROLE` | `core/economy.py` | Name of the base economy participation role |
| `EXP_REWARDS` | `core/economy.py` | EXP per command |
| `FISH` | `core/economy.py` | Fish species table (rarity, weight, coins, exp) |
| `HOUSE_ID` | `core/economy.py` | Bot's user ID (house account for /rob) |
| `MIN_BAIL` | `core/economy.py` | Minimum bail payment (350⬡) |
| `COIN` | `core/economy.py` | The coin emoji string |
| `SHOP` | `core/economy/shop.py` | All shop items (name, emoji, cost, description) |
| `SHOP_TIERS` | `core/economy/shop.py` | Which items are in T1/T2/T3 |
| `SHOP_LEVEL_REQ` | `core/economy/shop.py` | Min level per item |
| `COOLDOWNS` | `core/economy/store.py` | Base cooldown per command |
| `JAIL_DURATION` | `core/economy/store.py` | How long jail lasts |
| `LEVEL_ROLES` | `core/economy/levels.py` | `[(min_level, "RoleName"), ...]` highest first |
| `ECONOMY_ROLE` | `core/economy/levels.py` | Name of the base economy participation role |
| `EXP_REWARDS` | `core/economy/levels.py` | EXP per command |
| `FISH_CATALOGUE` | `core/economy/fishing.py` | Fish species table (rarity, weight, coins, exp) |
| `HOUSE_ID` | `core/economy/house.py` | Bot's user ID (house account for /rob) |
| `MIN_BAIL` | `core/economy/jail.py` | Minimum bail payment (350⬡) |
| `COIN` | `core/economy/store.py` | The coin emoji string |
| `_PAUSED` | `bot.py` | In-memory maintenance flag; toggled by `/pause`; blocks all non-admin commands |
---

View File

@@ -3,6 +3,17 @@
Here you'll find an overview of TipiBOT updates. Latest changes are at the top.
Format each version with a `## ` header (e.g. `## v0.1.0 — 2026-05-03`).
## v0.3.0 — 2026-08-19
- Added `/consumables` — a shop of repeatable, temporary boosts you can buy over and over again (unlike the permanent gear in `/shop`): **Energiajook XL** (1 hour of 2× earnings from `/work`, `/beg` and `/crime`), **XP jook** (1 hour of 2× EXP), and **Kohv** (instantly clears all your cooldowns). Run `/consumables` with no option to browse the menu and see which boosts are still ticking, or pick one to buy and activate it. Buying the same boost again extends its timer instead of wasting it.
- Fixed a broken icon on the "jailed" line in `/cooldowns`
## v0.2.0 — 2026-07-22
- Added `/quests` — daily and weekly quests with a "claim rewards" button. Three daily quests refresh every day and two weekly quests refresh every week; every player gets their own personal set that rotates over time. Complete objectives like working, fishing, wagering, or pulling off crimes to earn TipiCOIN and EXP.
- Fixed concurrent commands being able to overwrite each other's balance changes — every economy action now runs under a per-user lock, and house payouts use atomic database increments
- Fixed the maintenance pause and channel restrictions never actually being enforced — the global command check was registered incorrectly and silently did nothing (command logging was also affected)
## v0.1.0 — 2026-05-03
- Added `/patchnotes`

View File

@@ -44,6 +44,17 @@ Add the following fields:
> **Tip:** Set `user_id` as a unique index under **Indexes** tab.
The table above is only the base schema. After creating the collections, run:
```bash
python scripts/sync_pb_schema.py
```
It derives the complete field list from the bot's user model and adds whatever
is missing to **both** collections (stats, fishing, prestige, quests, ...).
Re-run it after every update - PocketBase silently drops writes to fields that
are not in the collection schema, which breaks features without any error.
Set **API rules** (all four: list, view, create, update) to admin-only (leave blank / locked).
---

2
requirements-dev.txt Normal file
View File

@@ -0,0 +1,2 @@
-r requirements.txt
pytest>=8.0

101
scripts/add_quest_fields.py Normal file
View File

@@ -0,0 +1,101 @@
"""Add the quest-system JSON fields to the economy_users PocketBase collection.
Run once after pulling the quest changes:
python scripts/add_quest_fields.py
Requirements:
- PocketBase running and reachable at PB_URL
- PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD set in .env
"""
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
import aiohttp
from dotenv import load_dotenv
sys.path.insert(0, str(Path(__file__).parent.parent))
load_dotenv()
import config # noqa: E402
PB_URL = config.PB_URL
PB_ADMIN_EMAIL = config.PB_ADMIN_EMAIL
PB_ADMIN_PASSWORD = config.PB_ADMIN_PASSWORD
# Patch both profiles' collections so the result doesn't depend on which
# BOT_PROFILE happened to be set when the script was run.
COLLECTIONS = sorted({config.PB_ECONOMY_COLLECTION_DEV, config.PB_ECONOMY_COLLECTION_ECONOMY})
# ---------------------------------------------------------------------------
# New fields to add
# ---------------------------------------------------------------------------
_NEW_JSON_FIELDS = [
"quest_daily",
"quest_weekly",
]
def _json_field(name: str) -> dict:
return {"name": name, "type": "json", "required": False}
async def main() -> None:
timeout = aiohttp.ClientTimeout(total=15)
async with aiohttp.ClientSession(timeout=timeout) as session:
# ── Authenticate ────────────────────────────────────────────────────
async with session.post(
f"{PB_URL}/api/collections/_superusers/auth-with-password",
json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD},
) as resp:
if resp.status != 200:
print(f"Auth failed ({resp.status}): {await resp.text()}")
return
token = (await resp.json())["token"]
hdrs = {"Authorization": token}
for collection in COLLECTIONS:
print(f"── {collection} ──")
# ── Fetch current collection ─────────────────────────────────────
async with session.get(
f"{PB_URL}/api/collections/{collection}", headers=hdrs
) as resp:
if resp.status != 200:
print(f"Could not fetch collection ({resp.status}): {await resp.text()}\n")
continue
col = await resp.json()
existing = {f["name"] for f in col.get("fields", [])}
print(f"Existing fields ({len(existing)}): {sorted(existing)}\n")
new_fields = []
for name in _NEW_JSON_FIELDS:
if name not in existing:
new_fields.append(_json_field(name))
print(f" + {name} (json)")
else:
print(f" = {name} (already exists)")
if not new_fields:
print("Nothing to add - schema already up to date.\n")
continue
# ── Patch collection schema ──────────────────────────────────────
updated_fields = col.get("fields", []) + new_fields
async with session.patch(
f"{PB_URL}/api/collections/{collection}",
json={"fields": updated_fields},
headers=hdrs,
) as resp:
if resp.status != 200:
print(f"Schema update failed ({resp.status}): {await resp.text()}\n")
continue
print(f"✅ Added {len(new_fields)} field(s) successfully.\n")
if __name__ == "__main__":
asyncio.run(main())

133
scripts/sync_pb_schema.py Normal file
View File

@@ -0,0 +1,133 @@
"""Reconcile the PocketBase economy collections with the bot's user schema.
The expected field list is derived from core.economy._default_user() - the
single source of truth for what the bot persists - so this supersedes
add_stats_fields.py / add_quest_fields.py and also covers the fields those
scripts never added (fishing, prestige, quests, last_heist, ...).
PocketBase silently drops record fields that are missing from the collection
schema, so any drift here breaks features without a single error message.
Patches BOTH profile collections regardless of BOT_PROFILE. Only ever adds
fields; existing fields are never removed or retyped (type mismatches are
reported for manual review).
python scripts/sync_pb_schema.py # add whatever is missing
python scripts/sync_pb_schema.py --check # report only; exit 1 on drift
"""
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
import aiohttp
from dotenv import load_dotenv
sys.path.insert(0, str(Path(__file__).parent.parent))
load_dotenv()
import config # noqa: E402
from core import economy # noqa: E402
PB_URL = config.PB_URL
COLLECTIONS = sorted({config.PB_ECONOMY_COLLECTION_DEV, config.PB_ECONOMY_COLLECTION_ECONOMY})
# _default_user() keys 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",
}
def _expected_fields() -> dict[str, str]:
"""Map every persisted field name to its PocketBase field type."""
expected = {"user_id": "text"}
for key, default in economy._default_user().items():
if key in _TEXT_FIELDS:
expected[key] = "text"
elif isinstance(default, bool):
expected[key] = "bool"
elif isinstance(default, (int, float)):
expected[key] = "number"
elif isinstance(default, (list, dict)):
expected[key] = "json"
else:
raise SystemExit(
f"Cannot infer a PocketBase type for {key!r} (default {default!r}). "
"Add it to _TEXT_FIELDS or extend the mapping."
)
return expected
async def main() -> int:
check_only = "--check" in sys.argv
expected = _expected_fields()
drift = False
timeout = aiohttp.ClientTimeout(total=15)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(
f"{PB_URL}/api/collections/_superusers/auth-with-password",
json={"identity": config.PB_ADMIN_EMAIL, "password": config.PB_ADMIN_PASSWORD},
) as resp:
if resp.status != 200:
print(f"Auth failed ({resp.status}): {await resp.text()}")
return 1
hdrs = {"Authorization": (await resp.json())["token"]}
for collection in COLLECTIONS:
print(f"── {collection} ──")
async with session.get(
f"{PB_URL}/api/collections/{collection}", headers=hdrs
) as resp:
if resp.status != 200:
print(f"Could not fetch collection ({resp.status}): {await resp.text()}\n")
drift = True
continue
col = await resp.json()
existing = {f["name"]: f.get("type", "?") for f in col.get("fields", [])}
missing = [name for name in expected if name not in existing]
mismatched = [
(name, existing[name], ftype)
for name, ftype in expected.items()
if name in existing and existing[name] != ftype
]
for name, have, want in mismatched:
drift = True
print(f" ! {name}: schema has type '{have}', bot expects '{want}' - fix manually")
if not missing:
print(" ✓ no missing fields\n" if not mismatched else "")
continue
drift = True
for name in missing:
print(f" + {name} ({expected[name]})")
if check_only:
print()
continue
new_fields = [{"name": name, "type": expected[name]} for name in missing]
async with session.patch(
f"{PB_URL}/api/collections/{collection}",
json={"fields": col.get("fields", []) + new_fields},
headers=hdrs,
) as resp:
if resp.status != 200:
print(f" Schema update failed ({resp.status}): {await resp.text()}\n")
continue
print(f" ✅ added {len(new_fields)} field(s)\n")
return 1 if (check_only and drift) else 0
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))

1372
strings.py

File diff suppressed because it is too large Load Diff

202
strings/__init__.py Normal file
View File

@@ -0,0 +1,202 @@
"""All user-facing text for TipiLAN Bot.
Strings live in domain submodules (common, commands, member, economy, admin,
games, fishing) and are re-exported here, so ``import strings`` and
``strings.NAME`` keep working unchanged. Edit the relevant submodule to change
any message, description, or flavour text without touching any logic code."""
from .common import (
MSG_BANNED,
MSG_SPAM_JAIL,
MSG_PONG,
MSG_RESTART_DONE,
MSG_RESTARTING,
MSG_SHUTTING_DOWN,
MSG_PAUSED,
MSG_UNPAUSED,
MSG_MAINTENANCE,
MSG_SYNC_DONE,
MSG_REMINDER,
MSG_LEVELUP,
MSG_LEVELUP_ROLE,
REMINDER_OPTS,
TITLE,
ERR,
CD_MSG,
REMINDERS_UI,
SEND_UI,
PATCHNOTES_UI,
STATUS_UI,
)
from .commands import (
CMD,
OPT,
HELP_CATEGORIES,
HELP_UI,
)
from .member import (
MEMBER_UI,
MEMBER_FIELDS,
ECONOMYSETUP_UI,
CHANNEL_UI,
BIRTHDAY_UI,
BIRTHDAY_MONTHS,
CHECK_UI,
TEAMSYNC_UI,
)
from .economy import (
WORK_JOBS,
BEG_LINES,
BEG_JAIL_LINES,
CRIME_WIN,
CRIME_LOSE,
QUEST_UI,
QUEST_DESCRIPTIONS,
SHOP_UI,
ITEM_DESCRIPTIONS,
CONSUMABLES_UI,
CONSUMABLE_DESCRIPTIONS,
VANITY_UI,
LOOTBOX_UI,
BANK_UI,
ACHIEVEMENTS_UI,
LOTTERY_UI,
JAILED_UI,
SHOP_BTN,
DAILY_UI,
STATS_UI,
PROFILE_UI,
BALANCE_UI,
COOLDOWNS_UI,
RANK_UI,
WORK_UI,
BEG_UI,
CRIME_UI,
ROB_UI,
GIVE_UI,
BUY_UI,
LEADERBOARD_UI,
REQUEST_UI,
)
from .admin import (
ADMINVIEW_UI,
ADMIN,
SEASON,
PRESTIGE_SHOP_NAMES,
PRESTIGE_SHOP_DESCRIPTIONS,
PRESTIGE_UI,
)
from .games import (
SLOTS_TIERS,
ROULETTE,
HEIST_STORY,
HEIST_UI,
BJ,
JAILBREAK_UI,
SLOTS_UI,
RPS_UI,
RPS_CHOICES,
BJ_UI,
)
from .fishing import (
FISH_NAMES,
FISH_RARITY_NAMES,
FISH_RARITY_EMOJI,
FISH_JUNK_LINES,
FISH_UI,
)
__all__ = [
'MSG_BANNED',
'MSG_SPAM_JAIL',
'MSG_PONG',
'MSG_RESTART_DONE',
'MSG_RESTARTING',
'MSG_SHUTTING_DOWN',
'MSG_PAUSED',
'MSG_UNPAUSED',
'MSG_MAINTENANCE',
'MSG_SYNC_DONE',
'MSG_REMINDER',
'MSG_LEVELUP',
'MSG_LEVELUP_ROLE',
'REMINDER_OPTS',
'TITLE',
'ERR',
'CD_MSG',
'REMINDERS_UI',
'SEND_UI',
'PATCHNOTES_UI',
'STATUS_UI',
'CMD',
'OPT',
'HELP_CATEGORIES',
'HELP_UI',
'MEMBER_UI',
'MEMBER_FIELDS',
'ECONOMYSETUP_UI',
'CHANNEL_UI',
'BIRTHDAY_UI',
'BIRTHDAY_MONTHS',
'CHECK_UI',
'TEAMSYNC_UI',
'WORK_JOBS',
'BEG_LINES',
'BEG_JAIL_LINES',
'CRIME_WIN',
'CRIME_LOSE',
'QUEST_UI',
'QUEST_DESCRIPTIONS',
'SHOP_UI',
'ITEM_DESCRIPTIONS',
'CONSUMABLES_UI',
'CONSUMABLE_DESCRIPTIONS',
'VANITY_UI',
'LOOTBOX_UI',
'BANK_UI',
'ACHIEVEMENTS_UI',
'LOTTERY_UI',
'JAILED_UI',
'SHOP_BTN',
'DAILY_UI',
'STATS_UI',
'PROFILE_UI',
'BALANCE_UI',
'COOLDOWNS_UI',
'RANK_UI',
'WORK_UI',
'BEG_UI',
'CRIME_UI',
'ROB_UI',
'GIVE_UI',
'BUY_UI',
'LEADERBOARD_UI',
'REQUEST_UI',
'ADMINVIEW_UI',
'ADMIN',
'SEASON',
'PRESTIGE_SHOP_NAMES',
'PRESTIGE_SHOP_DESCRIPTIONS',
'PRESTIGE_UI',
'SLOTS_TIERS',
'ROULETTE',
'HEIST_STORY',
'HEIST_UI',
'BJ',
'JAILBREAK_UI',
'SLOTS_UI',
'RPS_UI',
'RPS_CHOICES',
'BJ_UI',
'FISH_NAMES',
'FISH_RARITY_NAMES',
'FISH_RARITY_EMOJI',
'FISH_JUNK_LINES',
'FISH_UI',
]

128
strings/admin.py Normal file
View File

@@ -0,0 +1,128 @@
"""Economy admin responses, season reset, and prestige system.
Auto-split from the original strings.py; edit strings here.
"""
from core.emoji import EMOJI as E
__all__ = [
'ADMINVIEW_UI',
'ADMIN',
'SEASON',
'PRESTIGE_SHOP_NAMES',
'PRESTIGE_SHOP_DESCRIPTIONS',
'PRESTIGE_UI',
]
ADMINVIEW_UI: dict[str, str] = {
"title": "🔍 {name} - majandusandmed",
"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",
"f_jailed": "🚔 Vangis kuni",
"f_prestige": "🔥 Prestiiž",
"f_items": "🎒 Esemed",
"f_uses": "🔢 Kasutused",
"f_fish": "🎣 Kala",
"f_last_daily": "⏱️ Viimati daily",
"f_last_work": "⏱️ Viimati work",
"f_last_crime": "⏱️ Viimati crime",
"f_last_fish": "⏱️ Viimati fish",
"footer": "ID: {uid}",
"exp_val": "{exp} EXP (Tase {level})",
"prestige_val": "Prestiiž {level} · {pp} PP",
"fish_val": "{caught} püütud · {inv} inventaris",
}
# ---------------------------------------------------------------------------
# Admin command responses and DMs
# ---------------------------------------------------------------------------
ADMIN: dict[str, str] = {
"coins_done": "{emoji} **{name}**: {verb} {coin} → saldo **{balance}**\n📝 Põhjus: {reason}",
"coins_dm": "{emoji} Admin muutis sinu TipiCOINide saldot: **{verb} {coin}**\n📝 Põhjus: *{reason}*\nUus saldo: **{balance} {coin}**",
"jail_done": "🚔 **{name}** on vangistatud {minutes} minutiks (vabaneb {ts}).\n📝 Põhjus: {reason}",
"jail_dm": "🚔 Admin saatis sind vanglasse **{minutes} minutiks**.\n📝 Põhjus: *{reason}*\nVabaneb {ts}.",
"unjail_done": "✅ **{name}** on vanglast vabastatud.",
"unjail_dm": "✅ Admin vabastas sind vanglast.",
"ban_done": "🚫 **{name}** keelati majandussüsteemis osaleda.\n📝 Põhjus: {reason}",
"ban_dm": "🚫 Sul keelati TipiBOTi majandussüsteemis osalemine.\n📝 Põhjus: *{reason}*",
"unban_done": "✅ **{name}** on majandussüsteemi osalemise keelust eemaldatud.",
"unban_dm": "✅ Sinu TipiBOTi majandussüsteemis osalemise keeld on tühistatud. Saad taas käske kasutada.",
"reset_done": "🗑️ **{name}** majandusandmed on lähtestatud.\n📝 Põhjus: {reason}",
"reset_dm": "🗑️ Admin lähtestas sinu TipiBOTi majandusandmed (saldo, esemed, streak).\n📝 Põhjus: *{reason}*",
"exp_done": "{emoji} **{name}**: {verb} EXP → kokku **{exp} EXP** (Tase {level}).\n📝 Põhjus: {reason}",
"exp_dm": "{emoji} Admin muutis sinu EXP-i: **{verb} EXP**\n📝 Põhjus: *{reason}*\nUus EXP: **{exp}** (Tase {level})",
"item_given": "✅ **{item}** antud kasutajale **{name}** (tasuta).",
"item_removed":"🗑️ **{item}** eemaldatud kasutajalt **{name}**.",
"item_invalid":"❌ Tundmatu ese: `{item_id}`. Kontrolli `/shop` eseme ID-d.",
"item_not_owned": "❌ **{name}** ei oma eset `{item_id}`.",
"item_dm_given": "✅ Admin andis sulle eseme: **{item}**.",
"item_dm_removed":"🗑️ Admin eemaldas sult eseme: **{item}**.",
}
# ---------------------------------------------------------------------------
# /adminseason strings
# ---------------------------------------------------------------------------
SEASON: dict[str, str] = {
"top_header": "**Top mängijad EXP järgi:**",
"no_players": "Keegi ei teeninud EXP-i.",
"entry": "{prefix} {name} - {exp} EXP *(Tase {level})*",
"footer": "Kõigi EXP, mündid ja esemed lähtestati. Uus hooaeg algab kohe!",
"done": "✅ Hooaeg lõpetatud - EXP, mündid ja esemed lähtestatud.",
}
# ---------------------------------------------------------------------------
# Prestige system strings
# ---------------------------------------------------------------------------
PRESTIGE_SHOP_NAMES: dict[str, str] = {
"coin_mult": "Mündiboost",
"exp_mult": "EXP-boost",
"daily_plus": "Päevabonus+",
"work_plus": "Töötaja+",
}
PRESTIGE_SHOP_DESCRIPTIONS: dict[str, str] = {
"coin_mult": "Kõik TipiCOINide teenimisallikad (daily, töö, kerja, kala) teenivad +8% rohkem iga taseme kohta. Max 5 taset → +40%.",
"exp_mult": "Kõik EXP allikad teenivad +8% rohkem iga taseme kohta. Max 5 taset → +40%.",
"daily_plus": "Päevase boonuse alussumma tõuseb +20% iga taseme kohta. Max 3 taset → +60%.",
"work_plus": "/work teenib +20% rohkem iga taseme kohta. Max 3 taset → +60%.",
}
PRESTIGE_UI: dict[str, str] = {
"confirm_desc": (
"Oled tasemel **{level}** ({exp} EXP).\n\n"
"Prestiiži korral saad **{pp}** " + E["TipiFIRE"] + " ja kõik lähtestub:\n"
"• Saldo, EXP, esemed, ooteajad\n\n"
"**Kalakogu jääb alles!**\n\nKas oled kindel?"
),
"btn_confirm": "🔥 Jah, prestiiži!",
"btn_cancel": "❌ Tühista",
"btn_tab_status": "⭐ Prestiiz",
"btn_tab_shop": "🛍️ Uuendused",
"success_desc": (
"Said **{pp}** " + E["TipiFIRE"] + "\n"
"Prestiiži tase: **{level}**\n"
"Kogutud PP: **{total_pp}** " + E["TipiFIRE"] + "\n\n"
"*Kõik lähtestati. Alusta otsast!*"
),
"too_low_desc": "Prestiiži jaoks vajad taset **{required}** (sul on tase {level}).",
"shop_desc": "Sul on **{pp}** " + E["TipiFIRE"] + " · Vajuta nuppu uuenduse ostmiseks",
"shop_maxed": "✅ Max",
"shop_level_fmt": "Tase {cur}/{max}",
"shop_cost_fmt": "{cost} " + E["TipiFIRE"],
"buy_success_desc":"**{name}** uuendatud tasemele **{new_level}/{max_level}**!\nPP alles: **{pp}** " + E["TipiFIRE"],
"buy_no_pp": E["TipICRY"] + " Sul pole piisavalt PP. Sul on **{have}**, vajad **{need}** " + E["TipiFIRE"] + ".",
"buy_maxed": "❌ See uuendus on juba maksimumtasemel.",
"buy_not_found": "❌ Sellist uuendust ei leitud. Vaata `/prestigeshop`.",
"rank_line": E["TipiFIRE"] + " Prestiiž **{level}** · {pp} PP",
"rank_season": "🏆 Hooaja EXP: **{exp}**",
"btn_buy_upgrade": "{emoji} {name} +1 ({cost} PP)",
"status_footer": "⭐ Prestiiž {level} · {pp} PP",
}

245
strings/commands.py Normal file
View File

@@ -0,0 +1,245 @@
"""Slash-command and /help metadata (descriptions, options, categories).
Auto-split from the original strings.py; edit strings here.
"""
from core.emoji import EMOJI as E
__all__ = [
'CMD',
'OPT',
'HELP_CATEGORIES',
'HELP_UI',
]
# ---------------------------------------------------------------------------
# Slash command descriptions (shown in Discord's autocomplete)
# ---------------------------------------------------------------------------
CMD: dict[str, str] = {
"ping": "Vaata, kas bot on üleval",
"help": "Nimekiri kõikidest käskudest kategooriate kaupa",
"status": "Näita boti staatust ja ressursside kasutust",
"birthdays": "Näita kõigi liikmete sünnipäevi kuu järgi",
"check": "Laadi andmed, täida ID'd ja sünkroniseeri kõik liikmed",
"sync": "Sünkroniseeri käsklused Discordi serveriga",
"member": "Näita liikme andmeid tabelist",
"teamsync": "[Admin] Sünkroniseeri tiimirollid registreerimistabelist",
"restart": "Tee taaskäivitus botile",
"shutdown": "Lülita bot välja (ilma taaskäivituseta)",
"pause": "Peata / jätka kõik käsklused (hooldusrežiim)",
"send": "Saada sõnum valitud kanalisse",
"profile": "Vaata oma profiili: saldo, tase, esemed, statistika ja kalakogu",
"balance": "Vaata enda (või kellegi teise) TipiCOINide saldot",
"daily": "Võta enda päevane TipiCOINi boonus",
"work": "Tööta ja teeni TipiCOINe (1h ooteaeg)",
"beg": "Kerja TipiCOINe (5min ooteaeg)",
"crime": "Proovi oma õnne kriminaalse tegevusega (2h ooteaeg)",
"rob": "Proovi kelleltki TipiCOINe varastada",
"heist": "Alusta grupirööv pangahoidlasse (min 2 mängijat, max 8, 4h serveri ooteaeg)",
"roulette": "Panusta TipiCOINe punasele, mustale või rohelisele (1/37 võimalus, 14x payout)",
"give": "Anna TipiCOINe teisele mängijale",
"leaderboard": "TipiBOTi edetabel - kes on kõige rikkam?",
"shop": "Sirvi TipiBOTi poodi",
"buy": "Osta ese TipiBOTi poodist",
"rps": "Kivi-paber-käärid mõne teise mängija vastu",
"slots": "Proovi oma õnne TipiBOTi mänguautomaadiga",
"request": "Kerja TipiCOINe teistelt mängijatelt - nagu crowdfunding, aga halvem",
"reminders": "Halda DM meeldetuletusi - kõik on vaikimisi sees, lülita need siin välja",
"cooldowns": "Vaata kõikide käskude ooteaegu",
"jailed": "Vaata, kes on praegu vanglas",
"rank": "Vaata oma EXP, taset ja edetabeli positsiooni",
"stats": "Vaata oma mängustatistikat",
"jailbreak": "Proovi vanglast põgeneda kasutades täringuid",
"adminseason": "[Admin] Lõpeta võistlus, teavita võitjaid ja lähtesta EXP",
"admincoins": "[Admin] Anna või võta TipiCOINe kasutajale/kasutajalt",
"adminjail": "[Admin] Saada kasutaja vangi",
"adminunjail": "[Admin] Vabasta kasutaja vangist",
"adminban": "[Admin] Keela kasutajal majandussüsteemis osaleda",
"adminunban": "[Admin] Eemalda majandussüsteemi keeld kasutajalt",
"adminreset": "[Admin] Lähtesta kasutaja majandusandmed",
"adminview": "[Admin] Vaata kasutaja majandusandmeid",
"adminexp": "[Admin] Anna v\u00f5i v\u00f5ta EXP kasutajalt",
"adminitem": "[Admin] Anna v\u00f5i eemalda ese kasutajalt (tasuta)",
"allowchannel": "[Admin] Lisa kanal, kus bot võib vastata käsklustele",
"denychannel": "[Admin] Eemalda kanal lubatud kanalite nimekirjast",
"channels": "[Admin] Näita lubatud kanalite nimekirja",
"economysetup": "[Admin] Loo ja sea korda majandussüsteemi rollid",
"blackjack": "Mängi blackjacki TipiBOTi vastu",
"prestige": "Prestiiži (nõuab taset 30) ja teeni Prestiižipunkte",
"prestigeshop": "Vaata prestiižipoodi ja sinu uuenduste taset",
"prestigebuy": "Osta prestiižiuuendus Prestiižipunktide eest",
"fish": "Mine kalastama (interaktiivne mäng, 2min ooteaeg)",
"fishbook": "Vaata oma kalakogu ja kogutud kalaliike",
"fishsell": "Müü kalu oma inventarist",
"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)",
}
# ---------------------------------------------------------------------------
# Option descriptions (shown next to each parameter in Discord)
# ---------------------------------------------------------------------------
OPT: dict[str, str] = {
"admin_kasutaja": "Kasutaja",
"admin_põhjus": "Põhjus (saadetakse kasutajale DM kaudu)",
"admincoins_kogus": "Positiivne = anna, negatiivne = võta",
"adminjail_minutid": "Vangistamise kestus minutites",
"balance_kasutaja": "Mängija, kelle saldot vaadata (vaikimisi sina)",
"roulette_panus": "Panus TipiCOINides ('all' = kogu saldo)",
"roulette_värv": "Punane, must või roheline",
"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)",
"request_summa": "Kui palju TipiCOINe vajad?",
"request_põhjus": "Miks sa vajad TipiCOINe?",
"request_sihtmärk": "Valikuline: kellelt täpselt palud (teised ei saa toetada)",
"send_kanal": "Kanal, kuhu sõnum saata",
"send_sõnum": "Sõnum, mida saata",
"allowchannel_kanal": "Kanal, kus bot võib vastata käsklustele",
"denychannel_kanal": "Kanal, kus bot ei või vastata käsklustele",
"rob_sihtmärk": "Kellelt röövid?",
"member_user": "Mängija, kelle infot tahad vaadata",
"rank_kasutaja": "Mängija, kelle taset tahad vaadata (vaikimisi sina)",
"stats_kasutaja": "Mängija, kelle statistikat tahad vaadata (vaikimisi sina)",
"adminseason_top_n": "Kui palju mängijaid võitis (vaikimisi 10)",
"blackjack_panus": "Panus TipiCOINides ('all' = kogu saldo)",
"prestigebuy_upgrade": "Uuenduse ID (vaata /prestigeshop)",
"fishbook_kasutaja": "Mängija, kelle kalakogu vaadata (vaikimisi sina)",
"profile_kasutaja": "Mängija, kelle profiili vaadata (vaikimisi sina)",
"adminexp_kogus": "Positiivne = anna, negatiivne = võta",
"adminitem_ese": "Eseme ID (kasutatav sisse, vaata /shop)",
"adminitem_tegevus": "'anna' või 'eemalda'",
}
# ---------------------------------------------------------------------------
# /help categories
# ---------------------------------------------------------------------------
HELP_CATEGORIES: dict[str, dict] = {
"üldine": {
"label": "🤖 Üldine",
"description": "Üldised käsklused",
"color": 0x57F287,
"fields": [
("/ping", "Vaata, kas bot on üleval"),
("/status", "Näita boti staatust ja ressursside kasutust"),
("/help", "Nimekiri kõikidest käskudest kategooriate kaupa"),
("/birthdays", "Näita kõigi liikmete sünnipäevi kuu järgi"),
],
},
"tipibot": {
"label": "🪙 TipiBOT",
"description": "TipiCOIN majandus",
"color": 0xF4C430,
"fields": [
("/profile [@user]", "Saldo, tase, EXP progress, prestiiz - kõik ühes kohas. Nupud: Esemed · Statistika · Kalakogu."),
("/cooldowns", "Vaata kõikide käskude ooteaegu. Näitab ka vangla ooteaega."),
("/daily", "Võta enda päevane TipiCOINide boonus. 20h ooteaeg. Streak'i boonus: 3d=+50%, 7d=+100%, 14d=+200%."),
("/work", "Tööta ja teeni TipiCOINe (1h ooteaeg)"),
("/beg", "Kerja TipiCOINe (5min ooteaeg)"),
("/crime", "Proovi oma õnne kriminaalse tegevusega. 60% edu, 40% trahv + 30min vanglas. 2h ooteaeg."),
("/rob @user", "Proovi kelleltki TipiCOINe varastada. 45% edu. Ebaõnnestumisel saad trahvi."),
("/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."),
],
},
"shop": {
"label": "🛍️ Pood",
"description": "TipiBOTi poe esemed ja nende efektid",
"color": 0xF4C430,
"fields": [
(f"{E['TipiHIIR']} Mängurihiir - 500 ⬡", "Teeni töötades 50% rohkem TipiCOINe."),
(f"{E['TipiMATT']} XL hiirematt - 600 ⬡", "Kerjamise ooteaeg 5min → 3min."),
(f"{E['TipiKLAPID']} Kõrvaklapid - 1200 ⬡", "Päevase boonuse ooteaeg 20h → 18h."),
(f"{E['TipiPILET']} LAN pilet (2025) - 1200 ⬡", "Päevane boonus on duubeldatud."),
(f"{E['TipiVAC']} Anticheat - 750 ⬡", "Röövimine sinu vastu ebaõnnestub. Pärast 2 kasutust pead ostma uue."),
(f"{E['TipiBULL']} Red Bull - 800 ⬡", "30% tõenäosus, et teenid töötades 3x rohkem."),
(f"{E['TipiLAP']} Botikoobas - 1500 ⬡", "RTX 5090 jooksutab botte 24/7. Päevane boonus genereerib 5% intressi sinu saldo pealt."),
(f"{E['TipiLAUD']} Reguleeritav laud - 3500 ⬡ *(T2)*", "/work teenib 25% rohkem (stackib mängurihiirega)."),
(f"{E['TipiSERVER']} Jellyfin server - 4000 ⬡ *(T2)*", "Röövimise edu tõenäosus 45% → 60%."),
(f"{E['TipiMIC']} Mikrofon - 2800 ⬡ *(T2)*", "Teeni 30% rohkem eduka /crime puhul."),
(f"{E['TipiKLAVA']} Mehhaaniline klaviatuur - 1800 ⬡ *(T2)*", "/beg teenib 2x rohkem."),
(f"{E['TipiMONITOR']} Ultralai monitor - 2500 ⬡ *(T2)*", "/work ooteaeg: 1h → 40min."),
(f"{E['TipiCAT']} CAT6 netikaabel - 3500 ⬡ *(T2)*", "/crime edu tõenäosus tõuseb 60% → 75%."),
(f"{E['TipiMONITOR2']} 360hz monitor - 7500 ⬡ *(T3)*", "Mänguautomaadi jackpot 10x → 15x, kolmik 4x → 6x."),
(f"{E['TipiKARIKAS']} TipiLANi trofee - 6000 ⬡ *(T3)*", "Streak ei nulli, kui sa mõne päeva vahele jätad."),
(f"{E['TipiTOOL']} Mänguritool - 9000 ⬡ *(T3)*", "/crime ebaõnnestumine ei saada sind vanglasse."),
],
},
"games": {
"label": "🎮 Mängud",
"description": "Lõbusad mängud",
"color": 0x5865F2,
"fields": [
("/roulette <bet> <colour>", "Panusta TipiCOINe punasele, mustale või rohelisele (1/37 võimalus, 14x payout)"),
("/rps [bet] [opponent]", "Kivi-paber-käärid mõne teise mängija vastu"),
("/slots <bet>", "Proovi oma õnne TipiBOTi mänguautomaadiga"),
("/blackjack <bet>", "Mängi blackjacki TipiBOTi vastu. Blackjack maksab 3:2. Kakskordistamine (double down) on võimalik vaid esimese käigu ajal."),
],
},
"admin": {
"label": "🔧 Admin",
"description": "Admin käsklused (peidetud tavaliste kasutajate eest)",
"color": 0xED4245,
"fields": [
("/check", "Laadi andmed, täida ID'd ja sünkroniseeri kõik liikmed"),
("/member @user", "Näita liikme andmeid tabelist"),
("/sync", "Sünkroniseeri käsklused Discordi serveriga"),
("/restart", "Tee taaskäivitus botile"),
("/send #channel message", "Saada sõnum valitud kanalisse"),
("/admincoins @user <amount> <reason>", "Anna või võta TipiCOINe kasutajale/kasutajalt"),
("/adminjail @user <minutes> <reason>", "Saada kasutaja vangi"),
("/adminunjail @user", "Vabasta kasutaja vangist"),
("/adminban @user <reason>", "Keela kasutajal majandussüsteemis osaleda"),
("/adminunban @user", "Eemalda majandussüsteemi keeld kasutajalt"),
("/adminreset @user <reason>", "Lähtesta kasutaja majandusandmed"),
("/adminview @user", "Vaata kasutaja majandusandmeid"),
("/allowchannel #channel", "Lisa kanal, kus bot võib vastata käsklustele"),
("/denychannel #channel", "Eemalda kanal lubatud kanalite nimekirjast"),
("/channels", "Näita lubatud kanalite nimekirja"),
("/adminseason [top_n]", "Lõpeta võistlus, teavita võitjaid ja lähtesta EXP"),
("/economysetup", "Loo ja sea korda majandussüsteemi rollid (ECONOMY + taseme rollid) boti rolli alla"),
],
},
}
# ---------------------------------------------------------------------------
# /help UI
# ---------------------------------------------------------------------------
HELP_UI: dict[str, str] = {
"footer": "TipiBOT • Vali kategooria allmenüüst",
"select_placeholder": "Vali kategooria...",
}

251
strings/common.py Normal file
View File

@@ -0,0 +1,251 @@
"""Shared / system-wide strings (messages, titles, errors, cooldowns).
Auto-split from the original strings.py; edit strings here.
"""
from core.emoji import EMOJI as E
__all__ = [
'MSG_BANNED',
'MSG_SPAM_JAIL',
'MSG_PONG',
'MSG_RESTART_DONE',
'MSG_RESTARTING',
'MSG_SHUTTING_DOWN',
'MSG_PAUSED',
'MSG_UNPAUSED',
'MSG_MAINTENANCE',
'MSG_SYNC_DONE',
'MSG_REMINDER',
'MSG_LEVELUP',
'MSG_LEVELUP_ROLE',
'REMINDER_OPTS',
'TITLE',
'ERR',
'CD_MSG',
'REMINDERS_UI',
'SEND_UI',
'PATCHNOTES_UI',
'STATUS_UI',
]
# ---------------------------------------------------------------------------
# Repeated system messages
# ---------------------------------------------------------------------------
MSG_BANNED = "🚫 Sul keelati TipiBOTi majandussüsteemis osaleda."
MSG_SPAM_JAIL = "⚠️ Liiga kiire! Automaatsed skriptid/spam on keelatud. Oled **30 minutit vangis**. Kasuta `/jailbreak`, et varem välja pääseda."
MSG_PONG = "🏓 Pong!"
MSG_RESTART_DONE = "✅ Bot on taaskäivitatud!"
MSG_RESTARTING = "🔄 Taaskäivitan..."
MSG_SHUTTING_DOWN = "⛔ Lülitan boti välja..."
MSG_PAUSED = "⏸️ Bot on hooldusrežiimis - kõik käsklused on peatatud."
MSG_UNPAUSED = "▶️ Hooldusrežiim lõpetatud - käsklused on taas saadaval."
MSG_MAINTENANCE = "🔧 Bot on hetkel hoolduses. Proovi mõne hetke pärast uuesti."
MSG_SYNC_DONE = "✅ Käsud sünkroniseeritud!"
MSG_REMINDER = "⏰ **/{cmd}** on taas kasutamiseks valmis!\n-# Meeldetuletuste haldamiseks kasuta serveris `/reminders`."
MSG_LEVELUP = "⬆️ **{name}** jõudis **{level}. tasemele**!{extra}"
MSG_LEVELUP_ROLE = " 🎊 Uus tiitel: **{role}**!"
# ---------------------------------------------------------------------------
# /reminders dropdown options (cmd_key, label, description)
# ---------------------------------------------------------------------------
REMINDER_OPTS: list[tuple[str, str, str]] = [
("daily", "📅 /daily", "Päevane boonus (20t ooteaeg)"),
("work", "💼 /work", "Töö (1t ooteaeg, 40min monitoriga)"),
("beg", "🙏 /beg", "Kerjamine (5min ooteaeg)"),
("crime", "🦹 /crime", "Kuritegu (2t ooteaeg)"),
("rob", "🔫 /rob", "Rööv (2t ooteaeg)"),
("fish", "🎣 /fish", "Kalapüük (2min ooteaeg)"),
]
# ---------------------------------------------------------------------------
# Embed titles
# ---------------------------------------------------------------------------
TITLE: dict[str, str] = {
"daily": "📅 Päevane boonus",
"work": "💼 Töö",
"beg": "🙏 Kerjamine",
"crime_win": f"{E['TipiFIRE']} Kuritegu õnnestus!",
"crime_fail": f"{E['TipiTROLL']} Vahele jäid!",
"rob_win": f"{E['TipiFIRE']} Rööv õnnestus!",
"rob_fail": f"{E['TipiTROLL']} Rööv ebaõnnestus!",
"rob_anticheat": f"{E['TipiVAC']} Anticheat peatas sind!",
"jailbreak": "🎲 Vanglast põgenemine",
"jailbreak_free": f"🎲 {E['TipiFIRE']} DUUBEL! Oled vaba!",
"jailbreak_fail": f"{E['TipICRY']} Kolm katset läbi!",
"jailbreak_miss": "🎲 " + E["TipICRY"] + " Ei saanud duublit ({tries}/{max})",
"jailbreak_bail": "💸 Kautsjon",
"give": f"{E['TipiHEART']} TipiCOINi ülekanne",
"stats": "📊 Mängustatistika",
"leaderboard_coins": "🪙 TipiBOTi edetabel - Mündid",
"leaderboard_exp": "📊 TipiBOTi edetabel - EXP / Tase",
"leaderboard_season": "🏆 TipiBOTi edetabel - Hooaja EXP",
"leaderboard_prestige": f"{E['TipiFIRE']} TipiBOTi edetabel - Prestiiž",
"leaderboard_wagered": "🎲 TipiBOTi edetabel - Hasartmängud",
"leaderboard_fish": "🎣 TipiBOTi edetabel - Kalapüük",
"rps": "⚔️ Kivi, Paber, Käärid",
"rps_duel": "⚔️ KPK duell",
"rps_duel_active": "⚔️ KPK duell - käimas",
"rps_duel_done": "⚔️ KPK duell - lõppenud",
"rps_duel_cancel": "⚔️ KPK duell - tühistatud",
"rps_duel_expire": "⚔️ KPK duell - aegus",
"rps_duel_decline": "⚔️ KPK duell - keelduti",
"heist_lobby": "🔫 Grupirööv - kogunemine",
"heist_win": f"{E['TipiFIRE']} Grupirööv õnnestus!",
"heist_fail": f"{E['TipiSKULL']} Grupirööv ebaõnnestus!",
"heist_cancel": "🔫 Grupirööv tühistatud",
"request": f"{E['TipiHEART']} Rahataotlus",
"reminders": "⏰ Meeldetuletused",
"cooldowns": "⏱️ Sinu ooteajad",
"adminseason": "🏆 Hooaeg lõppes!",
"economysetup": "⚙️ Majanduse seadistamine",
"blackjack": "🃏 Blackjack",
"blackjack_bj": f"🃏 {E['TipiFIRE']} BLACKJACK!",
"blackjack_win": f"{E['TipiFIRE']} Võitsid!",
"blackjack_lose": f"{E['TipiSKULL']} Kaotasid!",
"blackjack_bust": f"{E['TipiSKULL']} Üle 21 - kaotasid!",
"blackjack_push": "🤝 Viik!",
"blackjack_dbust": f"{E['TipiSKULL']} Üle 21 - mõlemad kaotasid!",
"blackjack_dwin": f"{E['TipiFIRE']} Topeltpanus võitis!",
"prestige_confirm": "🔥 Prestiiž - kinnita",
"prestige_success": E["TipiFIRE"] + " Prestiiž {level} saavutatud!",
"prestige_too_low": "❌ Prestiiž pole saadaval",
"prestige_shop": f"{E['TipiFIRE']} Prestiižipood",
"prestige_buy_ok": "✅ Uuendus ostetud!",
"fish_cast": "🎣 Otsid kala...",
"fish_bite": "🐟 KALA NÄKKAB!",
"fish_escape": "🎣 Kala pääses!",
"fish_junk": "🗑️ Ai ai ai...",
"fishbook": "📖 Kalakogu",
"quests": "🎯 Ülesanded",
}
# ---------------------------------------------------------------------------
# Error / validation messages (use .format(**kwargs) for dynamic parts)
# ---------------------------------------------------------------------------
ERR: dict[str, str] = {
"positive_bet": "❌ Panus peab olema positiivne.",
"positive_amount": "❌ Summa peab olema positiivne.",
"positive_duration": "❌ Aeg peab olema positiivne.",
"admincoins_zero": "Kogus ei tohi olla 0.",
"missing_perms": "❌ Sul on vaja **Rollide haldamise** õigust selle käsu kasutamiseks.",
"generic_error": "❌ Tekkis viga: ```{error}```",
"send_failed": "❌ Sõnumi saatmine ebaõnnestus: ```{error}```",
"rob_self": "❌ Sa ei saa iseennast röövida.",
"rob_bot": "❌ Botti ei saa röövida.",
"rob_no_house": "❌ Kassa pole veel avatud.",
"rob_house_blocked": "❌ Panga seif on liiga hästi kaitstud. Kasuta `/heist` selle rüüstamiseks.",
"rob_too_poor": "❌ **{name}** on liiga vaene röövimiseks.",
"rob_target_jailed": "❌ **{name}** on vanglas - vabad inimesed ei saa vanglas olevaid röövida.",
"heist_active": "❌ Serveris on juba aktiivne grupirööv käimas! Oota, kuni see lõpeb.",
"heist_full": "❌ Grupirööv on täis!",
"heist_min_players": "❌ Grupiröövi alustamiseks on vaja vähemalt **{min}** osalejat.",
"broke": E["TipICRY"] + " Sul pole piisavalt TipiCOINe. Saldo: {bal}",
"broke_need": E["TipICRY"] + " Sul pole piisavalt TipiCOINe. Vajad veel {need}.",
"item_owned": "❌ Sul on see ese juba olemas.",
"item_not_found": "❌ Eset ei leitud.",
"item_level_req": "🔒 Selle eseme ostmiseks vajad **taset {min_level}** (sul on tase {user_level}). Teeni EXP-id kõiki käske kasutades.",
"not_your_game": "❌ See pole sinu mäng!",
"game_in_progress": "❌ Sul on juba mäng käimas! Lõpeta see enne.",
"not_your_challenge":"❌ See väljakutse pole sulle!",
"not_your_menu": "❌ See ei ole sinu menüü.",
"give_self": "❌ Sa ei saa iseendale TipiCOINe anda.",
"give_bot": "❌ Botile ei saa TipiCOINe anda.",
"give_jailed": "❌ Oled vangis - vanglas ei saa TipiCOINe anda. Pääsed välja {ts}.",
"rps_self": "❌ Sa ei saa iseendale väljakutset esitada.",
"rps_bot": "❌ Botid on KPK-is liiga head.",
"not_jailed": "❌ Sa pole praegu vangis.",
"not_in_leaderboard":"❌ Sind pole veel edetabelis.",
"admin_ban_bot": "❌ Botti ei saa bannida.",
"admin_reset_bot": "❌ Kassa andmeid ei saa lähtestada.",
"member_not_found": "❌ **{name}** ei leitud tabelist.",
"request_self_fund": "❌ Sa ei saa oma taotlust ise rahastada.",
"request_self": "❌ Sa ei saa iseendalt anuda.",
"request_bot": "❌ Botid on teatavasti kitsid.",
"request_targeted": "❌ See taotlus on suunatud kasutajale **{name}**.",
"request_closed": "❌ See taotlus on juba rahastatud või aegunud.",
"already_in_game": "❌ Sul on juba aktiivne mäng käimas!",
"invalid_amount": "❌ Sisesta kehtiv summa või 'all'.",
"fund_range": "❌ Sisesta summa vahemikus 1-{max}.",
"channel_only": "❌ Boti käske saab kasutada ainult nendes kanalites: {channels}",
"guild_only": "Seda käsku saab kasutada ainult serveris.",
"sheet_error": "❌ Tabeli laadimine ebaõnnestus: ```{error}```",
"gamble_cooldown": "🎰 Oled just mänginud! Saad uuesti mängida {ts}.",
"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.",
}
# ---------------------------------------------------------------------------
# Cooldown messages (use .format(ts=...) - ts is a Discord timestamp string)
# ---------------------------------------------------------------------------
CD_MSG: dict[str, str] = {
"daily": "⏳ Järgmine boonus {ts}.",
"work": "⏳ Saad töötada {ts}.",
"beg": "⏳ Saad kerjata {ts}.",
"crime": "⏳ Saad uuesti proovida {ts}.",
"rob": "⏳ Saad uuesti röövida {ts}.",
"heist": "⏳ Saad uuesti heisti teha {ts}.",
"heist_global": "⏳ Pangahoidla alles kosub eelmisest röövist. Järgmine heist võimalik {ts}.",
"jailed": E["TipiTROLL"] + " Oled vangis! Pääsed välja {ts}. Kasuta `/jailbreak`, et varem välja pääseda.",
"fish": "🎣 Saad uuesti kalastada {ts}.",
}
REMINDERS_UI: dict[str, str] = {
"select_placeholder": "Vali käsud, millest soovid meeldetuletust...",
"saved_on": "✅ Meeldetuletused sisse lülitatud: {names}",
"saved_off": "🔕 Kõik meeldetuletused välja lülitatud.",
"desc_active": "Praegu aktiivne: {status}\n\nMuuda valikut allpool.",
"desc_none": "Hetkel pole ühtegi meeldetuletust sisse lülitatud.\n\nVali allpool, millest soovid DM-i saada.",
"footer": "Bot DM-ib sulle, kui valitud käsk on taas kasutamiseks valmis.",
}
# ---------------------------------------------------------------------------
# /send UI strings
# ---------------------------------------------------------------------------
SEND_UI: dict[str, str] = {
"sent": "✅ Sõnum saadetud kanalisse {channel}!",
"forbidden": "❌ Mul pole õigust kanalisse {channel} kirjutada.",
}
# ---------------------------------------------------------------------------
# /patchnotes UI strings
# ---------------------------------------------------------------------------
PATCHNOTES_UI: dict[str, str] = {
"title": "📝 Muudatuste logi — {version}",
"footer": "Versioon {idx}/{total}",
"btn_newer": "◀ Uuem",
"btn_older": "Vanem ▶",
"select_placeholder": "Vali versioon…",
"empty_file": " Muudatuste logi on hetkel tühi.",
"empty_version": "_(selle versiooni kohta märkmeid pole)_",
}
# ---------------------------------------------------------------------------
# /status UI
# ---------------------------------------------------------------------------
STATUS_UI: dict[str, str] = {
"title": "🖥️ Boti olek",
"uptime_field": "🕐 Uptime",
"uptime_val": "{hours}t {minutes}m {seconds}s",
"latency_field": "📡 Latency",
"latency_val": "{ms} ms",
"ram_field": "🧠 RAM (RSS)",
"ram_val": "{mb} MB",
"cpu_field": "⚙️ CPU",
"cpu_val": "{percent}%",
"tasks_field": "🔄 Async tasks",
"eco_players_field": "👤 Eco players",
"members_cache_field": "📋 Liikmed (cache)",
"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": "-",
}

552
strings/economy.py Normal file
View File

@@ -0,0 +1,552 @@
"""Core economy: income, profile, shop, quests, leaderboard, requests.
Auto-split from the original strings.py; edit strings here.
"""
from core.emoji import EMOJI as E
__all__ = [
'WORK_JOBS',
'BEG_LINES',
'BEG_JAIL_LINES',
'CRIME_WIN',
'CRIME_LOSE',
'QUEST_UI',
'QUEST_DESCRIPTIONS',
'SHOP_UI',
'ITEM_DESCRIPTIONS',
'CONSUMABLES_UI',
'CONSUMABLE_DESCRIPTIONS',
'VANITY_UI',
'LOOTBOX_UI',
'BANK_UI',
'ACHIEVEMENTS_UI',
'LOTTERY_UI',
'JAILED_UI',
'SHOP_BTN',
'DAILY_UI',
'STATS_UI',
'PROFILE_UI',
'BALANCE_UI',
'COOLDOWNS_UI',
'RANK_UI',
'WORK_UI',
'BEG_UI',
'CRIME_UI',
'ROB_UI',
'GIVE_UI',
'BUY_UI',
'LEADERBOARD_UI',
'REQUEST_UI',
]
# ---------------------------------------------------------------------------
# Flavour text
# ---------------------------------------------------------------------------
WORK_JOBS: list[tuple[str, float]] = [
("paigaldasid aulas netikaableid", 1.0),
("paigaldasid aulas voolukaableid", 1.0),
("tõid poest korraldajatele pitsat", 1.0),
("konfigureerisid uue switchi", 1.0),
("vedasid laudu aulasse", 1.0),
("vedasid toole aulasse", 1.0),
("paigaldasid aulasse vahekardinaid", 1.0),
("aitasid üles seada turniiri lava", 1.0),
("töötasid baaris", 1.0),
("testisid helisüsteemi \"Erootikapoodi\" blastides", 1.0),
("juhendasid külastajaid infolauas", 1.0),
("seadistasid LAN serverit", 1.0),
("paigaldasid monitore", 1.0),
("ühendasid aulas kilpe", 1.0),
("aitasid LANil osalejat", 1.0),
("müüsid energiajooke", 1.0),
("tegid netikaableid", 1.0),
("lahendasid IP-konflikti", 1.0),
("parandasid kellegi arvutit", 1.0),
("tegid korda striimi heli (mitte nagu PGL)", 1.2),
("kirjutasid koodi miniturniiri jaoks", 1.2),
("võitsid turniiri", 1.5),
]
BEG_LINES: list[str] = [
"istusid kurva näoga oma monitori taga",
"palusid abi kellegi DMis",
"postitasid #abi kanalisse",
"vaatasid kadedusega teiste asju",
"kirjutasid /beg kolmandat korda järjest",
"küsisid oma tiimiliikmelt laenu",
"võtsid kiirlaenu",
"müüsid 3 sendi eest oma CSi skinne",
"korjasid kampuselt taarat",
"tegid TikToki live'i",
"refreshisid pangaäppi lootuses, et raha tekib",
"kirjutasid \"pls donate\" chatti",
"vaatasid oma tühja rahakotti",
"püüdsid kedagi guilt-trippida",
"lubasid exposure'it raha eest",
"mängisid kurba muusikat taustaks",
"teesklesid, et see on sotsiaalne eksperiment",
"kirjutasid emotsionaalse loo oma olukorrast",
"pakkusid vastu future equity't",
"tegite sõbraga koos kerjamisplaani",
"otsisid diivani vahelt sente",
"lubasid boostida kellegi ranki viieka eest",
"küsisid chatist, kas keegi teab kust tasuta V-Buckse saab",
"avastasid, et su krüptoportfell on sügavas miinuses",
]
BEG_JAIL_LINES: list[str] = [
"karjusid läbi trellide, et keegi münte läbi lükkaks",
"kirjutasid kongi seina peale oma PayPali aadressi",
"lubasid, et maksad järgmine kord vangi minnes tagasi",
"veensid valvurit, et oled tegelikult hea inimene",
"saatsid vanglast käsitsi kirjutatud kirja oma meeskonnale",
"pakkusid vahile tulevaste vihjete eest raha",
"koputasid morset vastu naaberkongi seina",
"üritasid vangla WiFit häkkida",
"lubasid teha tasuta IT-tööd vabanemise eest",
"üritasid lusikaga tunnelit kaevata",
"kirjutasid bug reporti vangla süsteemile",
"proovisid trellidest läbi pugeda, aga jäid kinni",
"pakkusid vangivalvurile oma Steami kontot vabanemise eest",
]
CRIME_WIN: list[str] = [
"häkkisid turniiri tulemuste tabelit",
"varastasid oma vastase hiire",
"varastasid oma vastase klaviatuuri",
"varastasid oma vastase kõrvaklapid",
"varastasid oma vastase monitori",
"varastasid oma vastase mikrofoni",
"müüsid võltsitud LANi pileteid",
"laenatasid kellegi GPUd ja ei tagastanud seda",
"laenatasid Filmiklubilt AUX-kaablit ja ei tagastanud seda",
"käivitasid DDoS rünnaku serverile",
"tegid oma shitcoin'idega pump and dump'i",
"kasutasid aimbotti ja keegi ei märganud",
"exploitisid mängu bugi enda kasuks",
"phishisid kellegi Steami konto",
"varastasid prize pooli raha",
"ühendasid enda krüptokaevandaja serverisse",
"tegid petukõnesid"
]
CRIME_LOSE: list[str] = [
"jäid turniiri adminile vahele",
"tõmbasid oma arvuti kaabli pistikust välja",
"kukutasid lahtise Red Bulli omale sülle",
"unustasid VPNi sisse lülitada",
"lagisid kriitilisel momendil",
"said banni otseülekande ajal",
"unustasid logid kustutada",
"sinu skript crashis valel hetkel",
"said reportitud mitme mängija poolt",
"sinu krüptokaevandaja süttis põlema",
"flashisid tervet oma tiimi",
"avastasid, et Windows otsustas keset mängu uuendama hakata",
"jäid politseile petukõnedega vahele"
]
# ---------------------------------------------------------------------------
# Quest system (/quests)
# ---------------------------------------------------------------------------
QUEST_UI: dict[str, str] = {
"daily_header": "📅 Päevaülesanded",
"weekly_header": "📆 Nädalaülesanded",
"progress": "{progress}/{max}",
"ready": "✅ Valmis - nõua auhind!",
"completed": "🏆 Nõutud",
"reward": "🎁 {coins} ⬡ · {exp} EXP",
"empty": "Ühtegi ülesannet pole. Proovi hiljem uuesti!",
"claim_btn": "🎁 Nõua auhinnad",
"claimed_msg": "🏆 Nõudsid {count} ülesande auhinnad: +{coins} · +{exp} EXP!",
"nothing": "Sul pole ühtegi valmis ülesannet, mida nõuda.",
"error": "❌ Ülesannete laadimine ebaõnnestus. Proovi hiljem uuesti.",
}
# Estonian one-line description per quest id (keys match economy.QUESTS_*).
QUEST_DESCRIPTIONS: dict[str, str] = {
# Daily
"work3": "Tööta 3 korda (/work)",
"beg5": "Kerja 5 korda (/beg)",
"wager500": "Panusta kokku 500 ⬡ hasartmängudes",
"fish2": "Püüa 2 kala (/fish)",
"crime1": "Soorita edukalt 1 kuritegu (/crime)",
"earn1000": "Teeni kokku 1 000 ⬡",
"give200": "Kingi teistele kokku 200 ⬡ (/give)",
# Weekly
"work20": "Tööta 20 korda (/work)",
"fish15": "Püüa 15 kala (/fish)",
"wager5000": "Panusta kokku 5 000 ⬡ hasartmängudes",
"crime5": "Soorita edukalt 5 kuritegu (/crime)",
"heist1": "Osale 1 grupiröövis (/heist)",
"earn10000": "Teeni kokku 10 000 ⬡",
}
# ---------------------------------------------------------------------------
# Shop UI strings
# ---------------------------------------------------------------------------
SHOP_UI: dict[str, str] = {
"tier_1": "Tier 1 - Algaja",
"tier_2": "Tier 2 - Kogenud",
"tier_3": "Tier 3 - Legend",
"desc": "Saldo: {bal} · Osta käsuga `/buy`",
"owned": "✅ Olemas",
"owned_uses_1": "✅ Olemas ({uses} kasutus järel)",
"owned_uses_n": "✅ Olemas ({uses} kasutust järel)",
"locked": "🔒 Tase {min_lvl} nõutud *(sul on {user_lvl})*",
}
# ---------------------------------------------------------------------------
# Shop item descriptions (keyed by SHOP dict key in economy.py)
# ---------------------------------------------------------------------------
ITEM_DESCRIPTIONS: dict[str, str] = {
"gaming_hiir": "Koolist varastatud hiir? Ei, see on mängurihiir. Teeni töötades 50% rohkem TipiCOINe.",
"hiirematt": "XXL suuruses, ainult parimast materjalist. Kerjamise ooteaeg 5min → 3min.",
"korvaklapid": "Noise-cancelling - kuuled ainult TipiCOINide kõlinat. Päevase boonuse ooteaeg 20h → 18h + 25⬡ boonust.",
"lan_pass": "Ametlik TipiLANi pilet (2025). Päevane boonus on duubeldatud.",
"energiajook": "Kolm Red Bulli järjest. 30% tõenäosus, et teenid töötades 3x rohkem.",
"gaming_laptop": "RTX 5090 jooksutab botte 24/7. Päevane boonus genereerib 5% intressi sinu saldo pealt.",
"anticheat": "VAC, EAC, Faceit AC ja BattlEye korraga. Röövimine sinu vastu ebaõnnestub. **2 kasutust**, siis pead ostma uue.",
"reguleeritav_laud": "Võid nii seista kui istuda - alati võidad. /work teenib 25% rohkem (stackib mängurihiirega).",
"jellyfin": "Self-hosted meediaserver - oled suurfirmadest sõltumatu. Röövimise edu 45% → 60%. Grupiröövi õnnestumisele +5%.",
"mikrofon": "Parem helikvaliteet teeb sind usutavamaks. Teeni 30% rohkem eduka /crime puhul.",
"klaviatuur": "Klõbinad kostuvad üle kogu saali. /beg teenib 2x rohkem.",
"monitor": "240Hz ja 27 tolli. /work ooteaeg: 1h → 40min.",
"cat6": "Gigabitine internet = ideaalne piraatluseks. /crime edu tõenäosus tõuseb 60% → 75%.",
"monitor_360": "360Hz, 1ms. Mänguautomaadi jackpot 10x → 15x, kolmik 4x → 6x. Hasartmängude ooteaeg 30s → 25s.",
"karikas": "Ainult legendidele. Streak ei nulli, kui sa mõne päeva vahele jätad.",
"gaming_tool": "Nii mugav, et isegi admin ei saa sind üles. /crime ebaõnnestumine ei saada sind vanglasse.",
"ussipurk": "Lakkumatu toiduga ussipurk - kalad ei saa vastu. Kalapyygi ooteaeg 2min → 90s.",
"kalavork": "Suurem võrk = suuremad kalad. Kõigi kalade haruldus tõuseb ühe astme võrra.",
"echolood": "Täpne ehholood näitab kala täpset asukohta. Haukamise aken 2s → 3s.",
}
# ---------------------------------------------------------------------------
# Consumables (repeatable, expiring boosts - a recurring coin sink)
# ---------------------------------------------------------------------------
CONSUMABLE_DESCRIPTIONS: dict[str, str] = {
"energy_xl": "Topeltannus kofeiini. **1 tund**: /work, /beg ja /crime teenivad **2x** rohkem.",
"xp_potion": "Kahtlane roheline jook. **1 tund**: kõik EXP-allikad annavad **2x** rohkem.",
"kohv": "Kange kohv äratab su üles. Nullib kohe kõik ooteajad (work, beg, crime, rob, fish).",
}
CONSUMABLES_UI: dict[str, str] = {
"title": "☕ Turgutused",
"desc": "Ühekordsed, korduvostetavad boostid. Saldo: {bal} · Osta `/consumables <ese>`",
"active_header": "⏳ Aktiivsed boostid",
"active_none": "Ühtegi boosti pole aktiivne.",
"buff_line": "{name} - veel **{time}**",
"kind_earn": "⚡ 2x tulu",
"kind_exp": "✨ 2x EXP",
"bought_title": "{emoji} {name} ostetud!",
"bought_buff": "Boost on aktiivne **{time}**.\nUus saldo: {balance}",
"bought_extended": "Boosti pikendati - aktiivne veel **{time}**.\nUus saldo: {balance}",
"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.",
"entry": "{mention} - vabaneb <t:{ts}:R>",
"footer": "{count} vang{plural}",
}
SHOP_BTN: dict[int, str] = {
1: "Tier 1",
2: "Tier 2",
3: "Tier 3",
}
# ---------------------------------------------------------------------------
# /daily embed strings
# ---------------------------------------------------------------------------
DAILY_UI: dict[str, str] = {
"earned": "✅ Said {earned}!",
"interest": E["TipiLAP"] + " Bot Farm tootis: +{interest}",
"vip": f"{E['TipiPILET']} LAN pileti boonus rakendus!",
"footer": "Streak: {streak_str} · Saldo: {balance}",
}
# ---------------------------------------------------------------------------
# /stats embed strings
# ---------------------------------------------------------------------------
STATS_UI: dict[str, str] = {
"economy_field": "💰 Majandus",
"economy_val": "Tipp-saldo: {peak}\nEluaegne tulu: {earned}\nEluaegne kaotus: {lost}",
"work_field": "🛠️ Töö & Kerja",
"work_val": "Töötanud: **{work}** korda\nKerjanud: **{beg}** korda",
"gamble_field": "🎲 Hasartmängud",
"gamble_val": "Panustatud kokku: {wagered}\nSuurim võit: {win}\nSuurim kaotus: {loss}\nSlotsi jackpotid: **{jackpots}** korda",
"crime_field": "🦹 Kuritegevus",
"crime_val": "Kuriteod: **{crimes}** ({succeeded} õnnestus)\nHeistid: **{heists}** ({heists_won} võideti)\nVangi sattunud: **{jailed}** korda\nKautsjon makstud: {bail}",
"social_field": "🤝 Sotsiaalne",
"social_val": "Kingitud: {given}\nSaadud: {received}",
"records_field": "🔥 Rekordid",
"records_val": "Pikim päevane streak: **{streak}** päeva\n🎁 Õnnekaste avatud: **{lootboxes}**\n🏅 Saavutusi: **{achievements}/{ach_total}**",
}
# ---------------------------------------------------------------------------
# /profile tabbed view
# ---------------------------------------------------------------------------
PROFILE_UI: dict[str, str] = {
"btn_profile": "💰 Profiil",
"btn_items": "🎒 Esemed",
"btn_stats": "📊 Statistika",
"btn_fish": "🎣 Kalakogu",
"main_title": "💰 {name}",
"items_title": "🎒 {name} - Esemed",
"stats_title": "📊 {name} - Statistika",
"fish_title": "🎣 {name} - Kalakogu",
"items_empty": "Sul pole ühtegi eset.",
"f_balance": "💰 Saldo",
"f_level": "📊 Tase",
"f_streak": "🔥 Streak",
"f_prestige": "⭐ Prestiiz",
"f_jail": "🚔 Vangis kuni",
"f_progress": "→ Tase {next}",
"progress_bar": "`{bar}` {done}/{needed} EXP",
"level_val": "Tase {level} - {role}",
"prestige_val":"⭐ P{level} · {pp} PP",
"footer_t1": "Tase 10 avab T2 poe · Tase 20 avab T3 poe",
"footer_t2": "T2 pood avatud · Tase 20 avab T3 poe",
"footer_t3": "T2 ja T3 pood avatud",
}
# ---------------------------------------------------------------------------
# /balance embed strings
# ---------------------------------------------------------------------------
BALANCE_UI: dict[str, str] = {
"saldo": "Saldo",
"streak": "Päeva streak",
"streak_val": "🔥 {streak}p",
"jailed_until": "🚔 Vangis kuni",
"items": "Esemed",
"uses_one": " *({uses} kasutus järel)*",
"uses_many": " *({uses} kasutust järel)*",
}
# ---------------------------------------------------------------------------
# /cooldowns embed strings
# ---------------------------------------------------------------------------
COOLDOWNS_UI: dict[str, str] = {
"ready": "✅ Valmis",
"daily_line": "📅 **/daily** {status}{note}",
"work_line": "💼 **/work** {status}{note}",
"beg_line": "🙏 **/beg** {status}{note}",
"crime_line": "🦹 **/crime** {status}",
"rob_line": "🔫 **/rob** {status}",
"fish_line": "🎣 **/fish** {status}{note}",
"note_korvak": " *(kõrvaklapid: 18t)*",
"note_monitor": " *(monitor: 40min)*",
"note_hiirematt": " *(hiirematt: 3min)*",
"note_ussipurk": " *(ussipurk: 90s)*",
"jailed": "\n🔒 **Vanglas** - vabaneb <t:{ts}:R>",
"jail_expired": "\n🔓 Vangla lõppes",
}
# ---------------------------------------------------------------------------
# /rank embed strings
# ---------------------------------------------------------------------------
RANK_UI: dict[str, str] = {
"title": "📊 {name} - Tase {level}",
"field_title": "Tiitel",
"field_exp": "EXP",
"field_progress": "Progress → Tase {next}",
"progress_val": "`{bar}` {progress}/{needed} EXP",
"footer_t1": "Tase 10 avab T2 poe · Tase 20 avab T3 poe",
"footer_t2": "T2 pood avatud · Tase 20 avab T3 poe",
"footer_t3": "T2 ja T3 pood avatud",
}
# ---------------------------------------------------------------------------
# /work embed strings
# ---------------------------------------------------------------------------
WORK_UI: dict[str, str] = {
"desc": "Sa {job} ja teenisid {earned}!",
"redbull": f"\n{E['TipiBULL']} Red Bull aktiveerus - 3x boonus!",
"hiir": f"\n{E['TipiHIIR']} Mängurihiir: +50% palk",
"laud": f"\n{E['TipiLAUD']} Reguleeritav laud: +25% palk",
"balance": "\nSaldo: {balance}",
}
# ---------------------------------------------------------------------------
# /beg embed strings
# ---------------------------------------------------------------------------
BEG_UI: dict[str, str] = {
"desc": "Sa {text} ja said {earned}.",
"klaviatuur": f"{E['TipiKLAVA']} Mehhaaniline klaviatuur: 2x tulu",
"balance": "Saldo: {balance}",
}
# ---------------------------------------------------------------------------
# /crime embed strings
# ---------------------------------------------------------------------------
CRIME_UI: dict[str, str] = {
"win_desc": "Sa {text} ja teenisid {earned}!",
"fail_base": "Sa {text} ja said trahvi {fine}.",
"fail_jailed": "\n\ud83d\udd12 Oled vangis! P\u00e4\u00e4sed {ts}.",
"fail_shield": "\n\ud83d\udee1\ufe0f Gaming Tool hoidis sind vanglast!",
"mikrofon": f"\n{E['TipiMIC']} Mikrofon: +30% saak",
"cat6": f"\n{E['TipiCAT']} CAT6: 75% edu t\u00f5en\u00e4osus",
"balance": "\nSaldo: {balance}",
}
# ---------------------------------------------------------------------------
# /rob embed strings
# ---------------------------------------------------------------------------
ROB_UI: dict[str, str] = {
"jackpot_desc": "💥 **JACKPOT!** Murdsid TipiBOTi kassasse sisse ja varastasid {stolen}!\nSaldo: {balance}",
"win_desc": "Varastasid {stolen} kasutajalt **{name}**!\nSaldo: {balance}",
"anticheat_desc": "**{name}** kaitseb end Anticheati'ga - said trahvi {fine}.",
"anticheat_worn": "⚠️ Sinu **Anticheat** on kulunud! Osta uus `/buy` käsuga.",
"victim_dm": "💸 **{robber}** varastas sinult **{stolen}** münti!",
"fail_desc": "Jäid vahele! Trahv: {fine}.\nSaldo: {balance}",
}
# ---------------------------------------------------------------------------
# /give embed strings
# ---------------------------------------------------------------------------
GIVE_UI: dict[str, str] = {
"desc": "**{giver}** andis {amount} kasutajale **{receiver}**.",
}
# ---------------------------------------------------------------------------
# /buy embed strings
# ---------------------------------------------------------------------------
BUY_UI: dict[str, str] = {
"title": "{emoji} {name} ostetud!",
"desc": "{description}\nUus saldo: {balance}",
}
# ---------------------------------------------------------------------------
# /leaderboard UI strings
# ---------------------------------------------------------------------------
LEADERBOARD_UI: dict[str, str] = {
"house_entry": "🤖 {name} *(maja)* - {balance}",
"house_default_name": "TipiBOT",
"no_entries": "Keegi ei ole veel punkte teeninud.",
"footer": "Lehekülg {page}/{total} · {count} mängijat",
"btn_coins": "🪙 Mündid",
"btn_exp": "📊 EXP",
"btn_find_me": "📍 Mina",
"exp_entry": "{prefix} {name} - {exp} EXP *(Tase {level})*",
"unknown_user": "Kasutaja {uid}",
"btn_season": "🏆 Hooaeg",
"btn_prestige": "🔥 Prestiiž",
"btn_wagered": "🎲 Hasartmäng",
"btn_fish": "🎣 Kalapyyk",
"season_entry": "{prefix} {name} - {exp} EXP *(Prestiiž {prestige})*",
"prestige_entry": "{prefix} {name} - Prestiiž **{prestige}** · {pp} PP",
"wagered_entry": "{prefix} {name} - {wagered} panustatud",
"fish_entry": "{prefix} {name} - {caught} kala",
}
# ---------------------------------------------------------------------------
# /request UI strings
# ---------------------------------------------------------------------------
REQUEST_UI: dict[str, str] = {
"modal_title": "Rahasta taotlust",
"modal_label": "Kui palju soovid panustada?",
"btn_fund": "Rahasta 💸",
"btn_funded": "Rahastatud ✅",
"btn_fund_remaining": "Rahasta 💸 ({remaining}⬡ jäänud)",
"funded_line": "✅ **{name}** panustas {amount}",
"funded_full": "\n🎯 Taotlus täielikult täidetud!",
"funded_partial": "\n💰 Jäänud: {remaining}",
"audience_all": "Avatud kõigile rahastajatele 🌍",
"audience_targeted": "Suunatud: **{name}**",
"desc": "**{requester}** palub {amount}\n\n📋 **Põhjus:** {reason}\n\n{audience}",
"footer": "Taotlus aegub 5 minuti pärast",
}

86
strings/fishing.py Normal file
View File

@@ -0,0 +1,86 @@
"""Fishing minigame: fish catalogue, rarities, and /fish UI.
Auto-split from the original strings.py; edit strings here.
"""
__all__ = [
'FISH_NAMES',
'FISH_RARITY_NAMES',
'FISH_RARITY_EMOJI',
'FISH_JUNK_LINES',
'FISH_UI',
]
# ---------------------------------------------------------------------------
# Fishing system strings
# ---------------------------------------------------------------------------
FISH_NAMES: dict[str, str] = {
"sarj": "Särg",
"ahven": "Ahven",
"koger": "Koger",
"viidikas": "Viidikas",
"latikas": "Latikas",
"karpkala": "Karpkala",
"linask": "Linask",
"haug": "Haug",
"angerjas": "Angerjas",
"siig": "Siig",
"forell": "Forell",
"koha": "Koha",
"tougjas": "Tõugjas",
"lohe": "Lõhe",
"vimb": "Vimb",
}
FISH_RARITY_NAMES: dict[str, str] = {
"common": "Tavaline",
"uncommon": "Ebatavaline",
"rare": "Haruldane",
"epic": "Eepiline",
"legendary": "Legendaarne",
}
FISH_RARITY_EMOJI: dict[str, str] = {
"common": "🐟",
"uncommon": "🐠",
"rare": "🎣",
"epic": "",
"legendary": "🌟",
}
FISH_JUNK_LINES: list[str] = [
"Sa saad... **vana saabas**. Klassika.",
"Õnnitlused, leidsid **kasutatud autorehvi**. Keskkond tänab sind... mitte.",
"Taas üks **klaaspudel** rohkem jões.",
"**Vana poes käimise kott**! Hoidis aega hästi.",
"**Roostes konserv** - ilma sildita. Parem mitte teada, mis sees on.",
"**Ummistunud drenaažitoru**. Keegi oli hoolimatu.",
"**Tühi rahakott**. Kellegi päev läks halvemaks kui sinu oma.",
"**Vana CD-plaat** - Evanescence, 2003. Heas seisukorras.",
"Sa said **kivikese**. Ilus kivikene. Aga siiski kivikene.",
"**Kaotsi läinud droon**. GPS ei tööta, aku tühi.",
]
FISH_UI: dict[str, str] = {
"btn_wait": "🎣 Oota näkkamist...",
"btn_bite": "🐟 TÕMBA!",
"btn_sell": "💰 Müü",
"btn_keep": "🎒 Hoia",
"cast_desc": "Viskad õnge vette. Oota, kuni kala näkkab...\n\n-# Vajuta nuppu, kui kala näkkab!",
"bite_desc": "**KALA NÄKKAB!** Tõmba kiiresti! ⚡\n\n-# Sul on 2 sekundit!",
"escape_desc": "Liiga hilja - kala lipsas minema. Proovi järgmine kord kiiremini!",
"junk_desc": "{text}\n\n-# Saldo: {balance}",
"catch_desc": "**{name}** · {weight}g · +{exp} EXP\n-# Kas müüd kohe ({value}) või hoiad inventaris?",
"catch_sold": "**{name}** · {weight}g\n\n+{coins} · +{exp} EXP\nSaldo: {balance}",
"catch_kept": "**{name}** · {weight}g lisatud inventarisse. *(+{exp} EXP)*",
"new_fish": "\n✨ **Uus kala kalakogusse lisatud!**",
"too_early": "❌ Kala pole veel näkkanud! Oota...",
"book_caught": "Püütud kalaliike: **{caught}/{total}**",
"book_yes": "{emoji} **{name}** *({rarity})* · {count}×{inv}",
"book_inv": " *(inventaris: {n})*",
"book_no": "❓ **???** *({rarity})*",
"book_footer": "Lehekülg {page}/{total_pages} · {caught}/{total} liiki",
"book_empty": "Sa pole veel ühtegi kala püüdnud! Kasuta `/fish`.",
"inv_empty": "Sinu kalainventaar on tühi! Kasuta `/fish` kala püüdmiseks.",
"inv_header": "Sul on **{count}** kala inventaris *(kokku väärt {total_value})*",
"inv_entry": "{emoji} **{name}** · {weight}g · {value}",
"inv_sold_all": "Müüsid **{count}** kala kokku {coins} eest!\nSaldo: {balance}",
"inv_none": "Inventaaris pole midagi müüa.",
}

307
strings/games.py Normal file
View File

@@ -0,0 +1,307 @@
"""Minigames & gambling: slots, roulette, RPS, blackjack, heist, jailbreak.
Auto-split from the original strings.py; edit strings here.
"""
from core.emoji import EMOJI as E
__all__ = [
'SLOTS_TIERS',
'ROULETTE',
'HEIST_STORY',
'HEIST_UI',
'BJ',
'JAILBREAK_UI',
'SLOTS_UI',
'RPS_UI',
'RPS_CHOICES',
'BJ_UI',
]
# ---------------------------------------------------------------------------
# /slots outcome strings (title, colour)
# ---------------------------------------------------------------------------
SLOTS_TIERS: dict[str, tuple[str, int]] = {
"jackpot": (f"{E['TipiFIRE']} JACKPOT!!!", 0xF4C430),
"triple": ("🎰 Kolmik!", 0x57F287),
"pair": ("🎰 Paar", 0x99AAB5),
"miss": (f"{E['TipICRY']} Ei õnnestunud", 0xED4245),
}
# ---------------------------------------------------------------------------
# /roulette outcome strings
# ---------------------------------------------------------------------------
ROULETTE: dict = {
"emoji": {"punane": "🔴", "must": "", "roheline": "🟢"},
"genitive": {"punane": "punase", "must": "musta", "roheline": "rohelise"},
"win_title": "{emoji} Võitsid!",
"lose_title": "{emoji} Kaotasid!",
"win_desc": "Ratas peatus **{genitive}** peal!{mult}\n+{change}\nSaldo: {balance}",
"lose_desc": "Ratas peatus **{genitive}** peal.\n-{change}\nSaldo: {balance}",
"spin_title": "🎰 Ratas keerleb...",
"spin_stop": "🎰 Ratas peatub...",
"spin_strip": "{s0} {s1} {s2} {s3} {s4}",
}
# ---------------------------------------------------------------------------
# /heist narrative story lines
# Placeholders: {leader}, {member}, {names}, {vehicle}
# ---------------------------------------------------------------------------
HEIST_STORY: dict = {
"vehicles": [
"Škoda",
"BMW",
"Lada",
"Mazda",
],
"arrival": [
"{leader} keerab nurga taga {vehicle} mootori kinni. Maskid ette. Keegi ei räägi.",
"{vehicle} peatub ühe kvartali kaugusel sihtmärgist. {leader}: *\"Pank on seal. Tegutseme plaani järgi.\"*",
"Meeskond astub {vehicle}st välja. {leader} kuulab kõrvamonitori. *\"Kaks valvurit, üks pimeala. Liigume nüüd.\"*",
"{leader} jälgib {vehicle}st sissepääsu. *\"Valvurite vahetus 40 sekundi pärast. See on meie ajaaken.\"*",
"Kell 3 öösel. Tänav on tühi. {vehicle} seisab väljas tühikäigul. {leader} tõmbab maski ette.",
"{vehicle} veereb aeglaselt vaiksesse pimedasse vahetänavasse. {leader}: *\"Viimane kontroll. Kõik valmis?\"*",
"{leader} koputab armatuurlauale. *\"Kui midagi läheb valesti, me lahkume kohe.\"* Vaikus vastuseks.",
"Tuul sahiseb mööda tühja tänavat. {vehicle} uksed avanevad korraga.",
"{member} kontrollib relva. {leader} vaatab kella. *\"Me oleme graafikus.\"*",
"{vehicle} tuled kustuvad. Linn jääb vaikseks. Nad liiguvad.",
],
"entry_sneaky": [
"{member} tõmbab kloonitud kaarti külgukse juures. Lukk klõpsab hääleta lahti.",
"Meeskond libiseb sisse laadimisestakaadi kaudu, riietatud öisteks koristajateks.",
"{leader} on kaks nädalat valvurite graafikut pähe õppinud. Röövlid kõnnivad sisse vahetuse ajal.",
"Võltsitud alltöövõtja kaardi abil saavad nad fuajeest läbi ilma lisaküsimuseta.",
"{member} lülitab välikaamerad sülearvutist välja. {leader} kõnnib sisse, nagu see oleks tema oma maja.",
"{member} kasutab signaaliblokeerijat. Häired ei jõua kunagi süsteemi.",
"{leader} avab ventilatsiooniluugi. *\"Läheme ülevalt.\"*",
"Turvamees haigutab. Sekund hiljem on ta seotud ja vaikselt nurka lohistatud.",
"{member} süstib lukku mikrokaamera. Mehhanism kaardistatakse sekunditega.",
],
"entry_loud": [
"{leader} lööb esiuksed lahti. *\"KÕIK PÕRANDALE - KOHE!\"*",
"Esmalt suitsugranaadid. Selleks ajaks, kui suits vaibub, on meeskond juba sees.",
"{leader} tulistab ühe lasu lakke. Haudvaikus. *\"Oleme siin seifi pärast! Kui teete koostööd, siis pääsete elusana.\"*",
"*\"Kui keegi ei liigu, ei saa keegi ka viga.\"* {leader} omab ruumi täielikku tähelepanu.",
"{member} lükkab kilbist elektri välja. Pimeduses juhib {leader} meeskonna mälu järgi edasi.",
"*\"See on rööv! Kõik pikali!\"* {leader} hääl kajab läbi saali.",
"Alarm hakkab ulguma juba enne, kui nad täielikult sisse jõuavad.",
"Klaas puruneb. Inimesed karjuvad. Täielik kaos.",
],
"inside": [
"{names} liiguvad kiiresti läbi fuajee, sidudes turvamehi postide külge kinni.",
"{member} katab väljapääsud. {leader} suundub otse seifi poole.",
"Üks kassapidajatest proovib käivitada vaikset häiret, kuid {member} märkab ta nihelemist ja peatab ta hoiatuslasuga.",
"{leader} hoiab töötajad rahulikuna samal ajal, kui teised suunduvad alumisele korrusele.",
"Turvakaamerad on tsüklil. {names} on korrusel üksi.",
"{member} kontrollib kellaaega. *\"Me oleme 30 sekundit ees.\"*",
"{leader} annab käemärgi. Meeskond jaguneb ilma sõnadeta.",
"Koridor on tühi. Liiga tühi. {leader} peatub hetkeks.",
"{names} liiguvad trepist alla, sammud summutatud.",
"Üks uks on lukus. {member} avab selle sekundiga.",
],
"vault": [
"Neljanda taseme ajalukuga seif. {member} tõmbab puuri välja. *\"Anna mulle kolm minutit.\"*",
"{leader} vaatab seifi ust. *\"Dünamiit.\"* Keegi ei vaidle.",
"{member} ühendub seifi juhtpaneeliga. Vana tarkvara. Ülevõtmine võtab 90 sekundit.",
"Seifil on käsikombinatsiooni lukk. Hea, et {leader} veetis kuu aega juhatajaga, teenides tema usaldust.",
"{member} paigutab lõhkeained. Meeskond astub tagasi. Üks kontrollitud plahvatus.",
"{leader} teeb magnetlukule tühistuse. Nagu õpikust võetud. Mehhanism annab järgi.",
"{member} higistab. *\"See pole standardlukk... anna mulle aega.\"*",
"{leader} kuulab vastu seifi ust. *\"Sees on liikumisandur.\"*",
"{member} lõikab läbi metallkihi nagu võid.",
"Ajurünnak. {leader} meenutab skeemi ja leiab nõrga koha.",
"{member} ühendab juhtmed ümber. Säde. Vaikus. Lukk avaneb.",
],
"vault_open": [
"Uks avaneb. Kuhi-kuhja järel raha, põrandast laeni. Täpselt nagu plaanitud.",
"*\"...jackpot.\"* {leader} piilub seifi sisse. Meeskond seisab hetkeks vaikuses.",
"{member} hakkab kotte täis laduma. {leader} piilub juba väljapääsu poole.",
"Puhas. Kiire. {names} on seifis ja täidavad kotte enne, kui tolm settib.",
"Seif on lahti. {leader} võtab hinge. *\"Okei. Kottidesse ja liikuma.\"*",
"{leader} naeratab esimest korda. *\"See oli seda väärt.\"*",
"Raha lõhn täidab õhu. {member}: *\"Võtame kõik.\"*",
"{names} töötavad vaikides. Iga liigutus loeb.",
"Kotid täituvad kiiremini kui oodatud.",
"{leader} pilk muutub tõsiseks. *\"Aeg otsas. Liigume.\"*",
],
"police_inbound": [
"Raadio krõbiseb. *\"Kõik üksused, relvastatud rööv Keskpangas-\"*",
"Väljas on kuulda eemalt ulguvaid sireene. {leader}: *\"Meil on umbes neli minutit. Liikuma.\"*",
"Punased ja sinised tuled vilguvad ülemistest akendest läbi. Varualarm käivitati.",
"Politsei helikopter skaneerib piirkonda prožektoriga. Aken sulgub kiiresti.",
"{member} kontrollib politsei skännerit. *\"Nad teavad. Kolm üksust, kaks minutit eemal.\"*",
"{member} kuulab raadiosidet. *\"Nad sulgevad kvartaleid.\"*",
"Sireenid lähenevad kiiremini kui plaanitud.",
"Helikopteri valgus libiseb üle akna. Liiga lähedal.",
],
"getaway_success": [
"Adrenaliin. {names} jooksevad {vehicle} juurde. Kõik sees. {leader} keerab mootori käima.",
"{member} haarab roolist. {leader} on kõrval. *\"Lähme.\"* Rahulikult.",
"{leader} libistab {vehicle} kõrvalteed pidi minema. Sireen kaugel taga. Nad on kiiremad.",
"Rohelised tuled. {vehicle} sõidab vaikselt mööda parklas ootavast patrullautost. Keegi ei märka.",
"{member} näitab teed. {leader} sõidab vaikselt läbi tagakvartali. Käed lõdvad.",
"{vehicle} rehvid vilisevad, kui nad pööravad kitsasse tänavasse.",
"{leader} sõidab ilma tuledeta. Ainult mälu juhib teda.",
"{member} vaatab kaarti. *\"Vasak, siis kohe parem!\"*",
"Mootor möirgab. Nad kaovad öösse.",
"{vehicle} libiseb läbi viimase rohelise tule.",
],
"getaway_fail": [
"{names} jooksevad {vehicle} juurde. Sireenid igalt poolt.",
"{member} haarab roolist. {leader} on kõrval. *\"Mine! MINE!\"*",
"{vehicle} kihutab tänavale, kuid helikopteri valgusvihk osutab neile. Kõik on nähtaval.",
"Kolm patrullautot jõuavad nende taha. {member} vaatab üle õla. *\"Nad on meil kannul.\"*",
"Raadio krõbiseb. *\"Sihtmärk kinnitatud. Blokeeri Liivalaia tänav.\"*",
"{vehicle} ei käivitu. {member}: *\"Päriselt ka või?!\"*",
"{member} komistab teel autoni. Tal oli pael lahti tulnud",
"{leader} avastab, et autot pole kuskil näha. See pukseeriti ära."
],
"escape_success": [
"{names} murravad läbi perimeetri enne väravate sulgemist. Puhas põgenemine.",
"{leader} oli planeerinud kolm väljumisteed. Vaja läks ühte. {vehicle} kaob linna.",
"Kahe kvartali kaugusel asuv peibutushäire tõmbab üksused eemale. {names} on kadunud enne, kui keegi seifi kontrollib.",
"Naelribad? Juba eemaldatud. Teeblokk? Vale tänav. {leader} mõtles kõigele.",
"{vehicle} sõidab maanteele nagu poleks midagi juhtunud. {leader} hingab esimest korda tunnis välja.",
"*\"Oleme vabad.\"* {member} piilub kardinate tagant välja. Tühi tänav. Nad tegid ära.",
"{names} kaovad sügavale linna nagu neid poleks kunagi olnud.",
"Raadio jääb vaikseks. Keegi ei jälita enam.",
"{vehicle} jäetakse maha. Uus plaan aktiveerub.",
"{leader}: *\"See oli liiga lihtne...\"*",
],
"escape_fail": [
"Patrullauto lõikab {vehicle} teekonna sillal ära. Mõlemalt poolt ümbritsetud. Ongi läbi.",
"{member} kukutab koti. Kolm sekundit kõhklust - ja uksed on ümber piiratud.",
"Helikopter jälgib neid kuni pelgupaigani. Kõik väljapääsud on kaetud.",
"{leader} arvutab võimalusi. *\"Väljapääsu pole.\"* Käed lähevad üles.",
"Naelribad lõhuvad {vehicle} rehvid. Meeskond libiseb liikluse keskele seisma.",
"{vehicle} põrkub vastu teetõket. Mootor sureb. Igast suunast tuled.",
],
}
# ---------------------------------------------------------------------------
# /heist UI strings
# ---------------------------------------------------------------------------
HEIST_UI: dict[str, str] = {
"names_duo": "**{a}** ja **{b}**",
"names_sep": " & ",
"names_crew": "**{leader}** ja meeskond",
"btn_join": "Ühine röövimisega 🔫",
"btn_start": "Alusta kohe ▶",
"already_joined": "Sa oled juba sees!",
"only_organizer": "Ainult heisti algataja saab heisti alustada.",
"lobby_desc": "**Osalejad ({n}/{max}):**\n{names}\n\nÕnnestumise tõenäosus: **{chance}%**\n\n*Ühinemisaken sulgub <t:{ts}:R>*",
"cancel_desc": "Ei piisanud osalejaid (vajad vähemalt **{min}**). Heist tühistati.",
"started_title": "🔫 Grupirööv käib...",
"started_desc": "**{n}** osalejat alustasid heisti. Jälgi allpool!",
"story_title": "🔫 Grupirööv",
"win_desc": "**Osalejad:**\n{names}\n\nSaak jagati võrdselt.\n**Igaüks sai: +{payout}**",
"fail_desc": "**Osalejad:**\n{names}\n\nKõik osalejad saavad **1,5 tundi vangi** + trahv ~15% saldost.",
}
# ---------------------------------------------------------------------------
# Blackjack in-game strings
# ---------------------------------------------------------------------------
BJ: dict[str, str] = {
"dealing": "*Jagame kaardid...*",
"result_field": "Tulemus",
"push_result": "±0 (panus tagasi)",
"doubled_label": "💰 *Topeltpanus: {total}*",
"btn_hit": "🃏 Võta kaart",
"btn_stand": "✋ Seisa",
"btn_double": "💰 Kahekordista (+{bet}⬡)",
"btn_split": "✂️ Split (+{bet}⬡)",
}
# ---------------------------------------------------------------------------
# /jailbreak UI strings
# ---------------------------------------------------------------------------
JAILBREAK_UI: dict[str, str] = {
"btn_roll": "🎲 Viska täringud ({try_}/{max})",
"rolling_desc": f"{E['TipiDICE']} *Täringud lendavad...*",
"free_desc": "{d1} {d2}\n\n✅ Viskasid duubli - pääsesid vanglast!",
"miss_desc": "{d1} {d2}\n\n{left} katset jäänud. Proovi uuesti!",
"intro_desc": "Oled vangis kuni {ts}.\n\nViska täringuid ja proovi **duublit** saada - siis pääsed tasuta vabaks!\nSul on **{tries} katset**. Ebaõnnestumisel saad valida: maksa kautsjon **(2030% saldost, min 350 ⬡)** või jää vanglasse kuni aja lõpuni.",
"already_bail": "Oled juba täringuid visanud. Ainuke väljapääs on kautsjon.\n\n💰 **Kautsjon: {min} {max}** (2030% saldost)\nSaldo: {bal} Väljas: {ts}",
"already_broke": "Oled juba täringuid visanud. Sul pole piisavalt raha kautsjoni maksmiseks.\n\n💰 Kautsjoni miinimum: **{min}** - sul on ainult {bal}\nVäljas: {ts}",
"bail_btn": "💸 Maksa kautsjon",
"bail_broke_desc": "❌ Sul pole piisavalt raha (min {min}).\nJääd vanglasse kuni aja lõpuni! Saldo: {balance}",
"bail_paid_desc": "✅ Kautsjon makstud: **{fine}**\nOled vaba! Saldo: {balance}",
"bail_already_free": "✅ Oled juba vaba - kautsjonit ei võetud. Saldo: {balance}",
"fail_broke_desc": "{d1} {d2} - ei olnud duubel\n\n❌ Sul pole kautsjoni maksmiseks piisavalt raha (min 350 ⬡).\nJääd vanglasse kuni aja lõpuni! Saldo: {balance}",
"fail_bail_desc": "{d1} {d2} - ei olnud duubel\n\nKautsjon makstud: **{fine}**\nSaldo: {balance}",
"fail_bail_offer": "{d1} {d2} - ei olnud duubel\n\n💰 **Kautsjon: {min} - {max}** (20-30% saldost)\nSaldo: {bal}\n\nSaad maksta kautsjoni või jääda vanglasse kuni aja lõpuni.",
}
# ---------------------------------------------------------------------------
# /slots additional UI strings (titles/footers per outcome)
# ---------------------------------------------------------------------------
SLOTS_UI: dict[str, str] = {
"playing": "🎰 Mängimas...",
"jackpot_footer": E["TipiKARIKAS"] + " Kolm karikat! +{change}",
"triple_footer": "✅ Kolm ühesugust! +{change}",
"pair_footer": "Kaks ühesugust! +{change}",
"miss_footer": "-{amount}",
"balance_line": "\nSaldo: {balance}",
}
# ---------------------------------------------------------------------------
# /rps UI strings
# ---------------------------------------------------------------------------
RPS_UI: dict[str, str] = {
"vs_bot_desc": "Tee oma valik! ⬇️",
"vs_bot_bet": "\nPanus: {bet}",
"result_tie": "🤝 Viik!",
"result_win": "🎉 Sina võitsid!",
"result_lose": "🤖 Bot võitis!",
"result_desc": "Sina: {player_pick} {player_name}\nBot: {bot_pick} {bot_name}\n\n**{result}**{bet_line}",
"bet_win": "\n+{amount} · Saldo: {balance}",
"bet_lose": "\n-{amount} · Saldo: {balance}",
"bet_tie": "\nViik - panus tagasi · Saldo: {balance}",
"challenge_desc": "{challenger} kutsub välja {opponent}{bet}\n\n{opponent}, kas võtad vastu? ⬇️",
"challenge_bet": " · Panus: {bet}",
"challenge_footer": "Väljakutse aegub 60 sekundi pärast",
"duel_active_desc": "{a} vs {b}{bet}\n\nMõlemad mängijad valivad DM-is. ⏳",
"duel_active_bet": " · Panus: {bet}",
"duel_dm": "⚔️ **KPK vs {opponent}**{bet}\nTee oma valik! ⬇️",
"duel_dm_bet": "\nPanus: {bet}",
"duel_waiting": "Valisid {choice} **{name}**. Ootame vastase valikut... ⏳",
"duel_result_a": "**⚔️ KPK tulemus vs {opponent}**\nSinu valik: {pick_a} {name_a}\nVastase valik: {pick_b} {name_b}\n\n**{result}**{bet_line}\nSaldo: {balance}",
"duel_verdict_win": "🏆 **{name}** võitis!",
"duel_verdict_tie": "🤝 Viik!",
"duel_broke": "\n⚠️ Makse ebaõnnestus (ebapiisav saldo)",
"duel_expire_dm": "⏰ KPK duell aegus - keegi ei teinud valikut õigeaegselt.",
"duel_expire_desc": "{a} vs {b}\n\nMäng aegus - valik jäi tegemata.",
"duel_dm_fail": "Ei saanud DM saata: **{names}**. Kontrolli privaatsussätteid.",
"duel_decline": "**{name}** keeldus väljakutsest.",
"duel_no_answer": "**{name}** ei vastanud väljakutsele.",
"duel_insufficient": "{mention}-l pole piisavalt TipiCOINi panuseks.",
"duel_done_desc": "{a} {pick_a} vs {pick_b} {b}\n\n**{verdict}**\n\n{name_a}: {bal_a}\n{name_b}: {bal_b}",
"server_only": "Seda käsku saab kasutada ainult serveris.",
"btn_accept": "Võta vastu ✅",
"btn_decline": "Keeldu ❌",
"btn_rock": "🪨 Kivi",
"btn_paper": "📄 Paber",
"btn_scissors": "✂️ Käärid",
}
RPS_CHOICES: dict[str, str] = {
"🪨": "Kivi",
"📄": "Paber",
"✂️": "Käärid",
}
BJ_UI: dict[str, str] = {
"dealer": "Dealer",
"player": "Sina",
"hand_n": "Käsi {n}",
"hand_active": "▶ **Käsi {n}**",
"hand_pending": "Käsi {n} *...*",
"bust": " 💥",
"balance_line": " · Saldo: {balance}",
}

119
strings/member.py Normal file
View File

@@ -0,0 +1,119 @@
"""Member sync, /check, birthdays, channel + economy-setup admin UI.
Auto-split from the original strings.py; edit strings here.
"""
__all__ = [
'MEMBER_UI',
'MEMBER_FIELDS',
'ECONOMYSETUP_UI',
'CHANNEL_UI',
'BIRTHDAY_UI',
'BIRTHDAY_MONTHS',
'CHECK_UI',
'TEAMSYNC_UI',
]
# ---------------------------------------------------------------------------
# /allowchannel /denychannel /channels UI strings
# ---------------------------------------------------------------------------
MEMBER_UI: dict[str, str] = {
"title": "👤 {name}",
"age_field": "Vanus",
"age_val": "{age}a",
}
MEMBER_FIELDS: list[tuple[str, str]] = [
("Nimi", "Nimi"),
("Organisatsioon", "Organisatsioon"),
("Valdkond", "Valdkond"),
("Roll", "Roll"),
("Sünnipäev", "Sünnipäev"),
("Meil", "Meil"),
("Telefon", "Telefon"),
("Discord", "Discord"),
("User ID", "Kasutaja ID"),
("Discordis synced?", "Sünkroniseeritud"),
("Groupi lisatud?", "Grupis"),
]
ECONOMYSETUP_UI: dict[str, str] = {
"created_field": "✅ Loodud",
"existing_field": "♻️ Juba olemas",
"footer": "Rollid on nüüd boti rolli all õiges järjekorras.",
}
CHANNEL_UI: dict[str, str] = {
"already_allowed": " {channel} on juba lubatud kanalite nimekirjas.",
"added": "{channel} lisatud. Bot vastab käskudele nüüd ainult lubatud kanalites.",
"not_in_list": " {channel} pole lubatud kanalite nimekirjas.",
"removed": "{channel} eemaldatud lubatud kanalite nimekirjast.",
"removed_last": "{channel} eemaldatud. Lubatud kanalite nimekiri on nüüd tühi - bot vastab kõikides kanalites.",
"list_title": "📋 Lubatud kanalid",
"list_empty": "Lubatud kanalite nimekiri on **tühi** - bot vastab käskudele kõikides kanalites.\n\nKasuta `/allowchannel`, et piirata kindlatele kanalitele.",
"list_filled": "Bot vastab käskudele ainult nendes kanalites:\n\n{lines}\n\n-# Kasuta `/allowchannel` / `/denychannel` nimekirja muutmiseks.",
}
# ---------------------------------------------------------------------------
# /birthdays UI
# ---------------------------------------------------------------------------
BIRTHDAY_UI: dict[str, str] = {
"no_entries": "Sellel kuul sünnipäevi ei ole.",
"today": "täna 🎉",
"tomorrow": "homme",
"in_days": "{days}p pärast",
"footer": "Leht {month}/12 · {month_name}",
}
BIRTHDAY_MONTHS: list[str] = [
"Jaanuar", "Veebruar", "Märts", "Aprill", "Mai", "Juuni",
"Juuli", "August", "September", "Oktoober", "November", "Detsember",
]
# ---------------------------------------------------------------------------
# /check summary strings
# ---------------------------------------------------------------------------
CHECK_UI: dict[str, str] = {
"sheet_stats_header": "📊 **Tabeli statistika** - {total} liiget",
"stat_ok": "✅ **{label}**: kõik täidetud",
"stat_warn": "⚠️ **{label}** puudub ({count}): {names}{more}",
"stat_more": " (+{count} veel)",
"stat_uid": "Kasutaja ID",
"stat_discord": "Discordi kasutajanimi",
"stat_bday": "Sünnipäev",
"no_name": "(no name)",
"done": "**Kontroll lõpetatud!**",
"already_ok": "✅ Juba korras: {count}",
"fixed": "🔧 Parandatud: {count}",
"not_found": "❓ Pole tabelis: {count}",
"bday_pings": "🎂 Sünnipäeva teavitused: {count}",
"errors": "⚠️ Vead: {count}",
"details_header": "**Üksikasjad:**",
"details_more": "... ja {count} rohkem",
"detail_error": "⚠️ {error}",
"detail_nickname": "hüüdnimi",
"detail_roles_added": "+rollid: {roles}",
"detail_changed": "🔧 **{name}**: {parts}",
"ids_filled": "\n🔑 Täideti **{count}** puuduvat kasutaja ID-d.",
}
# ---------------------------------------------------------------------------
# /teamsync UI strings (tournament team-role sync from the registration sheet)
# ---------------------------------------------------------------------------
TEAMSYNC_UI: dict[str, str] = {
"disabled": "⚠️ Tiimide sünkroonimine on välja lülitatud (TEAM_SHEET_ID puudub).",
"refresh_error": "⚠️ Registreerimistabeli laadimine ebaõnnestus: {error}",
"done": "**Tiimide sünkroonimine lõpetatud!**",
"scanned": "👥 Kontrollitud liikmeid: {count}",
"assigned": "✅ Tiimirolle antud: {count}",
"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:**",
"changes_more": "... ja {count} rohkem",
}

114
tests/conftest.py Normal file
View File

@@ -0,0 +1,114 @@
"""Shared fixtures: an in-memory PocketBase stand-in wired into core.economy."""
from __future__ import annotations
import asyncio
import copy
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from core import economy, pb_client # noqa: E402
class FakePocketBase:
"""In-memory stand-in for core.pb_client.
Mimics the behaviours the economy layer depends on:
- records are returned as deep copies (like JSON over REST)
- "field+" / "field-" body keys are atomic number modifiers
- fields not in `schema_fields` are silently dropped, like PocketBase
does for fields missing from the collection schema (schema_fields=None
keeps everything)
"""
def __init__(self, schema_fields: set[str] | None = None):
self.records: dict[str, dict] = {}
self.schema_fields = schema_fields
self._next_id = 0
def _filter(self, data: dict) -> dict:
if self.schema_fields is None:
return dict(data)
return {k: v for k, v in data.items() if k.rstrip("+-") in self.schema_fields}
def _apply(self, record: dict, data: dict) -> None:
for key, value in self._filter(data).items():
if key.endswith("+"):
record[key[:-1]] = record.get(key[:-1], 0) + value
elif key.endswith("-"):
record[key[:-1]] = record.get(key[:-1], 0) - value
else:
record[key] = value
async def get_record(self, user_id: str) -> dict | None:
await asyncio.sleep(0) # yield, so unserialized tasks would interleave
for record in self.records.values():
if record.get("user_id") == user_id:
return copy.deepcopy(record)
return None
async def create_record(self, record: dict) -> dict:
await asyncio.sleep(0)
self._next_id += 1
stored = self._filter(record)
stored["id"] = f"rec{self._next_id}"
stored["user_id"] = record.get("user_id", "")
self.records[stored["id"]] = stored
return copy.deepcopy(stored)
async def update_record(self, record_id: str, data: dict) -> dict:
await asyncio.sleep(0)
record = self.records[record_id]
self._apply(record, data)
return copy.deepcopy(record)
async def list_all_records(self, page_size: int = 500) -> list[dict]:
await asyncio.sleep(0)
return copy.deepcopy(list(self.records.values()))
async def count_records(self) -> int:
return len(self.records)
async def get_collection_fields(self) -> set[str]:
if self.schema_fields is None:
return set(economy._default_user()) | {"user_id"}
return set(self.schema_fields)
# -- test helpers -------------------------------------------------------
def record_for(self, user_id: int) -> dict:
for record in self.records.values():
if record.get("user_id") == str(user_id):
return record
raise KeyError(user_id)
def _install(monkeypatch, fake: FakePocketBase) -> FakePocketBase:
for name in ("get_record", "create_record", "update_record",
"list_all_records", "count_records", "get_collection_fields"):
monkeypatch.setattr(pb_client, name, getattr(fake, name))
# live house state is owned by economy.house (the package re-export is a snapshot)
monkeypatch.setattr(economy.house, "HOUSE_ID", None)
monkeypatch.setattr(economy.house, "_house_pb_id", None)
economy._user_locks.clear()
return fake
@pytest.fixture
def fake_pb(monkeypatch) -> FakePocketBase:
return _install(monkeypatch, FakePocketBase())
@pytest.fixture
def fake_pb_without_quest_fields(monkeypatch) -> FakePocketBase:
"""A fake whose collection schema predates the quest migration."""
schema = set(economy._default_user().keys()) | {"user_id"}
schema -= {"quest_daily", "quest_weekly"}
return _install(monkeypatch, FakePocketBase(schema_fields=schema))
def run(coro):
return asyncio.run(coro)

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

109
tests/test_consumables.py Normal file
View File

@@ -0,0 +1,109 @@
"""Tests for the consumables shop (repeatable, expiring coin sink)."""
from datetime import datetime, timedelta, timezone
from core import economy
from conftest import run
UID = 4242
def _fixed_now(monkeypatch, dt: datetime):
# _clock is the time seam shared by every economy submodule
monkeypatch.setattr(economy.store, "_clock", lambda: dt)
return dt
def _fund(fake_pb, amount: int) -> None:
run(economy.get_user(UID))
fake_pb.record_for(UID)["balance"] = amount
class TestBuy:
def test_buy_earn_buff_deducts_and_activates(self, fake_pb):
_fund(fake_pb, 1000)
res = run(economy.do_buy_consumable(UID, "energy_xl"))
assert res["ok"] and not res["instant"]
assert res["balance"] == 500 # 1000 - 500 cost
user = run(economy.get_user(UID))
assert economy.earn_mult(user) == 2.0
assert economy.exp_buff_mult(user) == 1.0
def test_insufficient_funds_rejected(self, fake_pb):
_fund(fake_pb, 100)
res = run(economy.do_buy_consumable(UID, "energy_xl"))
assert not res["ok"] and res["reason"] == "insufficient"
assert res["need"] == 400
assert run(economy.get_user(UID))["balance"] == 100 # unchanged
def test_banned_rejected(self, fake_pb):
_fund(fake_pb, 1000)
fake_pb.record_for(UID)["eco_banned"] = True
res = run(economy.do_buy_consumable(UID, "energy_xl"))
assert not res["ok"] and res["reason"] == "banned"
def test_unknown_consumable(self, fake_pb):
_fund(fake_pb, 1000)
res = run(economy.do_buy_consumable(UID, "nope"))
assert not res["ok"] and res["reason"] == "not_found"
class TestBuffLifecycle:
def test_buff_expires(self, fake_pb, monkeypatch):
t0 = _fixed_now(monkeypatch, datetime(2026, 8, 19, 12, tzinfo=timezone.utc))
_fund(fake_pb, 1000)
run(economy.do_buy_consumable(UID, "energy_xl"))
assert economy.earn_mult(run(economy.get_user(UID))) == 2.0
# 61 minutes later the 60-minute buff is gone
_fixed_now(monkeypatch, t0 + timedelta(minutes=61))
assert economy.earn_mult(run(economy.get_user(UID))) == 1.0
def test_rebuy_extends_duration(self, fake_pb, monkeypatch):
t0 = _fixed_now(monkeypatch, datetime(2026, 8, 19, 12, tzinfo=timezone.utc))
_fund(fake_pb, 2000)
run(economy.do_buy_consumable(UID, "energy_xl")) # expiry = t0 + 60m
_fixed_now(monkeypatch, t0 + timedelta(minutes=30))
res = run(economy.do_buy_consumable(UID, "energy_xl")) # extends, not resets
assert res["extended"] is True
# remaining should be ~90m (30m left + 60m added), not 60m
assert res["remaining"] > timedelta(minutes=85)
class TestEffects:
def test_earn_buff_doubles_work(self, fake_pb, monkeypatch):
import random
_fixed_now(monkeypatch, datetime(2026, 8, 19, 12, tzinfo=timezone.utc))
_fund(fake_pb, 1000)
monkeypatch.setattr(random, "randint", lambda a, b: 50)
monkeypatch.setattr(random, "choice", lambda seq: seq[0])
monkeypatch.setattr(random, "random", lambda: 0.99) # no energiajook luck
base = run(economy.do_work(UID))["earned"]
fake_pb.record_for(UID)["last_work"] = None # clear cooldown
run(economy.do_buy_consumable(UID, "energy_xl"))
boosted = run(economy.do_work(UID))["earned"]
assert boosted == base * 2
def test_exp_buff_doubles_award(self, fake_pb):
_fund(fake_pb, 1000)
run(economy.do_buy_consumable(UID, "xp_potion"))
res = run(economy.award_exp(UID, 10))
assert res["gained"] == 20 # 10 * 2x exp buff
def test_kohv_clears_cooldowns(self, fake_pb, monkeypatch):
t0 = _fixed_now(monkeypatch, datetime(2026, 8, 19, 12, tzinfo=timezone.utc))
_fund(fake_pb, 1000)
rec = fake_pb.record_for(UID)
rec["last_work"] = t0.isoformat() # on cooldown
res = run(economy.do_buy_consumable(UID, "kohv"))
assert res["ok"] and res["instant"]
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")

205
tests/test_economy_flows.py Normal file
View File

@@ -0,0 +1,205 @@
"""Tests for the async economy flows against the in-memory PocketBase fake."""
import asyncio
import random
from datetime import datetime, timedelta, timezone
from core import economy
from conftest import run
UID = 111
OTHER = 222
HOUSE = 999
def _fixed_now(monkeypatch, dt: datetime):
# _clock is the time seam shared by every economy submodule
monkeypatch.setattr(economy.store, "_clock", lambda: dt)
return dt
class TestGetUser:
def test_creates_default_record(self, fake_pb):
user = run(economy.get_user(UID))
assert user["balance"] == 0
assert fake_pb.record_for(UID)["user_id"] == str(UID)
def test_roundtrips_existing_data(self, fake_pb):
run(economy.get_user(UID))
fake_pb.record_for(UID)["balance"] = 1234
assert run(economy.get_user(UID))["balance"] == 1234
class TestDaily:
def test_first_claim(self, fake_pb):
res = run(economy.do_daily(UID))
assert res["ok"] and res["streak"] == 1 and res["earned"] == 150
def test_cooldown_blocks_second_claim(self, fake_pb):
run(economy.do_daily(UID))
res = run(economy.do_daily(UID))
assert not res["ok"] and res["reason"] == "cooldown"
def test_streak_increments_next_day(self, fake_pb, monkeypatch):
t0 = _fixed_now(monkeypatch, datetime(2026, 7, 25, 12, tzinfo=timezone.utc))
run(economy.do_daily(UID))
_fixed_now(monkeypatch, t0 + timedelta(days=1))
res = run(economy.do_daily(UID))
assert res["ok"] and res["streak"] == 2
def test_streak_resets_after_missed_day(self, fake_pb, monkeypatch):
t0 = _fixed_now(monkeypatch, datetime(2026, 7, 25, 12, tzinfo=timezone.utc))
run(economy.do_daily(UID))
_fixed_now(monkeypatch, t0 + timedelta(days=3))
res = run(economy.do_daily(UID))
assert res["ok"] and res["streak"] == 1
def test_karikas_preserves_streak(self, fake_pb, monkeypatch):
t0 = _fixed_now(monkeypatch, datetime(2026, 7, 25, 12, tzinfo=timezone.utc))
run(economy.do_daily(UID))
fake_pb.record_for(UID)["items"] = ["karikas"]
fake_pb.record_for(UID)["daily_streak"] = 5
_fixed_now(monkeypatch, t0 + timedelta(days=3))
res = run(economy.do_daily(UID))
assert res["ok"] and res["streak"] == 5
def test_streak_multiplier_tiers(self, fake_pb, monkeypatch):
t0 = _fixed_now(monkeypatch, datetime(2026, 7, 25, 12, tzinfo=timezone.utc))
run(economy.get_user(UID))
rec = fake_pb.record_for(UID)
rec["daily_streak"] = 13
rec["last_streak_date"] = (t0.date() - timedelta(days=1)).isoformat()
res = run(economy.do_daily(UID))
assert res["streak"] == 14 and res["streak_mult"] == 3.0 and res["earned"] == 450
class TestBuy:
def test_insufficient_funds(self, fake_pb):
res = run(economy.do_buy(UID, "gaming_hiir"))
assert not res["ok"] and res["reason"] == "insufficient"
def test_purchase_and_rebuy_blocked(self, fake_pb):
run(economy.get_user(UID))
fake_pb.record_for(UID)["balance"] = 1000
res = run(economy.do_buy(UID, "gaming_hiir"))
assert res["ok"] and res["balance"] == 500
assert "gaming_hiir" in fake_pb.record_for(UID)["items"]
res = run(economy.do_buy(UID, "gaming_hiir"))
assert not res["ok"] and res["reason"] == "owned"
def test_tier2_requires_level(self, fake_pb):
run(economy.get_user(UID))
fake_pb.record_for(UID)["balance"] = 100_000
res = run(economy.do_buy(UID, "jellyfin"))
assert not res["ok"] and res["reason"] == "level_required"
fake_pb.record_for(UID)["exp"] = economy.exp_for_level(10)
assert run(economy.do_buy(UID, "jellyfin"))["ok"]
def test_anticheat_repurchase_after_depletion(self, fake_pb):
run(economy.get_user(UID))
rec = fake_pb.record_for(UID)
rec["balance"] = 10_000
assert run(economy.do_buy(UID, "anticheat"))["ok"]
assert fake_pb.record_for(UID)["item_uses"]["anticheat"] == 2
fake_pb.record_for(UID)["item_uses"]["anticheat"] = 0
assert run(economy.do_buy(UID, "anticheat"))["ok"]
assert fake_pb.record_for(UID)["item_uses"]["anticheat"] == 2
class TestGive:
def test_transfer(self, fake_pb):
run(economy.get_user(UID))
fake_pb.record_for(UID)["balance"] = 500
res = run(economy.do_give(UID, OTHER, 200))
assert res["ok"]
assert fake_pb.record_for(UID)["balance"] == 300
assert fake_pb.record_for(OTHER)["balance"] == 200
def test_insufficient(self, fake_pb):
res = run(economy.do_give(UID, OTHER, 50))
assert not res["ok"] and res["reason"] == "insufficient"
def test_opposite_transfers_do_not_deadlock(self, fake_pb):
async def both():
for uid in (UID, OTHER):
await economy.get_user(uid)
fake_pb.record_for(uid)["balance"] = 100
await asyncio.wait_for(
asyncio.gather(
economy.do_give(UID, OTHER, 10),
economy.do_give(OTHER, UID, 25),
),
timeout=5,
)
run(both())
total = fake_pb.record_for(UID)["balance"] + fake_pb.record_for(OTHER)["balance"]
assert total == 200
class TestRob:
def test_anticheat_blocks_and_depletes(self, fake_pb):
run(economy.get_user(UID))
run(economy.get_user(OTHER))
economy.set_house(HOUSE)
fake_pb.record_for(UID)["balance"] = 1000
target = fake_pb.record_for(OTHER)
target["balance"] = 1000
target["items"] = ["anticheat"]
target["item_uses"] = {"anticheat": 1}
res = run(economy.do_rob(UID, OTHER))
assert res["ok"] and not res["success"] and res["reason"] == "valvur"
assert fake_pb.record_for(UID)["balance"] == 1000 - res["fine"]
assert "anticheat" not in fake_pb.record_for(OTHER)["items"]
# the fine flows to the house
assert fake_pb.record_for(HOUSE)["balance"] == res["fine"]
class TestGambling:
def test_roulette_conserves_money_with_house(self, fake_pb):
economy.set_house(HOUSE)
run(economy.get_user(UID))
fake_pb.record_for(UID)["balance"] = 1000
random.seed(3)
res = run(economy.do_roulette(UID, 100, "punane"))
assert res["ok"]
user_bal = fake_pb.record_for(UID)["balance"]
if res["won"]:
assert user_bal == 1000 + res["change"]
else:
assert user_bal == 900
assert fake_pb.record_for(HOUSE)["balance"] == 100
def test_bet_larger_than_balance_rejected(self, fake_pb):
res = run(economy.do_slots(UID, 50))
assert not res["ok"] and res["reason"] == "insufficient"
def test_blackjack_bet_and_payout(self, fake_pb):
economy.set_house(HOUSE)
run(economy.get_user(UID))
fake_pb.record_for(UID)["balance"] = 500
assert run(economy.do_blackjack_bet(UID, 100))["ok"]
assert fake_pb.record_for(UID)["balance"] == 400
# player loses: payout 0 of 100 invested -> house gains the bet
run(economy.do_blackjack_payout(UID, 0, total_invested=100))
assert fake_pb.record_for(UID)["balance"] == 400
assert fake_pb.record_for(HOUSE)["balance"] == 100
class TestConcurrency:
"""The per-user locks must serialize read-modify-write cycles."""
def test_concurrent_exp_awards_are_not_lost(self, fake_pb):
async def hammer():
await asyncio.gather(*(economy.award_exp(UID, 10) for _ in range(25)))
run(hammer())
assert fake_pb.record_for(UID)["exp"] == 250
def test_concurrent_credit_house_is_atomic(self, fake_pb):
economy.set_house(HOUSE)
async def hammer():
await economy.get_user(HOUSE)
await asyncio.gather(*(economy._credit_house(7) for _ in range(30)))
run(hammer())
assert fake_pb.record_for(HOUSE)["balance"] == 210

109
tests/test_economy_pure.py Normal file
View File

@@ -0,0 +1,109 @@
"""Tests for the pure (no-database) economy math."""
import random
from datetime import timedelta
from core import economy
class TestLevels:
def test_milestones(self):
assert economy.get_level(0) == 1
assert economy.get_level(249) == 4
assert economy.get_level(250) == 5
assert economy.get_level(1000) == 10
assert economy.get_level(4000) == 20
assert economy.get_level(9000) == 30
def test_exp_for_level_is_inverse(self):
for level in range(1, 41):
exp = economy.exp_for_level(level)
assert economy.get_level(exp) == level
if level > 1:
assert economy.get_level(exp - 1) == level - 1
def test_negative_exp_clamps_to_level_1(self):
assert economy.get_level(-500) == 1
def test_role_names(self):
assert economy.level_role_name(1) == "TipiNOOB"
assert economy.level_role_name(4) == "TipiNOOB"
assert economy.level_role_name(5) == "TipiGRINDER"
assert economy.level_role_name(10) == "TipiHUSTLER"
assert economy.level_role_name(20) == "TipiCHAD"
assert economy.level_role_name(30) == "TipiLEGEND"
assert economy.level_role_name(99) == "TipiLEGEND"
class 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
assert economy.gamble_exp(9) == 0
assert economy.gamble_exp(10) == 5
assert economy.gamble_exp(99) == 5
assert economy.gamble_exp(100) == 10
assert economy.gamble_exp(999) == 10
assert economy.gamble_exp(1_000) == 15
assert economy.gamble_exp(9_999) == 15
assert economy.gamble_exp(10_000) == 20
assert economy.gamble_exp(99_999) == 20
assert economy.gamble_exp(100_000) == 25
def test_cap(self):
assert economy.gamble_exp(10_000_000) == 25
class TestFormatTd:
def test_hours(self):
assert economy.format_td(timedelta(hours=1, minutes=23, seconds=45)) == "1t 23m"
def test_minutes(self):
assert economy.format_td(timedelta(minutes=45, seconds=12)) == "45m 12s"
def test_seconds(self):
assert economy.format_td(timedelta(seconds=8)) == "8s"
class TestRollFish:
def test_rolls_are_valid(self):
random.seed(42)
for _ in range(500):
fish_id, weight = economy.roll_fish()
if fish_id == "junk":
assert weight == 0
else:
fish = economy.FISH_CATALOGUE[fish_id]
assert fish["weight"][0] <= weight <= fish["weight"][1]
def test_rarity_bump_shifts_every_catch_up_a_tier(self):
random.seed(7)
rarities = {
economy.FISH_CATALOGUE[fid]["rarity"]
for fid, _ in (economy.roll_fish(rarity_bump=True) for _ in range(1000))
if fid != "junk"
}
assert "common" not in rarities
assert "legendary" in rarities

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

@@ -0,0 +1,115 @@
"""Regression tests for the money-safety audit fixes.
Covers the two pure-logic fixes:
- heist payout conserves coins (no minting when the house is poor) and the win
path no longer crashes on the shadowed `house` local.
- do_bail is idempotent: a jailed user can only be charged once per sentence, so
a double-click or a stale BailView cannot destroy coins with a second fine.
"""
from datetime import datetime, timedelta, timezone
from core import economy
from core.pb_client import DatabaseError
from conftest import run
UID = 111
OTHER = 222
HOUSE = 999
def _fixed_now(monkeypatch, dt: datetime):
monkeypatch.setattr(economy.store, "_clock", lambda: dt)
return dt
class TestHeistConservation:
def _setup_house(self, fake_pb, monkeypatch, balance: int):
monkeypatch.setattr(economy.house, "HOUSE_ID", HOUSE)
monkeypatch.setattr(economy.house, "_house_pb_id", None)
run(economy.get_user(HOUSE))
fake_pb.record_for(HOUSE)["balance"] = balance
def test_poor_house_win_does_not_mint(self, fake_pb, monkeypatch):
# Poor house is the case the pre-fix code minted from: the desired pot
# (floored at 300) exceeded the balance, so payouts outran the debit.
self._setup_house(fake_pb, monkeypatch, balance=100)
run(economy.get_user(UID))
run(economy.get_user(OTHER))
house_before = fake_pb.record_for(HOUSE)["balance"]
res = run(economy.do_heist_resolve([UID, OTHER], True)) # must not raise
assert res["ok"] and res["success"]
house_after = fake_pb.record_for(HOUSE)["balance"]
gains = fake_pb.record_for(UID)["balance"] + fake_pb.record_for(OTHER)["balance"]
# Coin conservation: the house loses exactly what the players gain.
assert house_before - house_after == gains
assert house_after >= 0
assert res["payout_each"] * 2 == gains
def test_rich_house_win_pays_out_and_conserves(self, fake_pb, monkeypatch):
self._setup_house(fake_pb, monkeypatch, balance=100_000)
run(economy.get_user(UID))
run(economy.get_user(OTHER))
house_before = fake_pb.record_for(HOUSE)["balance"]
res = run(economy.do_heist_resolve([UID, OTHER], True))
assert res["ok"] and res["payout_each"] > 0
house_after = fake_pb.record_for(HOUSE)["balance"]
gains = fake_pb.record_for(UID)["balance"] + fake_pb.record_for(OTHER)["balance"]
assert house_before - house_after == gains
class TestBailIdempotency:
def _jail(self, fake_pb, now, balance: int):
run(economy.get_user(UID))
rec = fake_pb.record_for(UID)
rec["balance"] = balance
rec["jailed_until"] = (now + timedelta(minutes=30)).isoformat()
def test_second_bail_when_free_is_noop(self, fake_pb, monkeypatch):
now = _fixed_now(monkeypatch, datetime(2026, 7, 25, 12, tzinfo=timezone.utc))
self._jail(fake_pb, now, balance=1000)
first = run(economy.do_bail(UID))
assert first["ok"]
bal_after_first = fake_pb.record_for(UID)["balance"]
assert bal_after_first < 1000 # a fine was charged
assert fake_pb.record_for(UID)["jailed_until"] is None # freed
# A double-click / stale view fires do_bail again while already free.
second = run(economy.do_bail(UID))
assert not second["ok"] and second["reason"] == "not_jailed"
assert fake_pb.record_for(UID)["balance"] == bal_after_first # no second charge
def test_bail_charges_once_for_a_real_sentence(self, fake_pb, monkeypatch):
now = _fixed_now(monkeypatch, datetime(2026, 7, 25, 12, tzinfo=timezone.utc))
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

122
tests/test_quests.py Normal file
View File

@@ -0,0 +1,122 @@
"""Tests for the quest system: rotation, progress, claiming, schema detection."""
import logging
from datetime import datetime, timedelta, timezone
from core import economy
from conftest import run
UID = 111
def _fixed_now(monkeypatch, dt: datetime):
monkeypatch.setattr(economy.store, "_clock", lambda: dt)
return dt
def _complete_all_active_quests(fake_pb, user_id: int) -> tuple[int, int]:
"""Push every active quest's tracked counter past its goal directly in the
store. Returns (expected_coins, expected_exp)."""
rec = fake_pb.record_for(user_id)
coins = exp = 0
for pool, block in (
(economy.QUESTS_DAILY, rec["quest_daily"]),
(economy.QUESTS_WEEKLY, rec["quest_weekly"]),
):
for qid, state in block["quests"].items():
stat = pool[qid]["stat"]
rec[stat] = state["snap"] + pool[qid]["goal"]
coins += pool[qid]["coins"]
exp += pool[qid]["exp"]
return coins, exp
class TestRotation:
def test_deterministic_per_seed(self):
a = economy._pick_quests(economy.QUESTS_DAILY, 3, "1:date:2026-07-26")
b = economy._pick_quests(economy.QUESTS_DAILY, 3, "1:date:2026-07-26")
assert a == b
def test_users_get_different_sets(self):
sets = {
tuple(economy._pick_quests(economy.QUESTS_DAILY, 3, f"{uid}:date:2026-07-26"))
for uid in range(50)
}
assert len(sets) > 1
def test_counts(self, fake_pb):
data = run(economy.get_quests(UID))
assert len(data["daily"]) == economy.DAILY_QUEST_COUNT
assert len(data["weekly"]) == economy.WEEKLY_QUEST_COUNT
def test_daily_rolls_over_weekly_stays(self, fake_pb, monkeypatch):
# a Tuesday, so day+1 stays inside the same ISO week
t0 = _fixed_now(monkeypatch, datetime(2026, 7, 21, 12, tzinfo=timezone.utc))
run(economy.get_quests(UID))
rec = fake_pb.record_for(UID)
daily_before, weekly_before = dict(rec["quest_daily"]), dict(rec["quest_weekly"])
_fixed_now(monkeypatch, t0 + timedelta(days=1))
run(economy.get_quests(UID))
rec = fake_pb.record_for(UID)
assert rec["quest_daily"]["date"] != daily_before["date"]
assert rec["quest_weekly"] == weekly_before
class TestProgress:
def test_progress_tracks_counter_delta(self, fake_pb):
run(economy.get_quests(UID))
rec = fake_pb.record_for(UID)
qid, state = next(iter(rec["quest_daily"]["quests"].items()))
stat = economy.QUESTS_DAILY[qid]["stat"]
rec[stat] = state["snap"] + 1
data = run(economy.get_quests(UID))
quest = next(q for q in data["daily"] if q["id"] == qid)
assert quest["progress"] == 1
def test_pre_roll_stats_do_not_count(self, fake_pb):
run(economy.get_user(UID))
fake_pb.record_for(UID)["work_count"] = 500
data = run(economy.get_quests(UID))
assert all(q["progress"] == 0 for q in data["daily"] + data["weekly"])
class TestClaim:
def test_claim_pays_and_is_idempotent(self, fake_pb):
run(economy.get_quests(UID))
coins, exp = _complete_all_active_quests(fake_pb, UID)
res = run(economy.claim_quests(UID))
assert res["ok"]
assert res["claimed"] == economy.DAILY_QUEST_COUNT + economy.WEEKLY_QUEST_COUNT
assert res["coins"] == coins and res["exp"] == exp
assert fake_pb.record_for(UID)["balance"] == coins
res = run(economy.claim_quests(UID))
assert not res["ok"] and res["reason"] == "nothing"
def test_claim_with_nothing_done(self, fake_pb):
run(economy.get_quests(UID))
res = run(economy.claim_quests(UID))
assert not res["ok"]
class TestSchemaDetection:
def test_missing_fields_reported(self, fake_pb_without_quest_fields):
assert run(economy.missing_schema_fields()) == ["quest_daily", "quest_weekly"]
def test_healthy_schema_reports_nothing(self, fake_pb):
assert run(economy.missing_schema_fields()) == []
def test_missing_quest_fields_warn_and_zero_progress(
self, fake_pb_without_quest_fields, caplog
):
"""Reproduces the live 'quests never progress' symptom: when the
collection schema lacks quest_daily/quest_weekly, PocketBase drops the
rolled quest block, so every call re-rolls with a fresh snapshot."""
fake = fake_pb_without_quest_fields
with caplog.at_level(logging.WARNING):
run(economy.get_quests(UID))
assert any("quest fields" in r.message for r in caplog.records)
# counters advance, but progress stays 0 because the snapshot re-rolls
fake.record_for(UID)["work_count"] = 500
data = run(economy.get_quests(UID))
assert all(q["progress"] == 0 for q in data["daily"] + data["weekly"])

View File

@@ -0,0 +1,90 @@
"""Regression tests: member-sheet writes must target columns by live header name.
The roster sheet gained a "Käepael" column after "Nimi". Writes used to take
their position from EXPECTED_HEADERS, so every write landed one column left -
the TRUE/FALSE synced flag overwrote "Roll", and /check then reported
"Rolli 'FALSE' ei leitud serverist" for every member.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from core import sheets # noqa: E402
from tests.conftest import run # noqa: E402
LIVE_HEADER = [
"Nimi", "Käepael", "Organisatsioon", "Meil", "Discord", "User ID", "Sünnipäev",
"Telefon", "Valdkond", "Roll", "Discordis synced?", "Groupi lisatud?", "Särk",
]
class FakeWorksheet:
def __init__(self, header: list[str]):
self.header = header
self.updates: list[tuple[str, str]] = []
self.cell_updates: list[tuple[int, int, str]] = []
self.appended: list[list[str]] = []
def row_values(self, index: int) -> list[str]:
assert index == 1
return list(self.header)
def update(self, values, range_name, value_input_option=None):
self.updates.append((range_name, values[0][0]))
def update_cells(self, cells, value_input_option=None):
self.cell_updates.extend((c.row, c.col, c.value) for c in cells)
def append_row(self, row, value_input_option=None):
self.appended.append(list(row))
@pytest.fixture
def sheet(monkeypatch):
ws = FakeWorksheet(LIVE_HEADER)
row = {h: "" for h in LIVE_HEADER}
row.update({"Nimi": "Mari Tamm", "Discord": "mari", "User ID": "123", "Roll": "Vabatahtlik"})
monkeypatch.setattr(sheets, "_worksheet", ws)
monkeypatch.setattr(sheets, "_headers", [], raising=False)
monkeypatch.setattr(sheets, "_cache", [row])
return ws
def test_set_synced_writes_live_synced_column_not_roll(sheet):
assert run(sheets.set_synced(123, False)) is True
assert sheet.updates == [("K3", "FALSE")] # K = "Discordis synced?", J would be "Roll"
assert sheets.get_cache()[0]["Roll"] == "Vabatahtlik"
def test_batch_set_synced_uses_live_column(sheet):
run(sheets.batch_set_synced([(123, True)]))
assert sheet.cell_updates == [(3, 11, "TRUE")]
def test_update_username_writes_discord_column_not_meil(sheet):
run(sheets.update_username(123, "mari_new"))
assert sheet.updates == [("E3", "mari_new")]
def test_add_new_member_row_places_values_by_header(sheet):
run(sheets.add_new_member_row("uus", 456))
(row,) = sheet.appended
assert len(row) == len(LIVE_HEADER)
assert row[LIVE_HEADER.index("Discord")] == "uus"
assert row[LIVE_HEADER.index("User ID")] == "456"
assert row[LIVE_HEADER.index("Discordis synced?")] == "FALSE"
assert row[LIVE_HEADER.index("Roll")] == ""
assert sheets.find_member_by_id(456)["Discord"] == "uus"
def test_missing_column_refuses_to_write(monkeypatch, sheet):
sheet.header = [h for h in LIVE_HEADER if h != "Discordis synced?"]
assert run(sheets.set_synced(123, True)) is False
run(sheets.batch_set_synced([(123, True)]))
assert sheet.updates == [] and sheet.cell_updates == []

77
tests/test_strings.py Normal file
View File

@@ -0,0 +1,77 @@
"""Guard tests for the strings/ package.
strings/ is split into domain submodules whose names are re-exported from
strings/__init__.py so callers keep using `strings.NAME`. It's easy to add a
constant to a submodule and forget to re-export it (or to shadow a name across
two submodules) - both would only surface as a runtime crash in a command.
These tests catch that at test time instead.
"""
import importlib
import pkgutil
import strings
# Auto-discover submodules so a newly added one is covered without editing this.
SUBMODULES = sorted(m.name for m in pkgutil.iter_modules(strings.__path__))
def _submodule(name):
return importlib.import_module(f"strings.{name}")
class TestStringsPackage:
def test_submodules_discovered(self):
# Sanity: the split actually produced multiple domain modules.
assert len(SUBMODULES) >= 2, SUBMODULES
def test_every_submodule_declares_all(self):
for name in SUBMODULES:
mod = _submodule(name)
assert hasattr(mod, "__all__"), f"strings.{name} is missing __all__"
def test_all_entries_exist_in_their_submodule(self):
for name in SUBMODULES:
mod = _submodule(name)
for const in mod.__all__:
assert hasattr(mod, const), (
f"{const} is listed in strings.{name}.__all__ "
f"but not defined in that module"
)
def test_every_name_is_reexported_from_package(self):
for name in SUBMODULES:
mod = _submodule(name)
for const in mod.__all__:
assert hasattr(strings, const), (
f"{const} is defined in strings.{name} but not re-exported "
f"from strings/__init__.py - add it to the imports there"
)
assert getattr(strings, const) is getattr(mod, const), (
f"strings.{const} is not the same object as strings.{name}.{const}"
)
def test_no_name_defined_in_two_submodules(self):
origin = {}
for name in SUBMODULES:
for const in _submodule(name).__all__:
assert const not in origin, (
f"{const} is defined in both strings.{origin[const]} "
f"and strings.{name}"
)
origin[const] = name
def test_package_all_matches_submodule_union(self):
union = set()
for name in SUBMODULES:
union |= set(_submodule(name).__all__)
assert set(strings.__all__) == union, {
"missing_from_package_all": sorted(union - set(strings.__all__)),
"extra_in_package_all": sorted(set(strings.__all__) - union),
}
def test_public_constants_all_declared(self):
# Every UPPER_CASE constant exposed on the package is accounted for in
# __all__ (E, the emoji helper, is an implementation detail, not a string).
public = {n for n in vars(strings) if n.isupper() and n != "E"}
assert public == set(strings.__all__)

383
tests/test_team_sync.py Normal file
View File

@@ -0,0 +1,383 @@
"""Tests for team-role sync from the tournament registration sheet.
Covers the risky parsing (turning a messy, multi-section, merged-cell sheet
into {team: [discord usernames]}) and the roster-independent add/remove/
auto-create behaviour of sync_team_role, using lightweight fakes for discord
+ the sheets cache.
"""
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 member_sync, sheets # noqa: E402
from tests.conftest import run # noqa: E402
# --- parse_lineup ----------------------------------------------------------
def test_parse_lineup_strips_citizenship_and_splits():
cell = "TFT (EST), nqmm (EST), sn1rk (EST), Kevka (EST), Kalatexx (EST)"
assert sheets.parse_lineup(cell) == ["TFT", "nqmm", "sn1rk", "Kevka", "Kalatexx"]
def test_parse_lineup_keeps_names_containing_commas_and_semicolons():
# A single player's descriptive name contains a ';' - must stay one name.
cell = "Onu Klaus ; vahepeal ka mõni teine tegelane (EST), Lurban (EST)"
assert sheets.parse_lineup(cell) == [
"Onu Klaus ; vahepeal ka mõni teine tegelane",
"Lurban",
]
def test_parse_lineup_handles_odd_usernames():
cell = "-acc +vac (EST), m (EST), M1X3RRRRRR (EST)"
assert sheets.parse_lineup(cell) == ["-acc +vac", "m", "M1X3RRRRRR"]
def test_parse_lineup_mixed_citizenship():
cell = "imp (LAT), milteg (EST), Freesies (EST)"
assert sheets.parse_lineup(cell) == ["imp", "milteg", "Freesies"]
def test_parse_lineup_empty():
assert sheets.parse_lineup("") == []
assert sheets.parse_lineup(" ") == []
def test_parse_lineup_fallback_without_citizenship():
assert sheets.parse_lineup("alice, bob") == ["alice", "bob"]
# --- parse_team_rosters (multi-section sheet) ------------------------------
# Mirrors the real sheet: merged title rows, a header row, team rows, a blank
# separator, then a SECOND section with a different column count.
SHEET_ROWS = [
["", "", "", "", "", "", ""],
["name", "members", "vrs ranking", "registration_date", "game", "", ""],
["[merged] TipiLAN 2026 CS2 Registration Log"] + [""] * 6,
["No", "Team Name", "Lineup (nickname, citizenship)", "VRS Ranking",
"Registration Timestamp", "Status", "Participation confirmed?"],
["1", "Piirivalvurid", "TFT (EST), nqmm (EST)", "N/A", "01.05.2026 15:07", "Confirmed", "Yes"],
["2", "GENESIS", "kapa (EST), neaQ (EST)", "N/A", "01.05.2026 15:24", "Confirmed", "Yes"],
["", "", "", "", "", "", ""],
["[merged] TipiLAN 2026 LoL Registration Log"] + [""] * 4,
["No", "Team Name", "Lineup (nickname, citizenship)",
"Registration Timestamp (dd.mm.yyyy hh:mm)", "Confirmation Status"],
["1", "Pushing 30s", "Onu Klaus (EST), Lurban (EST)", "01.05.2026 21:40", ""],
["", "", "", "", ""],
]
def test_parse_team_rosters_multiple_sections():
rosters = sheets.parse_team_rosters(SHEET_ROWS)
assert rosters == {
"Piirivalvurid": ["TFT", "nqmm"],
"GENESIS": ["kapa", "neaQ"],
"Pushing 30s": ["Onu Klaus", "Lurban"],
}
def test_parse_team_rosters_ignores_non_table_content():
# No header row anywhere -> nothing extracted, no crash.
assert sheets.parse_team_rosters([["just", "some", "prose"], ["more"]]) == {}
# --- parse_team_sections (which game/year a block of teams came from) ------
def test_parse_team_sections_tags_each_block_with_its_title():
sections = sheets.parse_team_sections(SHEET_ROWS)
assert [(s.title, sorted(s.rosters)) for s in sections] == [
("[merged] TipiLAN 2026 CS2 Registration Log", ["GENESIS", "Piirivalvurid"]),
("[merged] TipiLAN 2026 LoL Registration Log", ["Pushing 30s"]),
]
def test_parse_team_sections_title_does_not_leak_to_untitled_block():
rows = [
["[merged] TipiLAN 2026 CS2 Registration Log"] + [""] * 2,
["No", "Team Name", "Lineup (nickname)"],
["1", "GENESIS", "kapa (EST)"],
["", "", ""],
["No", "Team Name", "Lineup (nickname)"], # second block, no title above it
["1", "Nameless", "someone (EST)"],
]
titles = [s.title for s in sheets.parse_team_sections(rows)]
assert titles == ["[merged] TipiLAN 2026 CS2 Registration Log", ""]
# --- resolve_divider (section title -> configured divider role) ------------
DIVIDERS = {
"cs2_2026": 1498736834656604251,
"lol_2026": 1498736949706490017,
}
def test_resolve_divider_matches_game_and_year():
assert sheets.resolve_divider(
"[merged] TipiLAN 2026 CS2 Registration Log", DIVIDERS
) == 1498736834656604251
assert sheets.resolve_divider(
"[merged] TipiLAN 2026 LoL Registration Log", DIVIDERS
) == 1498736949706490017
def test_resolve_divider_year_scoped_key_ignores_other_years():
assert sheets.resolve_divider("TipiLAN 2025 CS2 Registration Log", DIVIDERS) is None
def test_resolve_divider_prefers_the_most_specific_match():
dividers = {"cs2": 111, "cs2_2026": 222}
assert sheets.resolve_divider("TipiLAN 2026 CS2 Log", dividers) == 222
assert sheets.resolve_divider("TipiLAN 2025 CS2 Log", dividers) == 111
def test_resolve_divider_no_config_or_no_title_is_none():
assert sheets.resolve_divider("TipiLAN 2026 CS2 Log", {}) is None
assert sheets.resolve_divider("", DIVIDERS) is None
def test_title_survives_notice_rows_below_it():
# Real sheets stack the game/year title above single-cell notice rows; the
# title's keywords must survive so resolve_divider still matches (regression:
# notice rows used to overwrite the title, yielding a None divider).
rows = [
["TipiLAN 2026 CS2 Registration Log"] + [""] * 4,
["This log is updated automatically."] + [""] * 4,
["If a team from the Top 32 withdraws, ..."] + [""] * 4,
["No", "Team Name", "Lineup (nickname, citizenship)", "", ""],
["1", "GENESIS", "kapa (EST)", "", ""],
]
section = sheets.parse_team_sections(rows)[0]
assert "CS2" in section.title and "2026" in section.title
assert sheets.resolve_divider(section.title, DIVIDERS) == 1498736834656604251
# --- plan_team_positions (pure role-ordering maths) ------------------------
def test_plan_moves_teams_directly_under_their_divider():
# Ascending = bottom-up, so the Discord UI renders this list reversed.
ordered = ["@everyone", "60hz", "999", "Raid", "CS2-2026", "LOL-2026", "Bot"]
plan = member_sync.plan_team_positions(
ordered, {"Bot"}, {"CS2-2026": ["60hz", "999", "Raid"]}
)
final = sorted(set(ordered), key=lambda n: plan.get(n, ordered.index(n)))
# Teams end up just below the divider, alphabetical downwards in the UI.
assert final == ["@everyone", "Raid", "999", "60hz", "CS2-2026", "LOL-2026", "Bot"]
def test_plan_groups_each_divider_separately():
ordered = ["@everyone", "Alpha", "Pushing 30s", "CS2-2026", "LOL-2026", "Bot"]
plan = member_sync.plan_team_positions(
ordered,
{"Bot"},
{"CS2-2026": ["Alpha"], "LOL-2026": ["Pushing 30s"]},
)
final = sorted(set(ordered), key=lambda n: plan.get(n, ordered.index(n)))
assert final == ["@everyone", "Alpha", "CS2-2026", "Pushing 30s", "LOL-2026", "Bot"]
def test_plan_is_a_noop_when_already_positioned():
ordered = ["@everyone", "Raid", "999", "60hz", "CS2-2026", "Bot"]
assert member_sync.plan_team_positions(
ordered, {"Bot"}, {"CS2-2026": ["60hz", "999", "Raid"]}
) == {}
def test_plan_skips_missing_divider_and_leaves_its_teams_alone():
ordered = ["@everyone", "60hz", "CS2-2026", "Bot"]
plan = member_sync.plan_team_positions(
ordered, {"Bot"}, {"LOL-2026": ["60hz"]} # divider not in the guild
)
assert plan == {}
def test_plan_never_moves_managed_roles():
ordered = ["@everyone", "60hz", "CS2-2026", "Nitro", "Bot"]
plan = member_sync.plan_team_positions(
ordered, {"Nitro", "Bot"}, {"CS2-2026": ["60hz", "Nitro"]}
)
assert "Nitro" not in plan and "Bot" not in plan
def test_build_username_index_is_case_insensitive():
index = sheets.build_username_index({"GENESIS": ["Kapa", "neaQ"]})
assert index == {"kapa": "GENESIS", "neaq": "GENESIS"}
# --- sync_team_role behaviour (roster-independent) -------------------------
class FakeRole:
def __init__(self, rid: int, name: str):
self.id = rid
self.name = name
def __eq__(self, other):
return isinstance(other, FakeRole) and other.id == self.id
def __hash__(self):
return hash(self.id)
class FakeMember:
def __init__(self, uid: int, name: str, roles, bot: bool = False):
self.id = uid
self.name = name
self.display_name = name
self.bot = bot
self.roles = list(roles)
async def add_roles(self, *roles, reason=None):
self.roles.extend(roles)
async def remove_roles(self, *roles, reason=None):
self.roles = [r for r in self.roles if r not in roles]
class FakeGuild:
def __init__(self, roles, members=None):
self.roles = list(roles)
self.members = list(members or [])
self._next = 9000
self.created: list[str] = []
async def create_role(self, name, reason=None):
self._next += 1
role = FakeRole(self._next, name)
self.roles.append(role)
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")
keeper = FakeRole(2, "Member") # not a team role - must be left alone
member = FakeMember(1, "tft", roles=[old_team, keeper])
guild = FakeGuild([old_team, keeper])
monkeypatch.setattr(sheets, "get_team_for_username",
lambda n: "GENESIS" if n.lower() == "tft" else None)
monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS", "OldTeam"})
result = run(member_sync.sync_team_role(member, guild))
assert result.created == "GENESIS" # auto-created the missing role
assert "GENESIS" in guild.created
assert result.added == "GENESIS"
assert result.removed == ["OldTeam"] # left their previous team
role_names = {r.name for r in member.roles}
assert "GENESIS" in role_names
assert "OldTeam" not in role_names
assert "Member" in role_names # unrelated role untouched
def test_sync_uses_existing_team_role(monkeypatch):
genesis = FakeRole(3, "GENESIS")
member = FakeMember(1, "kapa", roles=[])
guild = FakeGuild([genesis])
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: "GENESIS")
monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS"})
result = run(member_sync.sync_team_role(member, guild))
assert guild.created == [] # did NOT create a duplicate
assert result.created is None
assert result.added == "GENESIS"
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])
guild = FakeGuild([old_team])
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: None)
monkeypatch.setattr(sheets, "all_team_names", lambda: {"OldTeam"})
result = run(member_sync.sync_team_role(member, guild))
assert result.removed == ["OldTeam"]
assert result.added is None
assert old_team not in member.roles
def test_sync_no_team_sheet_is_noop(monkeypatch):
keeper = FakeRole(2, "Member")
member = FakeMember(1, "someone", roles=[keeper])
guild = FakeGuild([keeper])
# Empty caches = feature switched off: no removals even of a stale team role.
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: None)
monkeypatch.setattr(sheets, "all_team_names", lambda: set())
result = run(member_sync.sync_team_role(member, guild))
assert result.removed == []
assert guild.created == []
assert keeper in member.roles
def test_sync_all_team_roles_aggregates_and_skips_bots(monkeypatch):
genesis = FakeRole(3, "GENESIS")
m1 = FakeMember(1, "kapa", roles=[]) # will get GENESIS
m2 = FakeMember(2, "nobody", roles=[]) # not registered, unchanged
bot_member = FakeMember(3, "botto", roles=[], bot=True) # skipped
guild = FakeGuild([genesis], members=[m1, m2, bot_member])
teams = {"kapa": "GENESIS"}
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: teams.get(n.lower()))
monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS"})
summary = run(member_sync.sync_all_team_roles(guild))
assert summary.scanned == 2 # bot excluded
assert summary.assigned == 1
assert summary.removed == 0
assert summary.changes == ["kapa: +GENESIS"]

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