32 Commits

Author SHA1 Message Date
Rene Arumetsa
bd3fb7d3b8 feat(teams): add opt-in /teamsync reposition to tidy existing team roles
Creation-time placement only positions NEW team roles. Team roles made before
divider placement existed (sitting at the bottom of the role list) stayed put.

Add `reposition_team_roles(guild)` and expose it via `/teamsync reposition:true`
(off by default, so the hourly task never reorders roles on its own). It moves
each EXISTING team role under its game's divider, sheet-driven:

- a role's game/divider comes from sheets.get_game_for_team;
- roles whose team is no longer in the sheet (or game unknown) are left alone;
- idempotent: a role already within its section band (below its own divider,
  above the next divider down) is skipped, so re-runs move nothing.

Adds RepositionSummary, TEAMSYNC_UI/OPT strings, and tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R6JZkyszyDFuFtk25WBbcR
2026-08-28 16:05:38 +03:00
Rene Arumetsa
e179a35fc4 feat(teams): place new team roles under their game's divider role
Auto-created team roles now land directly under the CS2 / LoL "divider" role
in the community guild's role list, keeping it grouped by game.

- config: CS2_DIVIDER_ROLE_ID / LOL_DIVIDER_ROLE_ID (season-specific, env
  overridable) exposed as TEAM_DIVIDER_ROLE_IDS keyed by game code.
- core/sheets: tag each team with its game (CS2/LoL) from the section title it
  sits under; shared _scan_sections generator feeds both parse_team_rosters and
  the new parse_team_games; get_game_for_team accessor + _team_game cache.
- core/member_sync: on creating a team role, position it at its game divider's
  slot (best-effort; left in place if game/divider unknown or move refused).
- .env.example + tests for game tagging and role placement.

Each participant is in exactly one game, so one role per team is unambiguous.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R6JZkyszyDFuFtk25WBbcR
2026-08-28 15:51:48 +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
54 changed files with 6872 additions and 3549 deletions

View File

@@ -9,6 +9,17 @@ 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; drives /teamsync + hourly team-role sync on
# the economy/community bot. Leave unset to disable team-role sync entirely.
TEAM_SHEET_ID=
# Team-section "divider" role IDs. A newly-created team role is positioned
# directly under the divider for its game (CS2 / LoL). Season-specific - update
# yearly. 0 = leave that game's new roles at the bottom of the role list.
CS2_DIVIDER_ROLE_ID=
LOL_DIVIDER_ROLE_ID=
# Path to Google service account credentials JSON
GOOGLE_CREDS_PATH=credentials.json

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

View File

@@ -70,7 +70,7 @@ The economy system stores all player data in [PocketBase](https://pocketbase.io/
1. Download `pocketbase.exe` (Windows) from https://pocketbase.io/docs/ and place it in the project root.
2. Start PocketBase: `.\pocketbase.exe serve`
3. Open the admin UI at http://127.0.0.1:8090/_/ and create a superuser account.
4. Create two collections for profile separation: `economy_users_dev` and `economy_users_prod` - see `docs/POCKETBASE_SETUP.md` for schema notes.
4. Create 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`
@@ -222,7 +222,7 @@ If a member joins and their birthday is within `BIRTHDAY_WINDOW_DAYS` days, a bi
## 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 +245,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. 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 +262,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 +281,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.
@@ -340,6 +340,7 @@ The **ECONOMY** role is granted on your first EXP award (i.e. first successful e
| `/shop` | Browse all items by tier. Shows owned status, Anticheat charges remaining, and level lock for T2/T3. |
| `/buy <item>` | Purchase an item by name (partial match accepted). |
| `/reminders` | Toggle per-command DM notifications. Bot DMs you the moment each cooldown expires. |
| `/quests` | Personal daily (3) and weekly (2) quests with progress bars and a claim button. |
| `/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 +352,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 +381,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 +421,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 |
| Anticheat | 1 000 ⬡ | Rob attempts against you fail and fine the robber. **2 uses**, then repurchase. |
| 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) |
| 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 +457,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 +483,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 +521,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 +532,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

120
bot.py
View File

@@ -23,7 +23,7 @@ import config
import strings as S
from core import economy, 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,47 @@ async def before_birthday_daily():
await bot.wait_until_ready()
@tasks.loop(hours=1)
async def team_sync_hourly():
"""Reload the tournament registration sheet and re-apply team roles.
Economy profile only (the tournament players live in the community guild).
Runs the first iteration immediately on start, so this also covers the
initial load at boot. No-op when TEAM_SHEET_ID is unset.
"""
if IS_DEV_PROFILE or not config.TEAM_SHEET_ID:
return
try:
rosters = await sheets.refresh_teams()
except Exception as e:
log.error("team_sync_hourly: failed to load team sheet: %s", e)
return
if not rosters:
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:
log.info(
"team_sync_hourly: assigned=%d, removed=%d, created=%d, errors=%d",
summary.assigned, summary.removed, len(summary.created), len(summary.errors),
)
@team_sync_hourly.before_loop
async def before_team_sync_hourly():
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 +399,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 +429,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 +468,11 @@ async def on_ready():
birthday_daily.start()
log.info("Birthday daily task started (fires 09:00 Tallinn time)")
# Start hourly tournament team-role sync (economy/community guild)
if not IS_DEV_PROFILE and config.TEAM_SHEET_ID and not team_sync_hourly.is_running():
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()
@@ -461,6 +530,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,12 +667,7 @@ class HelpSelect(discord.ui.Select):
@tree.command(name="help", description=S.CMD["help"])
async def cmd_help(interaction: discord.Interaction):
<<<<<<< HEAD
is_admin = is_bot_admin(interaction.user)
=======
member = interaction.user
is_admin = isinstance(member, discord.Member) and is_bot_admin(member)
>>>>>>> 42f7bae68124fa6a9824780ba17b46d00f3f2b36
await interaction.response.send_message(
embed=_help_embed("üldine"), view=HelpView(is_admin), ephemeral=True
)
@@ -820,6 +888,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,
@@ -864,6 +939,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)

View File

@@ -396,12 +396,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()
@@ -414,6 +419,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"],

View File

@@ -792,6 +792,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
@@ -864,6 +869,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()
@@ -949,80 +957,111 @@ 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"]:
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"]:
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))

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

@@ -38,15 +38,21 @@ 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
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 +90,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 +163,81 @@ 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"] == "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"]:
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)
class RemindersSelect(discord.ui.Select):
def __init__(self, user_id: int, current: list[str]):
self.user_id = user_id

View File

@@ -0,0 +1,110 @@
"""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 sheets
from core.admin import bot_admin_check
from core.member_sync import reposition_team_roles, 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()
@app_commands.describe(reposition=S.OPT["teamsync_reposition"])
@bot_admin_check()
async def cmd_teamsync(interaction: discord.Interaction, reposition: bool = False):
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()
except Exception as e:
await interaction.followup.send(
S.TEAMSYNC_UI["refresh_error"].format(error=e), ephemeral=True
)
return
if not rosters:
await interaction.followup.send(S.TEAMSYNC_UI["disabled"], ephemeral=True)
return
summary = await sync_all_team_roles(guild, log)
message = _format_summary(summary)
repo = None
if reposition:
repo = await reposition_team_roles(guild, log)
message += "\n\n" + _format_reposition(repo)
await interaction.followup.send(message, ephemeral=True)
log.info(
"/teamsync - scanned=%d, assigned=%d, removed=%d, created=%d, errors=%d%s",
summary.scanned,
summary.assigned,
summary.removed,
len(summary.created),
len(summary.errors),
f", repositioned={repo.moved}" if repo else "",
)
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.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
def _format_reposition(repo) -> str:
lines = [S.TEAMSYNC_UI["reposition_header"]]
if not repo.moved:
lines.append(S.TEAMSYNC_UI["reposition_none"])
else:
lines.append(S.TEAMSYNC_UI["repositioned"].format(count=repo.moved))
lines.extend(repo.moves[:20])
if len(repo.moves) > 20:
lines.append(S.TEAMSYNC_UI["changes_more"].format(count=len(repo.moves) - 20))
if repo.errors:
lines.append(S.TEAMSYNC_UI["errors"].format(count=len(repo.errors)))
return "\n".join(lines)

View File

@@ -23,6 +23,9 @@ 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")
_LEGACY_GUILD_ID = _env_int("GUILD_ID", 0)
@@ -42,22 +45,42 @@ BIRTHDAY_CHANNEL_ID = (
BIRTHDAY_WINDOW_DAYS = int(os.getenv("BIRTHDAY_WINDOW_DAYS", "7"))
BASE_ROLE_IDS: list[int] = [1478304631930228779, 1478302278862766190]
# Team-section "divider" roles in the community guild. When a new team role is
# auto-created it is positioned directly under the divider for its game, so the
# role list stays grouped by game. Season-specific - update yearly (or override
# via CS2_DIVIDER_ROLE_ID / LOL_DIVIDER_ROLE_ID). 0 disables placement for that
# game (the role is still created, just left at the bottom of the list).
CS2_DIVIDER_ROLE_ID = _env_int("CS2_DIVIDER_ROLE_ID", 1498736834656604251)
LOL_DIVIDER_ROLE_ID = _env_int("LOL_DIVIDER_ROLE_ID", 1498736949706490017)
# Keyed by the canonical game code produced by core.sheets._detect_game.
TEAM_DIVIDER_ROLE_IDS: dict[str, int] = {
"CS2": CS2_DIVIDER_ROLE_ID,
"LoL": LOL_DIVIDER_ROLE_ID,
}
def _parse_admin_roles(raw: str) -> dict[int, int]:
"""Parse BOT_ADMIN_ROLES env var as "guild_id:role_id,guild_id:role_id"."""
result: dict[int, int] = {}
for pair in raw.split(","):
pair = pair.strip()
if not pair:
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
guild_str, _, role_str = pair.partition(":")
if not role_str:
raise SystemExit(f"BOT_ADMIN_ROLES: expected 'guild_id:role_id', got {pair!r}")
result[int(guild_str)] = int(role_str)
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, int] = _parse_admin_roles(os.getenv("BOT_ADMIN_ROLES", ""))
BOT_ADMIN_ROLES: dict[int, set[int]] = _parse_admin_roles(os.getenv("DISCORD_ADMIN_ROLES", ""))
PB_URL = os.getenv("PB_URL", "http://127.0.0.1:8090")
PB_ADMIN_EMAIL = os.getenv("PB_ADMIN_EMAIL", "")
@@ -75,26 +98,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,38 +6,22 @@ from discord import app_commands
import config
<<<<<<< HEAD
def is_bot_admin(member: discord.abc.User | None) -> bool:
"""True when the member has the configured admin role for their guild."""
"""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
=======
def is_bot_admin(member: discord.Member) -> bool:
"""Return True if the member has the configured bot-admin role for their guild."""
>>>>>>> 42f7bae68124fa6a9824780ba17b46d00f3f2b36
role_id = config.BOT_ADMIN_ROLES.get(member.guild.id)
if role_id is None:
admin_role_ids = config.BOT_ADMIN_ROLES.get(member.guild.id)
if not admin_role_ids:
return False
return any(r.id == role_id for r in member.roles)
return any(r.id in admin_role_ids for r in member.roles)
def bot_admin_check():
<<<<<<< HEAD
"""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"])
=======
"""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
>>>>>>> 42f7bae68124fa6a9824780ba17b46d00f3f2b36
return app_commands.check(predicate)

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,36 @@
"""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 .levels import *
from .shop import *
from .consumables 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 .admin import *
from . import ( # noqa: E402 (submodules addressable as economy.store etc.)
admin, consumables, fishing, gambling, heist, house, income, jail,
leaderboards, levels, prestige, quests, shop, store,
)

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

@@ -0,0 +1,165 @@
"""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,
"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"}

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

@@ -0,0 +1,146 @@
"""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
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")
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
# /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:
buffs = dict(user.get("active_buffs") or {})
current = _parse_dt(buffs.get(cons["kind"]))
# Stack: extend from the current expiry if still active, else from now.
extended = current is not None and current > _now()
start = current if extended else _now()
buffs[cons["kind"]] = (start + timedelta(minutes=cons["duration_min"])).isoformat()
user["active_buffs"] = buffs
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"]),
}

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

@@ -0,0 +1,194 @@
"""Fishing minigame: catalogue, rolls, catch/sell flows."""
from __future__ import annotations
import random
from datetime import timedelta
from ..pb_client import DatabaseError
from .store import (
COOLDOWNS, _cooldown_remaining, _commit, _is_jailed, _locked_by, _now,
_prestige_mult, _txn, get_user,
)
# ---------------------------------------------------------------------------
# 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 = timedelta(seconds=90) if "ussipurk" in user["items"] else COOLDOWNS["fish"]
if cd := _cooldown_remaining(user, "fish", override_cd=fish_cd):
return {"ok": False, "reason": "cooldown", "remaining": cd}
user["last_fish"] = _now().isoformat()
await _commit(user_id, user)
return {"ok": True}
@_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),
}

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

@@ -0,0 +1,275 @@
"""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, _txn, 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
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"])
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_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)
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
await _commit(user_id, user)
return {"ok": True, "balance": user["balance"]}
@_locked_by(0)
async def do_blackjack_payout(user_id: int, payout: int, total_invested: int = 0) -> dict:
"""Credit the net payout. House receives the difference when payout < total_invested."""
user = await get_user(user_id)
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))
await _commit(user_id, user)
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, _refund_user_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 _refund_user_safe(house.HOUSE_ID, fine if 'fine' in locals() else 0, "heist_fail_compensate", uid)
return {"ok": True, "payout_each": payout_each, "success": success, "failed_users": failed_users}

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

@@ -0,0 +1,91 @@
"""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 _refund_user_safe(_unused_house_id, amount: int, context: str, uid: int) -> None:
"""Best-effort atomic debit of `amount` from the house (compensates a failed
user fine). Logs critical if it fails."""
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,
)

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

@@ -0,0 +1,405 @@
"""Income and social commands: daily, work, beg, crime, rob, give."""
from __future__ import annotations
import random
from datetime import date, timedelta
import strings
from ..pb_client import DatabaseError
from . import house
from .store import (
COOLDOWNS, JAIL_DURATION, PRESTIGE_SHOP, _commit, _cooldown_remaining,
_is_jailed, _locked_by, _log, _now, _prestige_mult, _txn, get_user,
)
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 = timedelta(hours=18) if "korvaklapid" in user["items"] else COOLDOWNS["daily"]
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 = timedelta(minutes=40) if "monitor" in user["items"] else COOLDOWNS["work"]
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 = timedelta(minutes=3) if "hiirematt" in user["items"] else COOLDOWNS["beg"]
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,82 @@
"""Leaderboard queries over the full collection."""
from __future__ import annotations
from .. import pb_client
from .levels import get_level
async def get_leaderboard(top_n: int | None = 10) -> list[tuple[str, int]]:
"""Return top_n (user_id_str, balance) pairs sorted descending."""
records = await pb_client.list_all_records()
result = sorted(
((r["user_id"], r.get("balance", 0)) for r in records if r.get("user_id")),
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]

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}

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

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

@@ -0,0 +1,386 @@
"""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),
}
JAIL_DURATION = timedelta(minutes=30)
HEIST_JAIL = timedelta(hours=1, minutes=30)
# ---------------------------------------------------------------------------
# User schema
# ---------------------------------------------------------------------------
class UserData(TypedDict, total=False):
balance: int
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
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
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}}}
def _default_user() -> UserData:
return {
"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": {},
"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,
"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": {},
}
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
# /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)

View File

@@ -37,7 +37,7 @@ _DEV: dict[str, str] = {
"TipiTROLL": "<:TipiTROLL:1483431380166774895>",
"TipICRY": "<:TipICRY:1483431288852709387>",
"TipiSKULL": "<:TipiSKULL:1483431378929451028>",
"TipiDICE": "<:TipiDICE:1485923107108556950>",
"TipiDICE": "<a:TipiDICE:1485923107108556950>",
"TipiYKS": "<:TipiYKS:1483103190491856916>",
"TipiKAKS": "<:TipiKAKS:1483103215841972404>",
"TipiKOLM": "<:TipiKOLM:1483103217846980781>",
@@ -47,11 +47,42 @@ _DEV: dict[str, str] = {
"TipiSLOTS": "<a:TipiSLOTS:1483444233863037101>",
}
# Production application emoji IDs. Replace each ID with the one from the
# prod application in the dev portal. The dev IDs below are placeholders so
# this dict is structurally complete — they will NOT render in prod because
# they belong to a different application.
_ECONOMY: dict[str, str] = dict(_DEV)
# 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 = {

View File

@@ -48,7 +48,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 +56,40 @@ 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
errors: list[str] = field(default_factory=list)
@property
def changed(self) -> bool:
return bool(self.added or self.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)
changes: list[str] = field(default_factory=list) # human-readable per-member lines
errors: list[str] = field(default_factory=list)
@dataclass
class RepositionSummary:
"""Aggregate outcome of the opt-in existing-team-role reposition pass."""
scanned: int = 0 # existing team roles considered
moved: int = 0 # roles relocated under their divider
moves: list[str] = field(default_factory=list) # "role -> game" lines
errors: list[str] = field(default_factory=list)
def _format_nickname(full_name: str) -> str:
@@ -224,6 +256,195 @@ async def sync_member(
return result
async def _position_team_role_under_divider(
guild: discord.Guild,
role: discord.Role,
team_name: str,
result: TeamSyncResult,
) -> None:
"""Move a freshly-created team role directly under its game's divider role.
The team's game comes from the registration sheet; the divider role IDs come
from config (``TEAM_DIVIDER_ROLE_IDS``). Best-effort: if the game/divider is
unknown or the move is refused, the role just stays where it was created.
"""
game = sheets.get_game_for_team(team_name)
divider_id = config.TEAM_DIVIDER_ROLE_IDS.get(game) if game else None
if not divider_id:
return
divider = guild.get_role(divider_id)
if divider is None:
log.warning("Divider role %s for game %s not found in guild", divider_id, game)
return
try:
await role.edit(position=divider.position, reason="Team sync: paiguta tiimide alla")
log.info("Positioned team role %r under the %s divider", team_name, game)
except discord.Forbidden:
result.errors.append(f"Tiimirolli '{team_name}' paigutamiseks puudub õigus")
except discord.HTTPException as e:
result.errors.append(f"Tiimirolli '{team_name}' paigutamine ebaõnnestus: {e}")
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).
Only role NAMES present in the team sheet are ever added or removed, so no
unrelated role is ever at risk. When ``TEAM_SHEET_ID`` is unset the caches
are empty and this is a no-op returning an unchanged result.
"""
result = TeamSyncResult()
team_name = sheets.get_team_for_username(member.name)
all_teams = sheets.all_team_names()
if not all_teams:
return result # feature switched off (no team sheet loaded)
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}")
if result.created and desired is not None:
await _position_team_role_under_divider(guild, desired, team_name, result)
# 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}")
return result
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.changed:
bits: list[str] = []
if res.added:
bits.append(f"+{res.added}")
if res.removed:
bits.append("-" + ", -".join(res.removed))
summary.changes.append(f"{member.display_name}: {', '.join(bits)}")
return summary
def _resolve_dividers(guild: discord.Guild) -> dict[str, discord.Role]:
"""Map each configured game code to its divider role present in the guild."""
dividers: dict[str, discord.Role] = {}
for game, rid in config.TEAM_DIVIDER_ROLE_IDS.items():
if not rid:
continue
role = guild.get_role(rid)
if role is not None:
dividers[game] = role
return dividers
async def reposition_team_roles(
guild: discord.Guild,
log: logging.Logger = log,
) -> RepositionSummary:
"""Move EXISTING team roles under their game's divider (opt-in cleanup).
Unlike creation-time placement this also relocates team roles that were made
before divider placement existed and are sitting at the bottom of the list.
Sheet-driven: a role's game (and thus its divider) comes from
``sheets.get_game_for_team``; roles whose team is no longer in the sheet, or
whose game/divider is unknown, are left untouched.
Idempotent: a role already within its section band (below its own divider and
above the next divider down) is skipped, so re-running moves nothing.
"""
summary = RepositionSummary()
teams = sheets.all_team_names()
dividers = _resolve_dividers(guild)
if not teams or not dividers:
return summary
for role in list(guild.roles):
if role.name not in teams:
continue
summary.scanned += 1
game = sheets.get_game_for_team(role.name)
divider = dividers.get(game) if game else None
if divider is None:
continue # unknown game / divider not in guild -> leave in place
# Section band = (highest divider below this one, this divider). A role
# already inside it is grouped correctly; only relocate outliers.
lower = max(
(d.position for d in dividers.values() if d.position < divider.position),
default=0,
)
if lower < role.position < divider.position:
continue
try:
await role.edit(position=divider.position, reason="Team sync: reposition")
summary.moved += 1
summary.moves.append(f"{role.name} -> {game}")
log.info("Repositioned team role %r under the %s divider", role.name, game)
except discord.Forbidden:
summary.errors.append(f"Tiimirolli '{role.name}' paigutamiseks puudub õigus")
except discord.HTTPException as e:
summary.errors.append(f"Tiimirolli '{role.name}' paigutamine ebaõnnestus: {e}")
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,16 @@ Pure-cache helpers (get_cache, find_*) remain sync.
"""
import asyncio
import logging
import re
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",
@@ -69,12 +73,25 @@ def _get_worksheet() -> gspread.Worksheet:
def _ensure_headers(ws: gspread.Worksheet) -> None:
"""If the sheet is empty or missing headers, write them (headers are in row 1)."""
"""Verify the header row matches what we expect.
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; column lookups still work as long as the headers we rely on exist.
"""
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]
log.warning(
"Sheet header row does not match EXPECTED_HEADERS "
"(missing/renamed: %s). Expected %s, found %s. "
"Not writing to the (protected) header row; fix it manually if needed.",
missing or "none — order/whitespace differs",
EXPECTED_HEADERS,
existing,
)
def _refresh_sync() -> list[dict]:
@@ -234,3 +251,213 @@ def _add_new_member_row_sync(username: str, discord_id: int) -> None:
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"
# Canonical game code -> substrings that identify that game's section title.
# The sheet stacks a CS2 section and a LoL section, each introduced by a title
# row like "TipiLAN 2026 CS2 Registration Log"; teams are tagged with the game
# of the section they sit under so their Discord role can be placed accordingly.
_GAME_KEYWORDS: list[tuple[str, tuple[str, ...]]] = [
("CS2", ("cs2", "counter-strike", "counter strike", "csgo", "cs:go")),
("LoL", ("lol", "league of legends", "league")),
]
# 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_game: dict[str, str] = {} # team name -> game code ("CS2"/"LoL")
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 _detect_game(row: list) -> str | None:
"""Classify a row as a CS2 / LoL section title, or None if it's neither."""
text = " ".join(str(c) for c in row).lower()
for game, keys in _GAME_KEYWORDS:
if any(k in text for k in keys):
return game
return None
def _scan_sections(rows: list[list]):
"""Yield ``(team, players, game)`` for every team row across the sheet.
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. The
most recent game section title seen (via :func:`_detect_game`) tags every
team in the block that follows.
"""
current_game: str | None = None
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:
game = _detect_game(rows[i]) # title / meta row - may name the game
if game:
current_game = game
i += 1
continue
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))
yield team, players, current_game
i += 1
def parse_team_rosters(rows: list[list]) -> dict[str, list[str]]:
"""Extract {team_name: [nickname, ...]} from a tab's raw rows."""
rosters: dict[str, list[str]] = {}
for team, players, _game in _scan_sections(rows):
if players:
rosters.setdefault(team, []).extend(players)
return rosters
def parse_team_games(rows: list[list]) -> dict[str, str]:
"""Extract {team_name: game_code} from a tab's raw rows (first game wins)."""
games: dict[str, str] = {}
for team, _players, game in _scan_sections(rows):
if game and team not in games:
games[team] = game
return games
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_game
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]] = {}
games: dict[str, str] = {}
for ws in spreadsheet.worksheets():
vals = ws.get_all_values()
for team, players in parse_team_rosters(vals).items():
rosters.setdefault(team, []).extend(players)
for team, game in parse_team_games(vals).items():
games.setdefault(team, game)
_team_roster = rosters
_team_by_username = build_username_index(rosters)
_team_names = set(rosters)
_team_game = games
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_game_for_team(team: str) -> str | None:
"""Return the game code ('CS2'/'LoL') a team is registered under, or None."""
return _team_game.get(team)
def get_team_rosters() -> dict[str, list[str]]:
"""Current {team: [usernames]} cache (mainly for diagnostics/tests)."""
return _team_roster

View File

@@ -9,14 +9,14 @@ 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`, `JAIL_DURATION`, `COIN`, `get_user`/`_commit`/`_txn`), `income.py`, `gambling.py`, `fishing.py`, `jail.py`, `heist.py`, `prestige.py`, `shop.py`, `levels.py`, `quests.py`, `leaderboards.py`, `house.py`, `admin.py` |
| `core/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 |
@@ -57,19 +57,19 @@ 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
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
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
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
13. **`strings/common.py` `REMINDER_OPTS`** - add a reminder option if the command needs one
14. **`bot.py` `_maybe_remind`** - if the command has an item-modified cooldown, add an `elif` branch (this helper still lives in `bot.py` and is shared across all command modules)
---
@@ -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,7 +110,7 @@ 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
@@ -141,10 +141,10 @@ 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).
---
@@ -179,49 +179,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())

131
scripts/sync_pb_schema.py Normal file
View File

@@ -0,0 +1,131 @@
"""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 whose default is None, all ISO date/datetime strings
_TEXT_FIELDS = {
"last_daily", "last_work", "last_beg", "last_crime", "last_rob",
"last_heist", "last_fish", "last_streak_date", "jailed_until",
}
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()))

1374
strings.py

File diff suppressed because it is too large Load Diff

192
strings/__init__.py Normal file
View File

@@ -0,0 +1,192 @@
"""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,
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',
'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',
]

125
strings/admin.py Normal file
View File

@@ -0,0 +1,125 @@
"""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_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",
}

227
strings/commands.py Normal file
View File

@@ -0,0 +1,227 @@
"""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)",
}
# ---------------------------------------------------------------------------
# Option descriptions (shown next to each parameter in Discord)
# ---------------------------------------------------------------------------
OPT: dict[str, str] = {
"teamsync_reposition": "Paiguta ka olemasolevad tiimirollid nende mängu jaotise alla",
"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)",
"consumable_ese": "Turgutus, mida osta (tühjaks jättes näeb menüüd)",
"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"),
("/quests", "Vaata oma päeva- ja nädalaülesandeid ning nõua auhinnad (uueneb iga päev/nädal)."),
("/leaderboard", "TipiBOTi edetabel - kes on kõige rikkam?"),
("/shop", "Sirvi TipiBOTi poodi"),
("/buy <item>", "Osta ese TipiBOTi poodist"),
("/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...",
}

247
strings/common.py Normal file
View File

@@ -0,0 +1,247 @@
"""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}.",
}
# ---------------------------------------------------------------------------
# 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)",
"log_files_field": "📂 Log files",
"log_line": "`{name}` - {size_kb} KB",
"none": "-",
}

469
strings/economy.py Normal file
View File

@@ -0,0 +1,469 @@
"""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',
'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}",
}
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",
}
# ---------------------------------------------------------------------------
# /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}",
"errors": "⚠️ Vead: {count}",
"no_changes": "✨ Kõik tiimirollid olid juba korras.",
"changes_header": "**Muudatused:**",
"changes_more": "... ja {count} rohkem",
"reposition_header": "**Ümberpaigutus:**",
"repositioned": "📦 Ümber paigutatud rolle: {count}",
"reposition_none": "✨ Olemasolevad tiimirollid olid juba õiges sektsioonis.",
}

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)

102
tests/test_consumables.py Normal file
View File

@@ -0,0 +1,102 @@
"""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

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

View File

@@ -0,0 +1,86 @@
"""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 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

View File

@@ -0,0 +1,91 @@
"""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 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

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

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__)

318
tests/test_team_sync.py Normal file
View File

@@ -0,0 +1,318 @@
"""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"]]) == {}
def test_parse_team_games_tags_each_team_with_its_section():
# Teams inherit the game of the stacked section they sit under.
assert sheets.parse_team_games(SHEET_ROWS) == {
"Piirivalvurid": "CS2",
"GENESIS": "CS2",
"Pushing 30s": "LoL",
}
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, position: int = 0):
self.id = rid
self.name = name
self.position = position
async def edit(self, position=None, reason=None):
if position is not None:
self.position = position
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] = []
def get_role(self, rid):
return next((r for r in self.roles if r.id == rid), None)
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 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_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"]
def test_new_team_role_is_positioned_under_its_game_divider(monkeypatch):
divider = FakeRole(500, "===== COUNTER-STRIKE 2 2026 =====", position=10)
member = FakeMember(1, "tft", roles=[])
guild = FakeGuild([divider], members=[member])
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: "NEWTEAM")
monkeypatch.setattr(sheets, "all_team_names", lambda: {"NEWTEAM"})
monkeypatch.setattr(sheets, "get_game_for_team", lambda t: "CS2")
monkeypatch.setattr(config, "TEAM_DIVIDER_ROLE_IDS", {"CS2": 500})
result = run(member_sync.sync_team_role(member, guild))
assert result.created == "NEWTEAM"
new_role = next(r for r in guild.roles if r.name == "NEWTEAM")
assert new_role.position == 10 # slotted at the divider
def test_new_team_role_without_known_divider_is_left_in_place(monkeypatch):
member = FakeMember(1, "tft", roles=[])
guild = FakeGuild([], members=[member])
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: "NEWTEAM")
monkeypatch.setattr(sheets, "all_team_names", lambda: {"NEWTEAM"})
monkeypatch.setattr(sheets, "get_game_for_team", lambda t: None) # game unknown
monkeypatch.setattr(config, "TEAM_DIVIDER_ROLE_IDS", {"CS2": 500})
result = run(member_sync.sync_team_role(member, guild))
assert result.created == "NEWTEAM" # still created, just not moved
new_role = next(r for r in guild.roles if r.name == "NEWTEAM")
assert new_role.position == 0 # default, untouched
# --- reposition_team_roles (opt-in cleanup of existing roles) ---------------
def test_reposition_moves_out_of_section_roles_and_skips_placed_ones(monkeypatch):
cs2_div = FakeRole(100, "CS2 divider", position=20)
lol_div = FakeRole(200, "LoL divider", position=10)
genesis = FakeRole(1, "GENESIS", position=2) # CS2 team stuck at bottom
pushing = FakeRole(2, "Pushing 30s", position=5) # LoL team already in band
unrelated = FakeRole(3, "Moderator", position=30) # not a team - ignored
guild = FakeGuild([cs2_div, lol_div, genesis, pushing, unrelated])
games = {"GENESIS": "CS2", "Pushing 30s": "LoL"}
monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS", "Pushing 30s"})
monkeypatch.setattr(sheets, "get_game_for_team", lambda t: games.get(t))
monkeypatch.setattr(config, "TEAM_DIVIDER_ROLE_IDS", {"CS2": 100, "LoL": 200})
repo = run(member_sync.reposition_team_roles(guild))
assert repo.scanned == 2 # only the two team roles
assert repo.moved == 1 # only GENESIS was out of place
assert repo.moves == ["GENESIS -> CS2"]
assert genesis.position == 20 # moved under the CS2 divider
assert pushing.position == 5 # already in its band, untouched
assert unrelated.position == 30 # non-team role never considered
def test_reposition_leaves_role_whose_team_is_not_in_sheet(monkeypatch):
cs2_div = FakeRole(100, "CS2 divider", position=20)
lol_div = FakeRole(200, "LoL divider", position=10)
ghost = FakeRole(1, "GhostTeam", position=2) # a team role, but gone from sheet
guild = FakeGuild([cs2_div, lol_div, ghost])
# Team no longer registered -> not in all_team_names, so never scanned/moved.
monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS"})
monkeypatch.setattr(sheets, "get_game_for_team", lambda t: None)
monkeypatch.setattr(config, "TEAM_DIVIDER_ROLE_IDS", {"CS2": 100, "LoL": 200})
repo = run(member_sync.reposition_team_roles(guild))
assert repo.scanned == 0
assert repo.moved == 0
assert ghost.position == 2 # left exactly where it was