Merge branch 'fix/economy-money-safety'
This commit is contained in:
@@ -31,6 +31,12 @@ BIRTHDAY_CHANNEL_ID=
|
|||||||
# How many days before a birthday the on-join check counts as "coming up"
|
# How many days before a birthday the on-join check counts as "coming up"
|
||||||
BIRTHDAY_WINDOW_DAYS=7
|
BIRTHDAY_WINDOW_DAYS=7
|
||||||
|
|
||||||
|
# Channel ID where the daily lottery draw result is announced (optional - the
|
||||||
|
# draw still runs and pays the winner if unset; per-profile like BIRTHDAY_CHANNEL)
|
||||||
|
LOTTERY_CHANNEL_ID_DEV=
|
||||||
|
LOTTERY_CHANNEL_ID_ECONOMY=
|
||||||
|
LOTTERY_CHANNEL_ID=
|
||||||
|
|
||||||
# PocketBase backend (https://pocketbase.io)
|
# PocketBase backend (https://pocketbase.io)
|
||||||
PB_URL=http://127.0.0.1:8090
|
PB_URL=http://127.0.0.1:8090
|
||||||
PB_ADMIN_EMAIL=admin@example.com
|
PB_ADMIN_EMAIL=admin@example.com
|
||||||
|
|||||||
66
CLAUDE.md
Normal file
66
CLAUDE.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
Discord bot for the TipiLAN community (`discord.py`). Two responsibilities: **member management** (syncs nicknames/roles from a Google Sheet, birthday announcements) and the **TipiCOIN economy** (a large game economy stored in PocketBase). User-facing text is **Estonian**; only `docs/PATCHNOTES.md` content is English.
|
||||||
|
|
||||||
|
See `README.md` for the full feature/gameplay spec and `docs/DEV_NOTES.md` for the developer reference (add-a-command / add-a-shop-item checklists, constants).
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run tests (pure logic + economy flows against an in-memory PocketBase fake)
|
||||||
|
python -m pytest tests/ -q
|
||||||
|
|
||||||
|
# Single file / single test
|
||||||
|
python -m pytest tests/test_economy_pure.py -q
|
||||||
|
python -m pytest tests/test_economy_flows.py::TestDaily::test_streak -q
|
||||||
|
|
||||||
|
# Run the bot (requires .env; PocketBase must already be running)
|
||||||
|
BOT_PROFILE=dev python bot.py # or BOT_PROFILE=economy
|
||||||
|
```
|
||||||
|
|
||||||
|
Deps: `pip install -r requirements-dev.txt` (dev = runtime + pytest). CI (`.gitea/workflows/deploy.yml`) runs `pytest tests/ -q` on push to `master`, then deploys to the host by restarting `tipibot_dev` (canary) and `tipibot_eco` systemd units. The bot runs on host `tipilan-bots:/root/tipibot`; the local checkout has no `.env`/live DB.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Three layers, deliberately decoupled:
|
||||||
|
|
||||||
|
- **`core/`** — domain logic, **no Discord objects**. `core/economy/` is the economy package.
|
||||||
|
- **`commands/`** — one Discord slash-command group per file. Each exposes `register_<group>_commands(tree, bot, ...)`.
|
||||||
|
- **`bot.py`** — thin wiring layer: Discord client, event handlers (`on_ready`, `on_member_join`), background tasks, shared helpers, and it calls every `register_*_commands(...)` on startup, passing shared helpers down (`coin`, `cd_ts`, `award_exp`, `maybe_remind`, `parse_amount`).
|
||||||
|
|
||||||
|
### Dual-profile design (dev vs economy)
|
||||||
|
|
||||||
|
`BOT_PROFILE` env var (`dev` | `economy`) selects **everything** at import time in `config.py`: which Discord token, guild ID, birthday channel, and **PocketBase collection** (`economy_users_dev` vs `economy_users_prod`) are used. Legacy non-suffixed env vars act as fallbacks. Logs and `data/` are also namespaced per profile (`logs/<profile>/`, `data/<profile>/`). `/check`, `/member`, `/birthdays` are **dev-profile only**. When adding config, follow the `_DEV` / `_ECONOMY` + legacy-fallback pattern.
|
||||||
|
|
||||||
|
### The re-export pattern (important — two places)
|
||||||
|
|
||||||
|
Both `strings/` and `core/economy/` are packages split into submodules but **re-exported flat** through their `__init__.py`, so callers use `import strings as S; S.NAME` and `from core import economy; economy.do_daily(...)` unchanged.
|
||||||
|
|
||||||
|
- **Edit the submodule**, not the `__init__`. Strings live in `strings/{common,commands,member,economy,admin,games,fishing}.py`; economy logic in `core/economy/{store,income,gambling,fishing,jail,heist,prestige,shop,levels,quests,leaderboards,house,admin}.py`.
|
||||||
|
- `tests/test_strings.py` guards that every submodule name is re-exported — a new string that isn't re-exported fails CI.
|
||||||
|
- Caveat (see `tests/conftest.py:93`): the re-exported `economy.house` mutable state is a snapshot; live house state is owned by `economy.house`. Patch the submodule, not the alias.
|
||||||
|
|
||||||
|
### Economy persistence & concurrency (`core/economy/store.py`)
|
||||||
|
|
||||||
|
- Every mutation is a **read-modify-write**: `get_user(id)` → mutate the `UserData` dict → `await _commit(id, user)`. Money/EXP changes must also call `_txn(...)` for the transaction log.
|
||||||
|
- **Per-user async locks** serialize commits. Public mutating functions are decorated with `@_locked_by(<arg positions of user ids>)`. Two hard rules to avoid deadlock/lost-writes:
|
||||||
|
1. A `@_locked_by` function must **never call another `@_locked_by` function**.
|
||||||
|
2. House balance changes go through `_credit_house` (an atomic PocketBase increment, no lock) so they're safe to call while holding user locks.
|
||||||
|
Multi-user functions (`do_give`, `do_rob`) list both positions and acquire locks in sorted order.
|
||||||
|
- **PocketBase silently drops writes to fields not in the collection schema** — this is the #1 footgun. Adding any new persisted field means adding it to `_default_user()` **and** running `python scripts/sync_pb_schema.py` (reconciles both dev + prod collections). `store.missing_schema_fields()` / `/status` surface drift.
|
||||||
|
|
||||||
|
### Command result convention
|
||||||
|
|
||||||
|
`core.economy` functions return a dict with a `"reason"` key describing the outcome (success, `"cooldown"`, `"jailed"`, `"banned"`, insufficient funds, ...). The command handler in `commands/` maps every possible `res["reason"]` to a string/embed. On success it calls `award_exp(interaction, economy.EXP_REWARDS["<cmd>"])` and `maybe_remind(...)` where relevant.
|
||||||
|
|
||||||
|
## Adding an economy command or shop item
|
||||||
|
|
||||||
|
Follow the exact ordered checklists in `docs/DEV_NOTES.md` ("Adding a New Economy Command", "Adding a New Shop Item"). They enumerate every touchpoint across `core/economy/`, `strings/`, `commands/`, and `bot.py` (cooldowns, EXP rewards, help embed, reminders, item-modified cooldown branches). Missing a step generally means a silently broken feature rather than an error.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
No `pytest-asyncio`. Async tests wrap coroutines with the `run(coro)` helper from `tests/conftest.py` (which is `asyncio.run`). The `fake_pb` fixture monkeypatches `core.pb_client` with an in-memory `FakePocketBase` that mimics real PocketBase behaviour — including atomic `field+`/`field-` increments and **silently dropping writes to fields missing from the schema** (use `fake_pb_without_quest_fields` to simulate pre-migration schema drift). Prefer testing `core/` logic directly; there's no Discord in the test path.
|
||||||
28
README.md
28
README.md
@@ -222,7 +222,7 @@ If a member joins and their birthday is within `BIRTHDAY_WINDOW_DAYS` days, a bi
|
|||||||
|
|
||||||
## TipiCOIN Economy
|
## 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,7 +245,7 @@ The house is listed at **#0** on the leaderboard. Players can attempt to rob it
|
|||||||
|
|
||||||
| Command | Cooldown | Base payout | Notes |
|
| Command | Cooldown | Base payout | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `/daily` | 20h | 150 ⬡ | Streak multiplier applied (see below). Kõrvaklapid reduces cooldown to 18h. LAN pilet doubles the reward. Bot Farm adds 5% interest on your balance (capped at 500 ⬡/day). Prestige daily_plus adds +20% base per upgrade level. |
|
| `/daily` | 20h | 150 ⬡ | Streak multiplier applied (see below). Kõrvaklapid reduces cooldown to 18h and adds +25 ⬡. LAN pilet doubles the reward. Bot Farm adds 5% interest on your balance (capped at 500 ⬡/day). Prestige daily_plus adds +20% base per upgrade level. |
|
||||||
| `/work` | 1h | 15–75 ⬡ | Random job flavour text. Mängurihiir +50%, Reguleeritav laud +25% (stacks). Red Bull: 30% chance of ×3. Ultralai monitor reduces cooldown to 40min. Prestige work_plus adds +20% per upgrade level. |
|
| `/work` | 1h | 15–75 ⬡ | Random job flavour text. Mängurihiir +50%, Reguleeritav laud +25% (stacks). Red Bull: 30% chance of ×3. Ultralai monitor reduces cooldown to 40min. Prestige work_plus adds +20% per upgrade level. |
|
||||||
| `/beg` | 5min | 10–40 ⬡ | Hiirematt reduces cooldown to 3min. Mehaaniline klaviatuur multiplies earnings ×2. |
|
| `/beg` | 5min | 10–40 ⬡ | Hiirematt reduces cooldown to 3min. Mehaaniline klaviatuur multiplies earnings ×2. |
|
||||||
| `/crime` | 2h | 200–500 ⬡ | 60% success rate (75% with Cat6 kaabel). +30% earnings with Mikrofon on win. Fail = fine + 30min jail. Gaming tool skips jail on fail. |
|
| `/crime` | 2h | 200–500 ⬡ | 60% success rate (75% with Cat6 kaabel). +30% earnings with Mikrofon on win. Fail = fine + 30min jail. Gaming tool skips jail on fail. |
|
||||||
@@ -336,11 +336,19 @@ The **ECONOMY** role is granted on your first EXP award (i.e. first successful e
|
|||||||
| `/rank [@user]` | EXP total, current level, progress bar to next level, leaderboard rank. |
|
| `/rank [@user]` | EXP total, current level, progress bar to next level, leaderboard rank. |
|
||||||
| `/stats [@user]` | Lifetime statistics: economy totals, work/beg counts, gambling records, crime/heist history, social totals, best streak. |
|
| `/stats [@user]` | Lifetime statistics: economy totals, work/beg counts, gambling records, crime/heist history, social totals, best streak. |
|
||||||
| `/cooldowns` | All cooldowns at a glance with live Discord timestamps. Shows jail timer if jailed. |
|
| `/cooldowns` | All cooldowns at a glance with live Discord timestamps. Shows jail timer if jailed. |
|
||||||
| `/leaderboard` | Paginated leaderboard with 6 tabs: 🪙 Coins, 📊 EXP, 🏆 Season EXP, 🔥 Prestige, 🎲 Wagered, 🎣 Fish caught. House pinned at #0 on coins tab. |
|
| `/leaderboard` | Paginated leaderboard with 6 tabs: 🪙 Coins (net worth = wallet + bank), 📊 EXP, 🏆 Season EXP, 🔥 Prestige, 🎲 Wagered, 🎣 Fish caught. House pinned at #0 on coins tab. |
|
||||||
| `/shop` | Browse all items by tier. Shows owned status, Anticheat charges remaining, and level lock for T2/T3. |
|
| `/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). |
|
| `/buy <item>` | Purchase an item by name (partial match accepted). |
|
||||||
|
| `/bank` | View your vault. Banked coins are **rob-proof** (`/rob` and `/heist` only touch liquid balance) but earn no Bot Farm interest and can't be spent until withdrawn. |
|
||||||
|
| `/deposit <amount>` | Move coins from your wallet into the bank. `all` deposits everything liquid. |
|
||||||
|
| `/withdraw <amount>` | Move coins from the bank back to your wallet. `all` withdraws everything banked. |
|
||||||
|
| `/consumables` | Buy repeatable, expiring boosts (earn ×2, EXP ×2, or an instant cooldown wipe). A recurring coin sink. |
|
||||||
|
| `/lootbox` | Open a mystery box for **1 000 ⬡**. Weighted random reward: coin tiers (usually a small net loss), a 30-min earn/EXP ×2 buff, or a rare jackpot. |
|
||||||
|
| `/vanity` | Status shop — buy and equip cosmetic badges/titles (shown on `/profile`). Coins are **burned**, not recirculated. No gameplay effect. |
|
||||||
| `/reminders` | Toggle per-command DM notifications. Bot DMs you the moment each cooldown expires. |
|
| `/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. |
|
| `/quests` | Personal daily (3) and weekly (2) quests with progress bars and a claim button. |
|
||||||
|
| `/achievements` | Milestone badges over your lifetime stats (work/wealth/gambling/crime/fishing/streaks/prestige). Each unlocks once and pays a one-time coin reward; opening the command claims any newly earned. |
|
||||||
|
| `/lottery [amount]` | View the pot or buy tickets (200 ⬡ each, max 100/draw). One winner is drawn daily at 21:00 Tallinn time and takes the whole pot — more tickets = higher weighted chance. Coins are conserved (the pot equals total ticket spend). Announced in `LOTTERY_CHANNEL_ID` if set. |
|
||||||
| `/fish` | Interactive fishing minigame. Cast, wait for bite, pull, then keep or sell. 2min cooldown (90s with Ussipurk). |
|
| `/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. |
|
| `/fishbook` | View your fish collection - all caught species, rarity, count, and current inventory amounts. |
|
||||||
| `/fishsell` | Sell all fish currently in your inventory at once. |
|
| `/fishsell` | Sell all fish currently in your inventory at once. |
|
||||||
@@ -424,7 +432,7 @@ All items are **permanent** once purchased **except Anticheat**, which expires a
|
|||||||
| Hiirematt | 600 ⬡ | `/beg` cooldown 5min → 3min |
|
| Hiirematt | 600 ⬡ | `/beg` cooldown 5min → 3min |
|
||||||
| Red Bull | 800 ⬡ | `/work` has 30% chance to earn ×3 |
|
| 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. |
|
| Anticheat | 1 000 ⬡ | Rob attempts against you fail and fine the robber. **2 uses**, then repurchase. |
|
||||||
| Kõrvaklapid | 1 200 ⬡ | `/daily` cooldown 20h → 18h |
|
| Kõrvaklapid | 1 200 ⬡ | `/daily` cooldown 20h → 18h and +25 ⬡ bonus |
|
||||||
| LAN pilet | 1 200 ⬡ | `/daily` reward ×2 |
|
| LAN pilet | 1 200 ⬡ | `/daily` reward ×2 |
|
||||||
| Bot Farm | 1 500 ⬡ | `/daily` adds 5% interest on balance (capped at 500 ⬡/day) |
|
| Bot Farm | 1 500 ⬡ | `/daily` adds 5% interest on balance (capped at 500 ⬡/day) |
|
||||||
|
|
||||||
@@ -457,7 +465,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.
|
Commands that accept a coin amount (`/give`, `/roulette`, `/rps`, `/slots`, `/blackjack`) accept `"all"` as the amount to wager your entire balance.
|
||||||
|
|
||||||
### Custom emoji
|
### 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
|
```python
|
||||||
COIN = "<:tipicoin:YOUR_EMOJI_ID>"
|
COIN = "<:tipicoin:YOUR_EMOJI_ID>"
|
||||||
```
|
```
|
||||||
@@ -483,7 +491,15 @@ 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
|
├── 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
|
├── config.py # Environment variable loader
|
||||||
├── core/
|
├── core/
|
||||||
│ ├── economy/ # TipiCOIN business logic (re-exported via core/economy/__init__.py)
|
│ ├── economy/ # TipiCOIN business logic (re-exported via core/economy/__init__.py)
|
||||||
|
|||||||
116
bot.py
116
bot.py
@@ -336,6 +336,72 @@ async def before_birthday_daily():
|
|||||||
await bot.wait_until_ready()
|
await bot.wait_until_ready()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Daily lottery draw (Tallinn-time DRAW_HOUR:00)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
async def _resolve_channel(channel_id: int):
|
||||||
|
"""Best-effort fetch of a text channel by id (cache, then API)."""
|
||||||
|
if not channel_id:
|
||||||
|
return None
|
||||||
|
channel = bot.get_channel(channel_id)
|
||||||
|
if channel is None:
|
||||||
|
try:
|
||||||
|
channel = await bot.fetch_channel(channel_id)
|
||||||
|
except (discord.NotFound, discord.Forbidden, discord.HTTPException):
|
||||||
|
return None
|
||||||
|
return channel
|
||||||
|
|
||||||
|
|
||||||
|
@tasks.loop(time=datetime.time(hour=economy.lottery.DRAW_HOUR, minute=0, tzinfo=TALLINN_TZ))
|
||||||
|
async def lottery_draw_daily():
|
||||||
|
"""Draw the day's lottery winner and announce it (if a channel is set)."""
|
||||||
|
period = datetime.datetime.now(TALLINN_TZ).date().isoformat()
|
||||||
|
try:
|
||||||
|
result = await economy.do_lottery_draw(period)
|
||||||
|
except Exception:
|
||||||
|
log.exception("Lottery draw failed for %s", period)
|
||||||
|
return
|
||||||
|
channel = await _resolve_channel(config.LOTTERY_CHANNEL_ID)
|
||||||
|
if result is None:
|
||||||
|
log.info("Lottery draw %s: no participants", period)
|
||||||
|
if channel:
|
||||||
|
try:
|
||||||
|
await channel.send(S.LOTTERY_UI["draw_none"])
|
||||||
|
except discord.HTTPException:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
if not result.get("ok"):
|
||||||
|
log.error("Lottery draw %s could not pay winner %s (pot %s)",
|
||||||
|
period, result.get("winner_id"), result.get("pot"))
|
||||||
|
return
|
||||||
|
log.info("Lottery draw %s: winner %s won %s (%s/%s tickets)",
|
||||||
|
period, result["winner_id"], result["pot"],
|
||||||
|
result["winner_tickets"], result["total_tickets"])
|
||||||
|
if channel:
|
||||||
|
mention = f"<@{result['winner_id']}>"
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=S.LOTTERY_UI["draw_title"],
|
||||||
|
description=S.LOTTERY_UI["draw_win"].format(
|
||||||
|
winner=mention,
|
||||||
|
pot=_coin(result["pot"]),
|
||||||
|
tickets=result["winner_tickets"],
|
||||||
|
total=result["total_tickets"],
|
||||||
|
chance=round(result["win_chance"] * 100, 1),
|
||||||
|
players=result["participants"],
|
||||||
|
),
|
||||||
|
color=0xF4C430,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await channel.send(content=mention, embed=embed)
|
||||||
|
except discord.HTTPException:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@lottery_draw_daily.before_loop
|
||||||
|
async def before_lottery_draw_daily():
|
||||||
|
await bot.wait_until_ready()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Rotating rich presence
|
# Rotating rich presence
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -433,6 +499,11 @@ async def on_ready():
|
|||||||
birthday_daily.start()
|
birthday_daily.start()
|
||||||
log.info("Birthday daily task started (fires 09:00 Tallinn time)")
|
log.info("Birthday daily task started (fires 09:00 Tallinn time)")
|
||||||
|
|
||||||
|
# Start daily lottery draw (runs in every profile; each has its own collection)
|
||||||
|
if not lottery_draw_daily.is_running():
|
||||||
|
lottery_draw_daily.start()
|
||||||
|
log.info("Lottery draw task started (fires %02d:00 Tallinn time)", economy.lottery.DRAW_HOUR)
|
||||||
|
|
||||||
# Start rotating rich presence
|
# Start rotating rich presence
|
||||||
if not _rotate_presence.is_running():
|
if not _rotate_presence.is_running():
|
||||||
_rotate_presence.start()
|
_rotate_presence.start()
|
||||||
@@ -441,6 +512,16 @@ async def on_ready():
|
|||||||
# Re-schedule any reminder tasks lost on restart
|
# Re-schedule any reminder tasks lost on restart
|
||||||
await _restore_reminders()
|
await _restore_reminders()
|
||||||
|
|
||||||
|
# Refund stakes escrowed by interactive games (blackjack/RPS PvP) that a
|
||||||
|
# restart interrupted, so a mid-hand crash never eats a player's coins.
|
||||||
|
try:
|
||||||
|
refunded = await economy.reconcile_pending_wagers()
|
||||||
|
if refunded:
|
||||||
|
total = sum(amt for _, amt, _ in refunded)
|
||||||
|
log.info("Reconciled %d interrupted wager(s), refunded %d coins", len(refunded), total)
|
||||||
|
except Exception:
|
||||||
|
log.exception("Pending-wager reconciliation failed")
|
||||||
|
|
||||||
# Notify the channel where /restart was triggered
|
# Notify the channel where /restart was triggered
|
||||||
if _RESTART_FILE.exists():
|
if _RESTART_FILE.exists():
|
||||||
try:
|
try:
|
||||||
@@ -688,8 +769,10 @@ register_prestige_commands(
|
|||||||
|
|
||||||
def _parse_amount(value: str, balance: int) -> tuple[int | None, str | None]:
|
def _parse_amount(value: str, balance: int) -> tuple[int | None, str | None]:
|
||||||
"""Parse an amount string; 'all' resolves to the user's full balance.
|
"""Parse an amount string; 'all' resolves to the user's full balance.
|
||||||
Accepts plain integers and valid thousand-separated numbers (1,000 / 1.000 / 1 000).
|
Accepts plain non-negative integers and valid thousand-separated numbers
|
||||||
Rejects decimals and ambiguous inputs like 1,1 or 1.5.
|
(1,000 / 1.000 / 1 000). Rejects decimals, ambiguous inputs like 1,1 or 1.5,
|
||||||
|
and negative amounts (a negative bet/give would mint coins on loss/transfer
|
||||||
|
paths that trust the caller's sign).
|
||||||
Returns (amount, None) on success or (None, error_msg) on failure."""
|
Returns (amount, None) on success or (None, error_msg) on failure."""
|
||||||
v = value.strip()
|
v = value.strip()
|
||||||
if v.lower() == "all":
|
if v.lower() == "all":
|
||||||
@@ -698,9 +781,12 @@ def _parse_amount(value: str, balance: int) -> tuple[int | None, str | None]:
|
|||||||
if re.fullmatch(r'\d{1,3}([,. ]\d{3})*', v):
|
if re.fullmatch(r'\d{1,3}([,. ]\d{3})*', v):
|
||||||
v = re.sub(r'[,. ]', '', v)
|
v = re.sub(r'[,. ]', '', v)
|
||||||
try:
|
try:
|
||||||
return int(v), None
|
amount = int(v)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None, S.ERR["invalid_amount"]
|
return None, S.ERR["invalid_amount"]
|
||||||
|
if amount < 0:
|
||||||
|
return None, S.ERR["invalid_amount"]
|
||||||
|
return amount, None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -767,17 +853,7 @@ async def _restore_reminders() -> None:
|
|||||||
last_str = user.get(last_key)
|
last_str = user.get(last_key)
|
||||||
if not last_str:
|
if not last_str:
|
||||||
continue
|
continue
|
||||||
items = user.get("items", [])
|
cooldown = economy.effective_cooldown(cmd, user.get("items", []))
|
||||||
if cmd == "work" and "monitor" in items:
|
|
||||||
cooldown = datetime.timedelta(minutes=40)
|
|
||||||
elif cmd == "beg" and "hiirematt" in items:
|
|
||||||
cooldown = datetime.timedelta(minutes=3)
|
|
||||||
elif cmd == "daily" and "korvaklapid" in items:
|
|
||||||
cooldown = datetime.timedelta(hours=18)
|
|
||||||
elif cmd == "fish" and "ussipurk" in items:
|
|
||||||
cooldown = datetime.timedelta(seconds=90)
|
|
||||||
else:
|
|
||||||
cooldown = economy.COOLDOWNS.get(cmd)
|
|
||||||
if not cooldown:
|
if not cooldown:
|
||||||
continue
|
continue
|
||||||
last_dt = datetime.datetime.fromisoformat(last_str)
|
last_dt = datetime.datetime.fromisoformat(last_str)
|
||||||
@@ -796,17 +872,7 @@ async def _maybe_remind(user_id: int, cmd: str) -> None:
|
|||||||
user_data = await economy.get_user(user_id)
|
user_data = await economy.get_user(user_id)
|
||||||
if cmd not in user_data.get("reminders", []):
|
if cmd not in user_data.get("reminders", []):
|
||||||
return
|
return
|
||||||
items = set(user_data.get("items", []))
|
delay = economy.effective_cooldown(cmd, user_data.get("items", [])) or datetime.timedelta(hours=1)
|
||||||
if cmd == "work" and "monitor" in items:
|
|
||||||
delay = datetime.timedelta(minutes=40)
|
|
||||||
elif cmd == "beg" and "hiirematt" in items:
|
|
||||||
delay = datetime.timedelta(minutes=3)
|
|
||||||
elif cmd == "daily" and "korvaklapid" in items:
|
|
||||||
delay = datetime.timedelta(hours=18)
|
|
||||||
elif cmd == "fish" and "ussipurk" in items:
|
|
||||||
delay = datetime.timedelta(seconds=90)
|
|
||||||
else:
|
|
||||||
delay = economy.COOLDOWNS.get(cmd, datetime.timedelta(hours=1))
|
|
||||||
_schedule_reminder(user_id, cmd, delay)
|
_schedule_reminder(user_id, cmd, delay)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
25
commands/_replies.py
Normal file
25
commands/_replies.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""Shared reply helpers for command handlers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import discord
|
||||||
|
|
||||||
|
import strings as S
|
||||||
|
|
||||||
|
|
||||||
|
async def reply_db_error(interaction: discord.Interaction) -> None:
|
||||||
|
"""Tell the user the database is unavailable.
|
||||||
|
|
||||||
|
Economy core functions return {"ok": False, "reason": "db_error"} on a
|
||||||
|
PocketBase outage. Without this, handlers either fall through silently (a
|
||||||
|
deferred interaction hangs on "thinking...") or show a misleading "you're
|
||||||
|
broke" message. Works whether or not the interaction was already deferred.
|
||||||
|
"""
|
||||||
|
msg = S.ERR["db_error"]
|
||||||
|
try:
|
||||||
|
if interaction.response.is_done():
|
||||||
|
await interaction.followup.send(msg, ephemeral=True)
|
||||||
|
else:
|
||||||
|
await interaction.response.send_message(msg, ephemeral=True)
|
||||||
|
except discord.HTTPException:
|
||||||
|
pass
|
||||||
@@ -211,17 +211,29 @@ def register_economy_admin_commands(
|
|||||||
prestige_pp = data.get("prestige_points", 0)
|
prestige_pp = data.get("prestige_points", 0)
|
||||||
total_fish = data.get("total_fish_caught", 0)
|
total_fish = data.get("total_fish_caught", 0)
|
||||||
inv_fish = len(data.get("fish_inventory") or [])
|
inv_fish = len(data.get("fish_inventory") or [])
|
||||||
|
pw = data.get("pending_wager") or {}
|
||||||
|
wager_str = f"{pw.get('amount', 0):,} ({pw.get('kind')})" if pw.get("amount") else "-"
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
title=S.ADMINVIEW_UI["title"].format(name=kasutaja.display_name),
|
title=S.ADMINVIEW_UI["title"].format(name=kasutaja.display_name),
|
||||||
color=0x5865F2,
|
color=0x5865F2,
|
||||||
)
|
)
|
||||||
embed.add_field(name=S.ADMINVIEW_UI["f_balance"], value=f"{data.get('balance', 0):,} {economy.COIN}", inline=True)
|
embed.add_field(name=S.ADMINVIEW_UI["f_balance"], value=f"{data.get('balance', 0):,} {economy.COIN}", inline=True)
|
||||||
|
embed.add_field(name=S.ADMINVIEW_UI["f_bank"], value=f"{data.get('bank_balance', 0):,} {economy.COIN}", inline=True)
|
||||||
embed.add_field(name=S.ADMINVIEW_UI["f_exp"], value=S.ADMINVIEW_UI["exp_val"].format(exp=f"{exp:,}", level=level), inline=True)
|
embed.add_field(name=S.ADMINVIEW_UI["f_exp"], value=S.ADMINVIEW_UI["exp_val"].format(exp=f"{exp:,}", level=level), inline=True)
|
||||||
embed.add_field(name=S.ADMINVIEW_UI["f_streak"], value=str(data.get("daily_streak", 0)), inline=True)
|
embed.add_field(name=S.ADMINVIEW_UI["f_streak"], value=str(data.get("daily_streak", 0)), inline=True)
|
||||||
embed.add_field(name=S.ADMINVIEW_UI["f_banned"], value=banned, inline=True)
|
embed.add_field(name=S.ADMINVIEW_UI["f_banned"], value=banned, inline=True)
|
||||||
embed.add_field(name=S.ADMINVIEW_UI["f_jailed"], value=jailed, inline=True)
|
embed.add_field(name=S.ADMINVIEW_UI["f_jailed"], value=jailed, inline=True)
|
||||||
embed.add_field(name=S.ADMINVIEW_UI["f_prestige"], value=S.ADMINVIEW_UI["prestige_val"].format(level=prestige_lvl, pp=prestige_pp), inline=True)
|
embed.add_field(name=S.ADMINVIEW_UI["f_prestige"], value=S.ADMINVIEW_UI["prestige_val"].format(level=prestige_lvl, pp=prestige_pp), inline=True)
|
||||||
embed.add_field(name=S.ADMINVIEW_UI["f_fish"], value=S.ADMINVIEW_UI["fish_val"].format(caught=total_fish, inv=inv_fish), inline=True)
|
embed.add_field(name=S.ADMINVIEW_UI["f_fish"], value=S.ADMINVIEW_UI["fish_val"].format(caught=total_fish, inv=inv_fish), inline=True)
|
||||||
|
embed.add_field(
|
||||||
|
name=S.ADMINVIEW_UI["f_extras"],
|
||||||
|
value=S.ADMINVIEW_UI["extras_val"].format(
|
||||||
|
ach=len(data.get("achievements_earned") or []),
|
||||||
|
lootboxes=data.get("lootboxes_opened", 0),
|
||||||
|
wager=wager_str,
|
||||||
|
),
|
||||||
|
inline=True,
|
||||||
|
)
|
||||||
embed.add_field(name=S.ADMINVIEW_UI["f_items"], value=items_str, inline=False)
|
embed.add_field(name=S.ADMINVIEW_UI["f_items"], value=items_str, inline=False)
|
||||||
embed.add_field(name=S.ADMINVIEW_UI["f_uses"], value=uses_str, inline=False)
|
embed.add_field(name=S.ADMINVIEW_UI["f_uses"], value=uses_str, inline=False)
|
||||||
embed.add_field(name=S.ADMINVIEW_UI["f_last_daily"], value=data.get("last_daily") or "-", inline=True)
|
embed.add_field(name=S.ADMINVIEW_UI["f_last_daily"], value=data.get("last_daily") or "-", inline=True)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import datetime
|
|||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
from collections.abc import Awaitable, Callable, MutableSet
|
from collections.abc import Awaitable, Callable, MutableSet
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
from discord import app_commands
|
from discord import app_commands
|
||||||
@@ -13,6 +14,10 @@ from core import economy
|
|||||||
from core.emoji import EMOJI as E
|
from core.emoji import EMOJI as E
|
||||||
import strings as S
|
import strings as S
|
||||||
|
|
||||||
|
from ._replies import reply_db_error
|
||||||
|
|
||||||
|
_TALLINN = ZoneInfo("Europe/Tallinn")
|
||||||
|
|
||||||
|
|
||||||
def register_economy_extra_commands(
|
def register_economy_extra_commands(
|
||||||
tree: app_commands.CommandTree,
|
tree: app_commands.CommandTree,
|
||||||
@@ -120,6 +125,9 @@ def register_economy_extra_commands(
|
|||||||
return
|
return
|
||||||
res = await economy.do_heist_check(interaction.user.id)
|
res = await economy.do_heist_check(interaction.user.id)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
if res["reason"] == "banned":
|
if res["reason"] == "banned":
|
||||||
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||||
elif res["reason"] == "jailed":
|
elif res["reason"] == "jailed":
|
||||||
@@ -270,6 +278,9 @@ def register_economy_extra_commands(
|
|||||||
return
|
return
|
||||||
res = await economy.do_heist_check(interaction.user.id)
|
res = await economy.do_heist_check(interaction.user.id)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
if res["reason"] == "banned":
|
if res["reason"] == "banned":
|
||||||
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||||
elif res["reason"] == "jailed":
|
elif res["reason"] == "jailed":
|
||||||
@@ -396,12 +407,17 @@ def register_economy_extra_commands(
|
|||||||
def __init__(self, user_id: int):
|
def __init__(self, user_id: int):
|
||||||
super().__init__(timeout=60)
|
super().__init__(timeout=60)
|
||||||
self.user_id = user_id
|
self.user_id = user_id
|
||||||
|
self._paying = False
|
||||||
|
|
||||||
@discord.ui.button(label=S.JAILBREAK_UI["bail_btn"], style=discord.ButtonStyle.danger)
|
@discord.ui.button(label=S.JAILBREAK_UI["bail_btn"], style=discord.ButtonStyle.danger)
|
||||||
async def pay_bail(self, interaction: discord.Interaction, _: discord.ui.Button):
|
async def pay_bail(self, interaction: discord.Interaction, _: discord.ui.Button):
|
||||||
if interaction.user.id != self.user_id:
|
if interaction.user.id != self.user_id:
|
||||||
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
|
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
|
||||||
return
|
return
|
||||||
|
if self._paying or self.is_finished():
|
||||||
|
await interaction.response.defer()
|
||||||
|
return
|
||||||
|
self._paying = True
|
||||||
res = await economy.do_bail(self.user_id)
|
res = await economy.do_bail(self.user_id)
|
||||||
self.clear_items()
|
self.clear_items()
|
||||||
self.stop()
|
self.stop()
|
||||||
@@ -414,6 +430,15 @@ def register_economy_extra_commands(
|
|||||||
),
|
),
|
||||||
color=0xED4245,
|
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:
|
else:
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
title=S.TITLE["jailbreak_bail"],
|
title=S.TITLE["jailbreak_bail"],
|
||||||
@@ -504,6 +529,9 @@ def register_economy_extra_commands(
|
|||||||
|
|
||||||
res = await economy.do_give(interaction.user.id, kasutaja.id, summa_int)
|
res = await economy.do_give(interaction.user.id, kasutaja.id, summa_int)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
if res["reason"] == "banned":
|
if res["reason"] == "banned":
|
||||||
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||||
elif res["reason"] == "jailed":
|
elif res["reason"] == "jailed":
|
||||||
@@ -529,6 +557,64 @@ def register_economy_extra_commands(
|
|||||||
)
|
)
|
||||||
await interaction.response.send_message(embed=embed)
|
await interaction.response.send_message(embed=embed)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# /lottery - daily draw, one weighted winner takes the pot
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
@tree.command(name="lottery", description=S.CMD["lottery"])
|
||||||
|
@app_commands.describe(kogus=S.OPT["lottery_kogus"])
|
||||||
|
async def cmd_lottery(interaction: discord.Interaction, kogus: int | None = None):
|
||||||
|
period = economy.period_for(datetime.datetime.now(_TALLINN))
|
||||||
|
|
||||||
|
if kogus is None:
|
||||||
|
state = await economy.get_lottery_state(period, interaction.user.id)
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=S.LOTTERY_UI["title"],
|
||||||
|
description=S.LOTTERY_UI["desc"].format(
|
||||||
|
draw_time=f"{economy.DRAW_HOUR}:00", cost=coin(state["ticket_cost"])
|
||||||
|
),
|
||||||
|
color=0xF4C430,
|
||||||
|
)
|
||||||
|
embed.add_field(name=S.LOTTERY_UI["f_pot"], value=coin(state["pot"]), inline=True)
|
||||||
|
embed.add_field(name=S.LOTTERY_UI["f_players"], value=str(state["participants"]), inline=True)
|
||||||
|
if state["your_tickets"]:
|
||||||
|
chance = round(state["your_tickets"] / state["total_tickets"] * 100, 1)
|
||||||
|
embed.add_field(
|
||||||
|
name=S.LOTTERY_UI["f_your"],
|
||||||
|
value=S.LOTTERY_UI["your_val"].format(tickets=state["your_tickets"], chance=chance),
|
||||||
|
inline=False,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
embed.add_field(name=S.LOTTERY_UI["f_your"], value=S.LOTTERY_UI["your_none"], inline=False)
|
||||||
|
await interaction.response.send_message(embed=embed)
|
||||||
|
return
|
||||||
|
|
||||||
|
if kogus <= 0:
|
||||||
|
await interaction.response.send_message(S.ERR["positive_amount"], ephemeral=True)
|
||||||
|
return
|
||||||
|
res = await economy.do_buy_ticket(interaction.user.id, kogus, period)
|
||||||
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
elif res["reason"] == "banned":
|
||||||
|
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||||
|
elif res["reason"] == "max_tickets":
|
||||||
|
await interaction.response.send_message(
|
||||||
|
S.LOTTERY_UI["max_tickets"].format(cap=res["cap"], held=res["held"]), ephemeral=True
|
||||||
|
)
|
||||||
|
elif res["reason"] == "insufficient":
|
||||||
|
await interaction.response.send_message(
|
||||||
|
S.ERR["broke_need"].format(need=coin(res["need"])), ephemeral=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await interaction.response.send_message(S.ERR["invalid_amount"], ephemeral=True)
|
||||||
|
return
|
||||||
|
await interaction.response.send_message(
|
||||||
|
S.LOTTERY_UI["bought"].format(
|
||||||
|
count=res["bought"], cost=coin(res["cost"]),
|
||||||
|
tickets=res["tickets"], balance=coin(res["balance"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
class LeaderboardView(discord.ui.View):
|
class LeaderboardView(discord.ui.View):
|
||||||
PER_PAGE = 10
|
PER_PAGE = 10
|
||||||
|
|
||||||
@@ -737,14 +823,13 @@ def register_economy_extra_commands(
|
|||||||
@tree.command(name="leaderboard", description=S.CMD["leaderboard"])
|
@tree.command(name="leaderboard", description=S.CMD["leaderboard"])
|
||||||
async def cmd_leaderboard(interaction: discord.Interaction):
|
async def cmd_leaderboard(interaction: discord.Interaction):
|
||||||
await interaction.response.defer()
|
await interaction.response.defer()
|
||||||
coins_raw, exp_raw, season_raw, prestige_raw, wagered_raw, fish_raw = await asyncio.gather(
|
lbs = await economy.get_all_leaderboards() # single collection scan for all six tabs
|
||||||
economy.get_leaderboard(top_n=None),
|
coins_raw = lbs["coins"]
|
||||||
economy.get_leaderboard_exp(top_n=None),
|
exp_raw = lbs["exp"]
|
||||||
economy.get_leaderboard_season_exp(top_n=None),
|
season_raw = lbs["season"]
|
||||||
economy.get_leaderboard_prestige(top_n=None),
|
prestige_raw = lbs["prestige"]
|
||||||
economy.get_leaderboard_wagered(top_n=None),
|
wagered_raw = lbs["wagered"]
|
||||||
economy.get_leaderboard_fish(top_n=None),
|
fish_raw = lbs["fish"]
|
||||||
)
|
|
||||||
|
|
||||||
house_entry = None
|
house_entry = None
|
||||||
regular = []
|
regular = []
|
||||||
@@ -851,6 +936,9 @@ def register_economy_extra_commands(
|
|||||||
async def cmd_buy(interaction: discord.Interaction, ese: app_commands.Choice[str]):
|
async def cmd_buy(interaction: discord.Interaction, ese: app_commands.Choice[str]):
|
||||||
res = await economy.do_buy(interaction.user.id, ese.value)
|
res = await economy.do_buy(interaction.user.id, ese.value)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
if res["reason"] == "banned":
|
if res["reason"] == "banned":
|
||||||
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||||
elif res["reason"] == "owned":
|
elif res["reason"] == "owned":
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ from discord import app_commands
|
|||||||
from core import economy
|
from core import economy
|
||||||
import strings as S
|
import strings as S
|
||||||
|
|
||||||
|
from ._replies import reply_db_error
|
||||||
|
|
||||||
|
|
||||||
def register_economy_fish_commands(
|
def register_economy_fish_commands(
|
||||||
tree: app_commands.CommandTree,
|
tree: app_commands.CommandTree,
|
||||||
@@ -218,6 +220,9 @@ def register_economy_fish_commands(
|
|||||||
|
|
||||||
res = await economy.do_fish_start(interaction.user.id)
|
res = await economy.do_fish_start(interaction.user.id)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
if res["reason"] == "banned":
|
if res["reason"] == "banned":
|
||||||
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||||
elif res["reason"] == "cooldown":
|
elif res["reason"] == "cooldown":
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ from core import economy
|
|||||||
from core.emoji import EMOJI as E
|
from core.emoji import EMOJI as E
|
||||||
import strings as S
|
import strings as S
|
||||||
|
|
||||||
|
from ._replies import reply_db_error
|
||||||
|
|
||||||
|
|
||||||
def register_economy_games_commands(
|
def register_economy_games_commands(
|
||||||
tree: app_commands.CommandTree,
|
tree: app_commands.CommandTree,
|
||||||
@@ -116,6 +118,9 @@ def register_economy_games_commands(
|
|||||||
res = await economy.do_roulette(interaction.user.id, panus_int, värv.value)
|
res = await economy.do_roulette(interaction.user.id, panus_int, värv.value)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
active_games.discard(interaction.user.id)
|
active_games.discard(interaction.user.id)
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
if res["reason"] == "banned":
|
if res["reason"] == "banned":
|
||||||
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||||
elif res["reason"] == "jailed":
|
elif res["reason"] == "jailed":
|
||||||
@@ -202,7 +207,14 @@ def register_economy_games_commands(
|
|||||||
bet_line = ""
|
bet_line = ""
|
||||||
if self.bet > 0:
|
if self.bet > 0:
|
||||||
res = await economy.do_game_bet(interaction.user.id, self.bet, outcome)
|
res = await economy.do_game_bet(interaction.user.id, self.bet, outcome)
|
||||||
if outcome == "win":
|
if not res.get("ok"):
|
||||||
|
if res.get("reason") == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
|
# Player was jailed/went broke since the duel started - show the
|
||||||
|
# result without a bet line rather than crashing on res["balance"].
|
||||||
|
bet_line = ""
|
||||||
|
elif outcome == "win":
|
||||||
bet_line = S.RPS_UI["bet_win"].format(amount=coin(self.bet), balance=coin(res["balance"]))
|
bet_line = S.RPS_UI["bet_win"].format(amount=coin(self.bet), balance=coin(res["balance"]))
|
||||||
elif outcome == "lose":
|
elif outcome == "lose":
|
||||||
bet_line = S.RPS_UI["bet_lose"].format(amount=coin(self.bet), balance=coin(res["balance"]))
|
bet_line = S.RPS_UI["bet_lose"].format(amount=coin(self.bet), balance=coin(res["balance"]))
|
||||||
@@ -278,10 +290,12 @@ def register_economy_games_commands(
|
|||||||
if self.bet > 0:
|
if self.bet > 0:
|
||||||
if winner == "a":
|
if winner == "a":
|
||||||
await economy.do_rps_pvp_payout(self.player_a.id, self.bet)
|
await economy.do_rps_pvp_payout(self.player_a.id, self.bet)
|
||||||
|
await economy.do_rps_pvp_forfeit(self.player_b.id)
|
||||||
bet_line_a = f"\n+{coin(self.bet)}"
|
bet_line_a = f"\n+{coin(self.bet)}"
|
||||||
bet_line_b = f"\n-{coin(self.bet)}"
|
bet_line_b = f"\n-{coin(self.bet)}"
|
||||||
elif winner == "b":
|
elif winner == "b":
|
||||||
await economy.do_rps_pvp_payout(self.player_b.id, self.bet)
|
await economy.do_rps_pvp_payout(self.player_b.id, self.bet)
|
||||||
|
await economy.do_rps_pvp_forfeit(self.player_a.id)
|
||||||
bet_line_a = f"\n-{coin(self.bet)}"
|
bet_line_a = f"\n-{coin(self.bet)}"
|
||||||
bet_line_b = f"\n+{coin(self.bet)}"
|
bet_line_b = f"\n+{coin(self.bet)}"
|
||||||
else:
|
else:
|
||||||
@@ -652,6 +666,9 @@ def register_economy_games_commands(
|
|||||||
res = await economy.do_slots(interaction.user.id, panus_int)
|
res = await economy.do_slots(interaction.user.id, panus_int)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
active_games.discard(interaction.user.id)
|
active_games.discard(interaction.user.id)
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
if res["reason"] == "banned":
|
if res["reason"] == "banned":
|
||||||
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||||
return
|
return
|
||||||
@@ -792,6 +809,11 @@ def register_economy_games_commands(
|
|||||||
self._doubled_hands: set[int] = set()
|
self._doubled_hands: set[int] = set()
|
||||||
self._split_aces: bool = False
|
self._split_aces: bool = False
|
||||||
self.message: discord.Message | None = None
|
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()
|
self._refresh_buttons()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -864,6 +886,9 @@ def register_economy_games_commands(
|
|||||||
return discord.Embed(title=S.TITLE["blackjack"], description=desc, color=0x5865F2)
|
return discord.Embed(title=S.TITLE["blackjack"], description=desc, color=0x5865F2)
|
||||||
|
|
||||||
async def _resolve_all(self, interaction: discord.Interaction) -> None:
|
async def _resolve_all(self, interaction: discord.Interaction) -> None:
|
||||||
|
if self._resolved:
|
||||||
|
return
|
||||||
|
self._resolved = True
|
||||||
active_games.discard(self.user_id)
|
active_games.discard(self.user_id)
|
||||||
self.clear_items()
|
self.clear_items()
|
||||||
self.stop()
|
self.stop()
|
||||||
@@ -918,13 +943,17 @@ def register_economy_games_commands(
|
|||||||
embed = self._cur_embed(game_over=True, hand_results=hand_results)
|
embed = self._cur_embed(game_over=True, hand_results=hand_results)
|
||||||
embed.title = S.TITLE[title_key]
|
embed.title = S.TITLE[title_key]
|
||||||
embed.color = color
|
embed.color = color
|
||||||
|
if res.get("ok"):
|
||||||
|
result_line = result_str + S.BJ_UI["balance_line"].format(balance=coin(res["balance"]))
|
||||||
|
else:
|
||||||
|
result_line = result_str + "\n" + S.ERR["payout_failed"]
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name=S.BJ["result_field"],
|
name=S.BJ["result_field"],
|
||||||
value=result_str + S.BJ_UI["balance_line"].format(balance=coin(res["balance"])),
|
value=result_line,
|
||||||
inline=False,
|
inline=False,
|
||||||
)
|
)
|
||||||
await self.message.edit(embed=embed, view=self)
|
await self.message.edit(embed=embed, view=self)
|
||||||
if total_payout > total_invested:
|
if res.get("ok") and total_payout > total_invested:
|
||||||
asyncio.create_task(award_exp(interaction, economy.gamble_exp(total_invested)))
|
asyncio.create_task(award_exp(interaction, economy.gamble_exp(total_invested)))
|
||||||
|
|
||||||
async def _do_dealer_reveal(self, interaction: discord.Interaction) -> None:
|
async def _do_dealer_reveal(self, interaction: discord.Interaction) -> None:
|
||||||
@@ -949,6 +978,11 @@ def register_economy_games_commands(
|
|||||||
if interaction.user.id != self.user_id:
|
if interaction.user.id != self.user_id:
|
||||||
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
|
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
|
||||||
return
|
return
|
||||||
|
if self._busy or self.is_finished():
|
||||||
|
await interaction.response.defer()
|
||||||
|
return
|
||||||
|
self._busy = True
|
||||||
|
try:
|
||||||
await interaction.response.defer()
|
await interaction.response.defer()
|
||||||
self._cur_hand.append(self.deck.pop())
|
self._cur_hand.append(self.deck.pop())
|
||||||
val = _bj_value(self._cur_hand)
|
val = _bj_value(self._cur_hand)
|
||||||
@@ -969,23 +1003,40 @@ def register_economy_games_commands(
|
|||||||
else:
|
else:
|
||||||
self._refresh_buttons()
|
self._refresh_buttons()
|
||||||
await self.message.edit(embed=self._cur_embed(), view=self)
|
await self.message.edit(embed=self._cur_embed(), view=self)
|
||||||
|
finally:
|
||||||
|
self._busy = False
|
||||||
|
|
||||||
async def _stand(self, interaction: discord.Interaction) -> None:
|
async def _stand(self, interaction: discord.Interaction) -> None:
|
||||||
if interaction.user.id != self.user_id:
|
if interaction.user.id != self.user_id:
|
||||||
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
|
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
|
||||||
return
|
return
|
||||||
|
if self._busy or self.is_finished():
|
||||||
|
await interaction.response.defer()
|
||||||
|
return
|
||||||
|
self._busy = True
|
||||||
|
try:
|
||||||
await interaction.response.defer()
|
await interaction.response.defer()
|
||||||
if len(self.hands) > 1:
|
if len(self.hands) > 1:
|
||||||
await self._advance_or_finish(interaction)
|
await self._advance_or_finish(interaction)
|
||||||
else:
|
else:
|
||||||
await self._do_dealer_reveal(interaction)
|
await self._do_dealer_reveal(interaction)
|
||||||
|
finally:
|
||||||
|
self._busy = False
|
||||||
|
|
||||||
async def _double(self, interaction: discord.Interaction) -> None:
|
async def _double(self, interaction: discord.Interaction) -> None:
|
||||||
if interaction.user.id != self.user_id:
|
if interaction.user.id != self.user_id:
|
||||||
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
|
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
|
||||||
return
|
return
|
||||||
|
if self._busy or self.is_finished():
|
||||||
|
await interaction.response.defer()
|
||||||
|
return
|
||||||
|
self._busy = True
|
||||||
|
try:
|
||||||
res = await economy.do_blackjack_bet(self.user_id, self.bet)
|
res = await economy.do_blackjack_bet(self.user_id, self.bet)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
await interaction.response.send_message(
|
await interaction.response.send_message(
|
||||||
S.ERR["broke"].format(bal=coin(res.get("balance", 0))), ephemeral=True
|
S.ERR["broke"].format(bal=coin(res.get("balance", 0))), ephemeral=True
|
||||||
)
|
)
|
||||||
@@ -997,13 +1048,23 @@ def register_economy_games_commands(
|
|||||||
await self.message.edit(embed=self._cur_embed(), view=None)
|
await self.message.edit(embed=self._cur_embed(), view=None)
|
||||||
await asyncio.sleep(_BJ_DEAL_DELAY)
|
await asyncio.sleep(_BJ_DEAL_DELAY)
|
||||||
await self._do_dealer_reveal(interaction)
|
await self._do_dealer_reveal(interaction)
|
||||||
|
finally:
|
||||||
|
self._busy = False
|
||||||
|
|
||||||
async def _split_hand(self, interaction: discord.Interaction) -> None:
|
async def _split_hand(self, interaction: discord.Interaction) -> None:
|
||||||
if interaction.user.id != self.user_id:
|
if interaction.user.id != self.user_id:
|
||||||
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
|
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
|
||||||
return
|
return
|
||||||
|
if self._busy or self.is_finished():
|
||||||
|
await interaction.response.defer()
|
||||||
|
return
|
||||||
|
self._busy = True
|
||||||
|
try:
|
||||||
res = await economy.do_blackjack_bet(self.user_id, self.bet)
|
res = await economy.do_blackjack_bet(self.user_id, self.bet)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
await interaction.response.send_message(
|
await interaction.response.send_message(
|
||||||
S.ERR["broke"].format(bal=coin(res.get("balance", 0))), ephemeral=True
|
S.ERR["broke"].format(bal=coin(res.get("balance", 0))), ephemeral=True
|
||||||
)
|
)
|
||||||
@@ -1021,8 +1082,13 @@ def register_economy_games_commands(
|
|||||||
else:
|
else:
|
||||||
self._refresh_buttons()
|
self._refresh_buttons()
|
||||||
await self.message.edit(embed=self._cur_embed(), view=self)
|
await self.message.edit(embed=self._cur_embed(), view=self)
|
||||||
|
finally:
|
||||||
|
self._busy = False
|
||||||
|
|
||||||
async def on_timeout(self) -> None:
|
async def on_timeout(self) -> None:
|
||||||
|
if self._resolved:
|
||||||
|
return
|
||||||
|
self._resolved = True
|
||||||
active_games.discard(self.user_id)
|
active_games.discard(self.user_id)
|
||||||
try:
|
try:
|
||||||
await economy.do_blackjack_payout(self.user_id, 0, sum(self.bets))
|
await economy.do_blackjack_payout(self.user_id, 0, sum(self.bets))
|
||||||
@@ -1058,6 +1124,9 @@ def register_economy_games_commands(
|
|||||||
|
|
||||||
res = await economy.do_blackjack_bet(interaction.user.id, bet)
|
res = await economy.do_blackjack_bet(interaction.user.id, bet)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
if res["reason"] == "banned":
|
if res["reason"] == "banned":
|
||||||
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||||
elif res["reason"] == "jailed":
|
elif res["reason"] == "jailed":
|
||||||
@@ -1111,31 +1180,36 @@ def register_economy_games_commands(
|
|||||||
await asyncio.sleep(_BJ_DEAL_DELAY)
|
await asyncio.sleep(_BJ_DEAL_DELAY)
|
||||||
if _bj_is_blackjack(dealer_hand):
|
if _bj_is_blackjack(dealer_hand):
|
||||||
push_res = await economy.do_blackjack_payout(interaction.user.id, bet, bet)
|
push_res = await economy.do_blackjack_payout(interaction.user.id, bet, bet)
|
||||||
|
push_line = (
|
||||||
|
S.BJ["push_result"] + S.BJ_UI["balance_line"].format(balance=coin(push_res["balance"]))
|
||||||
|
if push_res.get("ok")
|
||||||
|
else S.BJ["push_result"] + "\n" + S.ERR["payout_failed"]
|
||||||
|
)
|
||||||
embed = _bj_embed(
|
embed = _bj_embed(
|
||||||
player_hand,
|
player_hand,
|
||||||
dealer_hand,
|
dealer_hand,
|
||||||
S.TITLE["blackjack_push"],
|
S.TITLE["blackjack_push"],
|
||||||
0x99AAB5,
|
0x99AAB5,
|
||||||
hide_dealer=False,
|
hide_dealer=False,
|
||||||
result_field=(
|
result_field=(S.BJ["result_field"], push_line),
|
||||||
S.BJ["result_field"],
|
|
||||||
S.BJ["push_result"] + S.BJ_UI["balance_line"].format(balance=coin(push_res["balance"])),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
payout = bet + int(bet * 1.5)
|
payout = bet + int(bet * 1.5)
|
||||||
bj_res = await economy.do_blackjack_payout(interaction.user.id, payout, bet)
|
bj_res = await economy.do_blackjack_payout(interaction.user.id, payout, bet)
|
||||||
|
bj_line = (
|
||||||
|
f"+{coin(payout)}" + S.BJ_UI["balance_line"].format(balance=coin(bj_res["balance"]))
|
||||||
|
if bj_res.get("ok")
|
||||||
|
else f"+{coin(payout)}" + "\n" + S.ERR["payout_failed"]
|
||||||
|
)
|
||||||
embed = _bj_embed(
|
embed = _bj_embed(
|
||||||
player_hand,
|
player_hand,
|
||||||
dealer_hand,
|
dealer_hand,
|
||||||
S.TITLE["blackjack_bj"],
|
S.TITLE["blackjack_bj"],
|
||||||
0xF4C430,
|
0xF4C430,
|
||||||
hide_dealer=False,
|
hide_dealer=False,
|
||||||
result_field=(
|
result_field=(S.BJ["result_field"], bj_line),
|
||||||
S.BJ["result_field"],
|
|
||||||
f"+{coin(payout)}" + S.BJ_UI["balance_line"].format(balance=coin(bj_res["balance"])),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
if bj_res.get("ok"):
|
||||||
asyncio.create_task(award_exp(interaction, economy.gamble_exp(bet)))
|
asyncio.create_task(award_exp(interaction, economy.gamble_exp(bet)))
|
||||||
active_games.discard(interaction.user.id)
|
active_games.discard(interaction.user.id)
|
||||||
await msg.edit(embed=embed)
|
await msg.edit(embed=embed)
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ from discord import app_commands
|
|||||||
from core import economy
|
from core import economy
|
||||||
import strings as S
|
import strings as S
|
||||||
|
|
||||||
|
from ._replies import reply_db_error
|
||||||
|
|
||||||
|
|
||||||
def register_economy_income_commands(
|
def register_economy_income_commands(
|
||||||
tree: app_commands.CommandTree,
|
tree: app_commands.CommandTree,
|
||||||
@@ -25,6 +27,9 @@ def register_economy_income_commands(
|
|||||||
await interaction.response.defer()
|
await interaction.response.defer()
|
||||||
res = await economy.do_daily(interaction.user.id)
|
res = await economy.do_daily(interaction.user.id)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
if res["reason"] == "banned":
|
if res["reason"] == "banned":
|
||||||
await interaction.followup.send(S.MSG_BANNED, ephemeral=True)
|
await interaction.followup.send(S.MSG_BANNED, ephemeral=True)
|
||||||
elif res["reason"] == "cooldown":
|
elif res["reason"] == "cooldown":
|
||||||
@@ -63,6 +68,9 @@ def register_economy_income_commands(
|
|||||||
await interaction.response.defer()
|
await interaction.response.defer()
|
||||||
res = await economy.do_work(interaction.user.id)
|
res = await economy.do_work(interaction.user.id)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
if res["reason"] == "banned":
|
if res["reason"] == "banned":
|
||||||
await interaction.followup.send(S.MSG_BANNED, ephemeral=True)
|
await interaction.followup.send(S.MSG_BANNED, ephemeral=True)
|
||||||
elif res["reason"] == "cooldown":
|
elif res["reason"] == "cooldown":
|
||||||
@@ -97,6 +105,9 @@ def register_economy_income_commands(
|
|||||||
await interaction.response.defer()
|
await interaction.response.defer()
|
||||||
res = await economy.do_beg(interaction.user.id)
|
res = await economy.do_beg(interaction.user.id)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
if res["reason"] == "banned":
|
if res["reason"] == "banned":
|
||||||
await interaction.followup.send(S.MSG_BANNED, ephemeral=True)
|
await interaction.followup.send(S.MSG_BANNED, ephemeral=True)
|
||||||
elif res["reason"] == "cooldown":
|
elif res["reason"] == "cooldown":
|
||||||
@@ -128,6 +139,9 @@ def register_economy_income_commands(
|
|||||||
await interaction.response.defer()
|
await interaction.response.defer()
|
||||||
res = await economy.do_crime(interaction.user.id)
|
res = await economy.do_crime(interaction.user.id)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
if res["reason"] == "banned":
|
if res["reason"] == "banned":
|
||||||
await interaction.followup.send(S.MSG_BANNED, ephemeral=True)
|
await interaction.followup.send(S.MSG_BANNED, ephemeral=True)
|
||||||
elif res["reason"] == "cooldown":
|
elif res["reason"] == "cooldown":
|
||||||
@@ -187,6 +201,9 @@ def register_economy_income_commands(
|
|||||||
await interaction.response.defer()
|
await interaction.response.defer()
|
||||||
res = await economy.do_rob(interaction.user.id, sihtmärk.id)
|
res = await economy.do_rob(interaction.user.id, sihtmärk.id)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
if res["reason"] == "banned":
|
if res["reason"] == "banned":
|
||||||
await interaction.followup.send(S.MSG_BANNED, ephemeral=True)
|
await interaction.followup.send(S.MSG_BANNED, ephemeral=True)
|
||||||
elif res["reason"] == "cooldown":
|
elif res["reason"] == "cooldown":
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from discord import app_commands
|
|||||||
from core import economy
|
from core import economy
|
||||||
import strings as S
|
import strings as S
|
||||||
|
|
||||||
|
from ._replies import reply_db_error
|
||||||
|
|
||||||
|
|
||||||
def register_prestige_commands(
|
def register_prestige_commands(
|
||||||
tree: app_commands.CommandTree,
|
tree: app_commands.CommandTree,
|
||||||
@@ -127,6 +129,9 @@ def register_prestige_commands(
|
|||||||
return
|
return
|
||||||
await interaction.response.defer()
|
await interaction.response.defer()
|
||||||
res = await economy.do_prestige(self.user_id)
|
res = await economy.do_prestige(self.user_id)
|
||||||
|
if not res["ok"] and res.get("reason") == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
self.clear_items()
|
self.clear_items()
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
@@ -166,6 +171,9 @@ def register_prestige_commands(
|
|||||||
await interaction.response.defer()
|
await interaction.response.defer()
|
||||||
res = await economy.do_prestige_buy(self.user_id, upgrade_id)
|
res = await economy.do_prestige_buy(self.user_id, upgrade_id)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
if res["reason"] == "insufficient_pp":
|
if res["reason"] == "insufficient_pp":
|
||||||
err = S.PRESTIGE_UI["buy_no_pp"].format(have=res["have"], need=res["need"])
|
err = S.PRESTIGE_UI["buy_no_pp"].format(have=res["have"], need=res["need"])
|
||||||
elif res["reason"] == "maxed":
|
elif res["reason"] == "maxed":
|
||||||
@@ -221,6 +229,9 @@ def register_prestige_commands(
|
|||||||
return
|
return
|
||||||
res = await economy.do_prestige_buy(interaction.user.id, upgrade.strip().lower())
|
res = await economy.do_prestige_buy(interaction.user.id, upgrade.strip().lower())
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
if res["reason"] == "banned":
|
if res["reason"] == "banned":
|
||||||
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||||
elif res["reason"] == "not_found":
|
elif res["reason"] == "not_found":
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ from discord import app_commands
|
|||||||
from core import economy
|
from core import economy
|
||||||
import strings as S
|
import strings as S
|
||||||
|
|
||||||
|
from ._replies import reply_db_error
|
||||||
|
|
||||||
|
|
||||||
def register_economy_profile_commands(
|
def register_economy_profile_commands(
|
||||||
tree: app_commands.CommandTree,
|
tree: app_commands.CommandTree,
|
||||||
@@ -29,10 +31,15 @@ def register_economy_profile_commands(
|
|||||||
pct = progress / needed if needed > 0 else 1.0
|
pct = progress / needed if needed > 0 else 1.0
|
||||||
filled = int(pct * 12)
|
filled = int(pct * 12)
|
||||||
bar = "█" * filled + "░" * (12 - filled)
|
bar = "█" * filled + "░" * (12 - filled)
|
||||||
embed = discord.Embed(
|
badge = economy.vanity_badge(data)
|
||||||
title=S.PROFILE_UI["main_title"].format(name=target.display_name),
|
title = (
|
||||||
color=0xF4C430,
|
f"{badge[0]} {target.display_name}"
|
||||||
|
if badge
|
||||||
|
else S.PROFILE_UI["main_title"].format(name=target.display_name)
|
||||||
)
|
)
|
||||||
|
embed = discord.Embed(title=title, color=0xF4C430)
|
||||||
|
if badge:
|
||||||
|
embed.set_author(name=badge[1])
|
||||||
embed.add_field(name=S.PROFILE_UI["f_balance"], value=coin(data.get("balance", 0)), inline=True)
|
embed.add_field(name=S.PROFILE_UI["f_balance"], value=coin(data.get("balance", 0)), inline=True)
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name=S.PROFILE_UI["f_level"],
|
name=S.PROFILE_UI["f_level"],
|
||||||
@@ -148,7 +155,12 @@ def register_economy_profile_commands(
|
|||||||
)
|
)
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name=S.STATS_UI["records_field"],
|
name=S.STATS_UI["records_field"],
|
||||||
value=S.STATS_UI["records_val"].format(streak=_s("best_daily_streak")),
|
value=S.STATS_UI["records_val"].format(
|
||||||
|
streak=_s("best_daily_streak"),
|
||||||
|
lootboxes=_s("lootboxes_opened"),
|
||||||
|
achievements=len(data.get("achievements_earned") or []),
|
||||||
|
ach_total=len(economy.ACHIEVEMENTS),
|
||||||
|
),
|
||||||
inline=True,
|
inline=True,
|
||||||
)
|
)
|
||||||
return embed
|
return embed
|
||||||
@@ -287,6 +299,9 @@ def register_economy_profile_commands(
|
|||||||
color=0xF4C430,
|
color=0xF4C430,
|
||||||
)
|
)
|
||||||
embed.add_field(name=S.BALANCE_UI["saldo"], value=coin(data["balance"]), inline=True)
|
embed.add_field(name=S.BALANCE_UI["saldo"], value=coin(data["balance"]), inline=True)
|
||||||
|
bank_balance = data.get("bank_balance", 0)
|
||||||
|
if bank_balance:
|
||||||
|
embed.add_field(name=S.BANK_UI["f_bank"], value=coin(bank_balance), inline=True)
|
||||||
streak = data.get("daily_streak", 0)
|
streak = data.get("daily_streak", 0)
|
||||||
if streak:
|
if streak:
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
@@ -334,6 +349,53 @@ def register_economy_profile_commands(
|
|||||||
data = await economy.get_user(target.id)
|
data = await economy.get_user(target.id)
|
||||||
await interaction.response.send_message(embed=_balance_embed(target, data))
|
await interaction.response.send_message(embed=_balance_embed(target, data))
|
||||||
|
|
||||||
|
@tree.command(name="achievements", description=S.CMD["achievements"])
|
||||||
|
async def cmd_achievements(
|
||||||
|
interaction: discord.Interaction, kasutaja: discord.Member | None = None
|
||||||
|
):
|
||||||
|
target = kasutaja or interaction.user
|
||||||
|
note = ""
|
||||||
|
# Only the invoker's own view claims newly-earned achievements.
|
||||||
|
if target.id == interaction.user.id:
|
||||||
|
res = await economy.do_check_achievements(interaction.user.id)
|
||||||
|
if not res["ok"]:
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
|
if res["new"]:
|
||||||
|
names = ", ".join(
|
||||||
|
f"{economy.ACHIEVEMENTS[a]['emoji']} {economy.ACHIEVEMENTS[a]['name']}"
|
||||||
|
for a in res["new"]
|
||||||
|
)
|
||||||
|
note = S.ACHIEVEMENTS_UI["unlocked_note"].format(
|
||||||
|
names=names, reward=coin(res["reward"])
|
||||||
|
) + "\n\n"
|
||||||
|
|
||||||
|
data = await economy.get_user(target.id)
|
||||||
|
rows = economy.achievements_view(data)
|
||||||
|
earned_count = sum(1 for r in rows if r["earned"])
|
||||||
|
lines = [
|
||||||
|
S.ACHIEVEMENTS_UI["row_earned"].format(
|
||||||
|
emoji=r["emoji"], name=r["name"], reward=coin(r["reward"])
|
||||||
|
)
|
||||||
|
if r["earned"]
|
||||||
|
else S.ACHIEVEMENTS_UI["row_locked"].format(
|
||||||
|
emoji=r["emoji"], name=r["name"], progress=r["progress"],
|
||||||
|
goal=r["goal"], reward=coin(r["reward"]),
|
||||||
|
)
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
title = S.ACHIEVEMENTS_UI["title"]
|
||||||
|
if target.id != interaction.user.id:
|
||||||
|
title += f" · {target.display_name}"
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=title,
|
||||||
|
description=note
|
||||||
|
+ S.ACHIEVEMENTS_UI["desc"].format(earned=earned_count, total=len(rows))
|
||||||
|
+ "\n\n" + "\n".join(lines),
|
||||||
|
color=0xF4C430,
|
||||||
|
)
|
||||||
|
await interaction.response.send_message(embed=embed)
|
||||||
|
|
||||||
@tree.command(name="cooldowns", description=S.CMD["cooldowns"])
|
@tree.command(name="cooldowns", description=S.CMD["cooldowns"])
|
||||||
async def cmd_cooldowns(interaction: discord.Interaction):
|
async def cmd_cooldowns(interaction: discord.Interaction):
|
||||||
data = await economy.get_user(interaction.user.id)
|
data = await economy.get_user(interaction.user.id)
|
||||||
@@ -539,7 +601,12 @@ def register_economy_profile_commands(
|
|||||||
)
|
)
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name=S.STATS_UI["records_field"],
|
name=S.STATS_UI["records_field"],
|
||||||
value=S.STATS_UI["records_val"].format(streak=_s("best_daily_streak")),
|
value=S.STATS_UI["records_val"].format(
|
||||||
|
streak=_s("best_daily_streak"),
|
||||||
|
lootboxes=_s("lootboxes_opened"),
|
||||||
|
achievements=len(data.get("achievements_earned") or []),
|
||||||
|
ach_total=len(economy.ACHIEVEMENTS),
|
||||||
|
),
|
||||||
inline=True,
|
inline=True,
|
||||||
)
|
)
|
||||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
@@ -8,6 +9,8 @@ from discord import app_commands
|
|||||||
from core import economy
|
from core import economy
|
||||||
import strings as S
|
import strings as S
|
||||||
|
|
||||||
|
from ._replies import reply_db_error
|
||||||
|
|
||||||
|
|
||||||
def register_economy_support_commands(
|
def register_economy_support_commands(
|
||||||
tree: app_commands.CommandTree,
|
tree: app_commands.CommandTree,
|
||||||
@@ -38,15 +41,24 @@ def register_economy_support_commands(
|
|||||||
)
|
)
|
||||||
return
|
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)
|
res = await economy.do_give(interaction.user.id, self._view.requester.id, amount)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
|
self._view.remaining += amount # roll back the reservation
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
data = await economy.get_user(interaction.user.id)
|
data = await economy.get_user(interaction.user.id)
|
||||||
await interaction.response.send_message(
|
await interaction.response.send_message(
|
||||||
S.ERR["broke"].format(bal=coin(data["balance"])), ephemeral=True
|
S.ERR["broke"].format(bal=coin(data["balance"])), ephemeral=True
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
self._view.remaining -= amount
|
|
||||||
funded_line = S.REQUEST_UI["funded_line"].format(
|
funded_line = S.REQUEST_UI["funded_line"].format(
|
||||||
name=interaction.user.display_name,
|
name=interaction.user.display_name,
|
||||||
amount=coin(amount),
|
amount=coin(amount),
|
||||||
@@ -84,6 +96,9 @@ def register_economy_support_commands(
|
|||||||
self.add_item(self.fund_btn)
|
self.add_item(self.fund_btn)
|
||||||
|
|
||||||
async def _fund(self, interaction: discord.Interaction):
|
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:
|
if interaction.user.id == self.requester.id:
|
||||||
await interaction.response.send_message(S.ERR["request_self_fund"], ephemeral=True)
|
await interaction.response.send_message(S.ERR["request_self_fund"], ephemeral=True)
|
||||||
return
|
return
|
||||||
@@ -154,6 +169,263 @@ def register_economy_support_commands(
|
|||||||
await interaction.response.send_message(embed=embed, view=view)
|
await interaction.response.send_message(embed=embed, view=view)
|
||||||
view.message = await interaction.original_response()
|
view.message = await interaction.original_response()
|
||||||
|
|
||||||
|
# -- /consumables -------------------------------------------------------
|
||||||
|
def _consumables_embed(user_data: dict) -> discord.Embed:
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=S.CONSUMABLES_UI["title"],
|
||||||
|
description=S.CONSUMABLES_UI["desc"].format(bal=coin(user_data["balance"])),
|
||||||
|
color=0xF4C430,
|
||||||
|
)
|
||||||
|
active = economy.active_buffs(user_data)
|
||||||
|
if active:
|
||||||
|
lines = [
|
||||||
|
S.CONSUMABLES_UI["buff_line"].format(
|
||||||
|
name=S.CONSUMABLES_UI[f"kind_{kind}"],
|
||||||
|
time=economy.format_td(economy.buff_remaining(user_data, kind)),
|
||||||
|
)
|
||||||
|
for kind in active
|
||||||
|
]
|
||||||
|
buff_value = "\n".join(lines)
|
||||||
|
else:
|
||||||
|
buff_value = S.CONSUMABLES_UI["active_none"]
|
||||||
|
embed.add_field(name=S.CONSUMABLES_UI["active_header"], value=buff_value, inline=False)
|
||||||
|
for cons in economy.CONSUMABLES.values():
|
||||||
|
embed.add_field(
|
||||||
|
name=f"{cons['emoji']} {cons['name']} · {cons['cost']} {economy.COIN}",
|
||||||
|
value=cons["description"],
|
||||||
|
inline=False,
|
||||||
|
)
|
||||||
|
return embed
|
||||||
|
|
||||||
|
@tree.command(name="consumables", description=S.CMD["consumables"])
|
||||||
|
@app_commands.describe(ese=S.OPT["consumable_ese"])
|
||||||
|
@app_commands.choices(
|
||||||
|
ese=[
|
||||||
|
app_commands.Choice(name=f"{c['name']} ({c['cost']} TipiCOINi)", value=cid)
|
||||||
|
for cid, c in economy.CONSUMABLES.items()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
async def cmd_consumables(
|
||||||
|
interaction: discord.Interaction,
|
||||||
|
ese: app_commands.Choice[str] | None = None,
|
||||||
|
):
|
||||||
|
if ese is None:
|
||||||
|
data = await economy.get_user(interaction.user.id)
|
||||||
|
await interaction.response.send_message(
|
||||||
|
embed=_consumables_embed(data), ephemeral=True
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
res = await economy.do_buy_consumable(interaction.user.id, ese.value)
|
||||||
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
|
if res["reason"] == "banned":
|
||||||
|
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||||
|
elif res["reason"] == "insufficient":
|
||||||
|
await interaction.response.send_message(
|
||||||
|
S.ERR["broke_need"].format(need=coin(res["need"])), ephemeral=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await interaction.response.send_message(S.ERR["item_not_found"], ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
cons = res["consumable"]
|
||||||
|
if res["instant"]:
|
||||||
|
# Kohv wiped these cooldowns, so any reminder DM already scheduled for
|
||||||
|
# them is now stale - cancel it (the next command run reschedules).
|
||||||
|
for cmd in economy.INSTANT_RESET_COMMANDS:
|
||||||
|
cancel_reminder_task(interaction.user.id, cmd)
|
||||||
|
desc = S.CONSUMABLES_UI["bought_instant"].format(balance=coin(res["balance"]))
|
||||||
|
else:
|
||||||
|
key = "bought_extended" if res["extended"] else "bought_buff"
|
||||||
|
desc = S.CONSUMABLES_UI[key].format(
|
||||||
|
time=economy.format_td(res["remaining"]),
|
||||||
|
balance=coin(res["balance"]),
|
||||||
|
)
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=S.CONSUMABLES_UI["bought_title"].format(emoji=cons["emoji"], name=cons["name"]),
|
||||||
|
description=desc,
|
||||||
|
color=0x57F287,
|
||||||
|
)
|
||||||
|
await interaction.response.send_message(embed=embed)
|
||||||
|
|
||||||
|
# -- /vanity ------------------------------------------------------------
|
||||||
|
def _vanity_embed(user_data: dict) -> discord.Embed:
|
||||||
|
owned = set(user_data.get("vanity_owned") or [])
|
||||||
|
active = user_data.get("vanity_active")
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=S.VANITY_UI["title"],
|
||||||
|
description=S.VANITY_UI["desc"].format(bal=coin(user_data.get("balance", 0))),
|
||||||
|
color=0xF4C430,
|
||||||
|
)
|
||||||
|
for vid, v in economy.VANITY.items():
|
||||||
|
if vid == active:
|
||||||
|
status = S.VANITY_UI["line_active"]
|
||||||
|
elif vid in owned:
|
||||||
|
status = S.VANITY_UI["line_owned"]
|
||||||
|
else:
|
||||||
|
status = f"{v['cost']} {economy.COIN}"
|
||||||
|
embed.add_field(
|
||||||
|
name=f"{v['emoji']} {v['name']} · {status}",
|
||||||
|
value=S.VANITY_UI["entry_title"].format(title=v["title"]),
|
||||||
|
inline=False,
|
||||||
|
)
|
||||||
|
embed.set_footer(text=S.VANITY_UI["footer"])
|
||||||
|
return embed
|
||||||
|
|
||||||
|
@tree.command(name="vanity", description=S.CMD["vanity"])
|
||||||
|
@app_commands.describe(ese=S.OPT["vanity_ese"])
|
||||||
|
@app_commands.choices(
|
||||||
|
ese=[
|
||||||
|
app_commands.Choice(name=f"{v['emoji']} {v['title']} ({v['cost']} TipiCOINi)", value=vid)
|
||||||
|
for vid, v in economy.VANITY.items()
|
||||||
|
]
|
||||||
|
+ [app_commands.Choice(name=S.VANITY_UI["none_choice"], value=economy.vanity.NONE_ID)]
|
||||||
|
)
|
||||||
|
async def cmd_vanity(
|
||||||
|
interaction: discord.Interaction,
|
||||||
|
ese: app_commands.Choice[str] | None = None,
|
||||||
|
):
|
||||||
|
if ese is None:
|
||||||
|
data = await economy.get_user(interaction.user.id)
|
||||||
|
await interaction.response.send_message(
|
||||||
|
embed=_vanity_embed(data), ephemeral=True
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
res = await economy.do_vanity_select(interaction.user.id, ese.value)
|
||||||
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
|
if res["reason"] == "banned":
|
||||||
|
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||||
|
elif res["reason"] == "insufficient":
|
||||||
|
await interaction.response.send_message(
|
||||||
|
S.ERR["broke_need"].format(need=coin(res["need"])), ephemeral=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await interaction.response.send_message(S.ERR["item_not_found"], ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
action = res["action"]
|
||||||
|
if action == "unequipped":
|
||||||
|
await interaction.response.send_message(S.VANITY_UI["unequipped"], ephemeral=True)
|
||||||
|
return
|
||||||
|
v = res["vanity"]
|
||||||
|
if action == "bought":
|
||||||
|
msg = S.VANITY_UI["bought"].format(
|
||||||
|
emoji=v["emoji"], title=v["title"], balance=coin(res["balance"])
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
msg = S.VANITY_UI["equipped"].format(emoji=v["emoji"], title=v["title"])
|
||||||
|
await interaction.response.send_message(msg)
|
||||||
|
|
||||||
|
# -- /lootbox -----------------------------------------------------------
|
||||||
|
@tree.command(name="lootbox", description=S.CMD["lootbox"])
|
||||||
|
async def cmd_lootbox(interaction: discord.Interaction):
|
||||||
|
res = await economy.do_open_lootbox(interaction.user.id)
|
||||||
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
elif res["reason"] == "banned":
|
||||||
|
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||||
|
else:
|
||||||
|
await interaction.response.send_message(
|
||||||
|
S.ERR["broke_need"].format(need=coin(res["need"])), ephemeral=True
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Suspense: show the box opening, then reveal the reward.
|
||||||
|
await interaction.response.send_message(
|
||||||
|
embed=discord.Embed(
|
||||||
|
title=S.LOOTBOX_UI["title"], description=S.LOOTBOX_UI["opening"], color=0xF4C430
|
||||||
|
)
|
||||||
|
)
|
||||||
|
msg = await interaction.original_response()
|
||||||
|
await asyncio.sleep(1.2)
|
||||||
|
|
||||||
|
if res["buff_kind"]:
|
||||||
|
line = S.LOOTBOX_UI["buff_" + res["buff_kind"]].format(min=res["buff_min"])
|
||||||
|
foot = S.LOOTBOX_UI["foot_buff"].format(balance=coin(res["balance"]))
|
||||||
|
color = 0x5865F2
|
||||||
|
else:
|
||||||
|
line = S.LOOTBOX_UI[res["outcome"]].format(coins=coin(res["reward_coins"]))
|
||||||
|
if res["net"] >= 0:
|
||||||
|
foot = S.LOOTBOX_UI["foot_win"].format(net=coin(res["net"]), balance=coin(res["balance"]))
|
||||||
|
color = 0x57F287
|
||||||
|
else:
|
||||||
|
foot = S.LOOTBOX_UI["foot_loss"].format(net=coin(abs(res["net"])), balance=coin(res["balance"]))
|
||||||
|
color = 0xF4C430 if res["outcome"] != "coins_small" else 0xED4245
|
||||||
|
embed = discord.Embed(title=S.LOOTBOX_UI["title"], description=line, color=color)
|
||||||
|
embed.set_footer(text=foot)
|
||||||
|
await msg.edit(embed=embed)
|
||||||
|
|
||||||
|
# -- /bank, /deposit, /withdraw -----------------------------------------
|
||||||
|
@tree.command(name="bank", description=S.CMD["bank"])
|
||||||
|
async def cmd_bank(interaction: discord.Interaction):
|
||||||
|
data = await economy.get_user(interaction.user.id)
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=S.BANK_UI["title"], description=S.BANK_UI["desc"], color=0xF4C430
|
||||||
|
)
|
||||||
|
embed.add_field(name=S.BANK_UI["f_liquid"], value=coin(data.get("balance", 0)), inline=True)
|
||||||
|
embed.add_field(name=S.BANK_UI["f_bank"], value=coin(data.get("bank_balance", 0)), inline=True)
|
||||||
|
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||||
|
|
||||||
|
@tree.command(name="deposit", description=S.CMD["deposit"])
|
||||||
|
@app_commands.describe(summa=S.OPT["deposit_summa"])
|
||||||
|
async def cmd_deposit(interaction: discord.Interaction, summa: str):
|
||||||
|
data = await economy.get_user(interaction.user.id)
|
||||||
|
amount, err = parse_amount(summa, data.get("balance", 0))
|
||||||
|
if err or amount is None:
|
||||||
|
await interaction.response.send_message(err or S.ERR["invalid_amount"], ephemeral=True)
|
||||||
|
return
|
||||||
|
if amount <= 0:
|
||||||
|
await interaction.response.send_message(S.ERR["positive_amount"], ephemeral=True)
|
||||||
|
return
|
||||||
|
res = await economy.do_deposit(interaction.user.id, amount)
|
||||||
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
elif res["reason"] == "banned":
|
||||||
|
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||||
|
else:
|
||||||
|
await interaction.response.send_message(S.BANK_UI["nothing_liquid"], ephemeral=True)
|
||||||
|
return
|
||||||
|
await interaction.response.send_message(
|
||||||
|
S.BANK_UI["deposited"].format(
|
||||||
|
amount=coin(res["amount"]), balance=coin(res["balance"]), bank=coin(res["bank"])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@tree.command(name="withdraw", description=S.CMD["withdraw"])
|
||||||
|
@app_commands.describe(summa=S.OPT["withdraw_summa"])
|
||||||
|
async def cmd_withdraw(interaction: discord.Interaction, summa: str):
|
||||||
|
data = await economy.get_user(interaction.user.id)
|
||||||
|
amount, err = parse_amount(summa, data.get("bank_balance", 0))
|
||||||
|
if err or amount is None:
|
||||||
|
await interaction.response.send_message(err or S.ERR["invalid_amount"], ephemeral=True)
|
||||||
|
return
|
||||||
|
if amount <= 0:
|
||||||
|
await interaction.response.send_message(S.ERR["positive_amount"], ephemeral=True)
|
||||||
|
return
|
||||||
|
res = await economy.do_withdraw(interaction.user.id, amount)
|
||||||
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
elif res["reason"] == "banned":
|
||||||
|
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||||
|
else:
|
||||||
|
await interaction.response.send_message(S.BANK_UI["nothing_bank"], ephemeral=True)
|
||||||
|
return
|
||||||
|
await interaction.response.send_message(
|
||||||
|
S.BANK_UI["withdrawn"].format(
|
||||||
|
amount=coin(res["amount"]), balance=coin(res["balance"]), bank=coin(res["bank"])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
class RemindersSelect(discord.ui.Select):
|
class RemindersSelect(discord.ui.Select):
|
||||||
def __init__(self, user_id: int, current: list[str]):
|
def __init__(self, user_id: int, current: list[str]):
|
||||||
self.user_id = user_id
|
self.user_id = user_id
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ from pathlib import Path
|
|||||||
import discord
|
import discord
|
||||||
from discord import app_commands
|
from discord import app_commands
|
||||||
|
|
||||||
|
from core import economy
|
||||||
from core.admin import bot_admin_check
|
from core.admin import bot_admin_check
|
||||||
import strings as S
|
import strings as S
|
||||||
from core.admin import bot_admin_check
|
|
||||||
|
|
||||||
|
|
||||||
def register_ops_admin_commands(
|
def register_ops_admin_commands(
|
||||||
@@ -45,6 +45,7 @@ def register_ops_admin_commands(
|
|||||||
latency_ms = round(bot.latency * 1000, 1)
|
latency_ms = round(bot.latency * 1000, 1)
|
||||||
cache_size = get_member_cache_size()
|
cache_size = get_member_cache_size()
|
||||||
user_count = await count_economy_users()
|
user_count = await count_economy_users()
|
||||||
|
eco_stats = await economy.get_economy_stats()
|
||||||
|
|
||||||
embed = discord.Embed(title=S.STATUS_UI["title"], color=0x57F287)
|
embed = discord.Embed(title=S.STATUS_UI["title"], color=0x57F287)
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
@@ -82,6 +83,18 @@ def register_ops_admin_commands(
|
|||||||
value=str(cache_size),
|
value=str(cache_size),
|
||||||
inline=True,
|
inline=True,
|
||||||
)
|
)
|
||||||
|
total = eco_stats["total_coins"]
|
||||||
|
house_pct = round(eco_stats["house_balance"] / total * 100) if total else 0
|
||||||
|
embed.add_field(
|
||||||
|
name=S.STATUS_UI["supply_field"],
|
||||||
|
value=S.STATUS_UI["supply_val"].format(
|
||||||
|
total=f"{total:,}".replace(",", " "),
|
||||||
|
players=f"{eco_stats['player_coins']:,}".replace(",", " "),
|
||||||
|
house=f"{eco_stats['house_balance']:,}".replace(",", " "),
|
||||||
|
house_pct=house_pct,
|
||||||
|
),
|
||||||
|
inline=False,
|
||||||
|
)
|
||||||
|
|
||||||
log_lines = [
|
log_lines = [
|
||||||
S.STATUS_UI["log_line"].format(name=p.name, size_kb=f"{p.stat().st_size / 1024:.1f}")
|
S.STATUS_UI["log_line"].format(name=p.name, size_kb=f"{p.stat().st_size / 1024:.1f}")
|
||||||
|
|||||||
@@ -42,6 +42,15 @@ BIRTHDAY_CHANNEL_ID = (
|
|||||||
BIRTHDAY_WINDOW_DAYS = int(os.getenv("BIRTHDAY_WINDOW_DAYS", "7"))
|
BIRTHDAY_WINDOW_DAYS = int(os.getenv("BIRTHDAY_WINDOW_DAYS", "7"))
|
||||||
BASE_ROLE_IDS: list[int] = [1478304631930228779, 1478302278862766190]
|
BASE_ROLE_IDS: list[int] = [1478304631930228779, 1478302278862766190]
|
||||||
|
|
||||||
|
# Channel where the daily lottery draw result is announced. Optional - if unset,
|
||||||
|
# the draw still runs and pays the winner, it just isn't announced.
|
||||||
|
_LEGACY_LOTTERY_CHANNEL_ID = _env_int("LOTTERY_CHANNEL_ID", 0)
|
||||||
|
LOTTERY_CHANNEL_ID_DEV = _env_int("LOTTERY_CHANNEL_ID_DEV", _LEGACY_LOTTERY_CHANNEL_ID)
|
||||||
|
LOTTERY_CHANNEL_ID_ECONOMY = _env_int("LOTTERY_CHANNEL_ID_ECONOMY", 0)
|
||||||
|
LOTTERY_CHANNEL_ID = (
|
||||||
|
LOTTERY_CHANNEL_ID_ECONOMY if BOT_PROFILE == "economy" else LOTTERY_CHANNEL_ID_DEV
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _parse_admin_roles(raw: str) -> dict[int, set[int]]:
|
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...".
|
"""Parse DISCORD_ADMIN_ROLES env var as "guild_id:role_id[:role_id...],guild_id:role_id...".
|
||||||
|
|||||||
@@ -16,8 +16,12 @@ from .store import (
|
|||||||
)
|
)
|
||||||
from .house import *
|
from .house import *
|
||||||
from .house import _credit_house, _house_record_id
|
from .house import _credit_house, _house_record_id
|
||||||
|
from .bank import *
|
||||||
from .levels import *
|
from .levels import *
|
||||||
from .shop import *
|
from .shop import *
|
||||||
|
from .consumables import *
|
||||||
|
from .vanity import *
|
||||||
|
from .lootbox import *
|
||||||
from .fishing import *
|
from .fishing import *
|
||||||
from .quests import *
|
from .quests import *
|
||||||
from .quests import _ensure_quests, _pick_quests, _quest_view
|
from .quests import _ensure_quests, _pick_quests, _quest_view
|
||||||
@@ -27,9 +31,12 @@ from .gambling import *
|
|||||||
from .prestige import *
|
from .prestige import *
|
||||||
from .leaderboards import *
|
from .leaderboards import *
|
||||||
from .heist import *
|
from .heist import *
|
||||||
|
from .achievements import *
|
||||||
|
from .lottery import *
|
||||||
from .admin import *
|
from .admin import *
|
||||||
|
|
||||||
from . import ( # noqa: E402 (submodules addressable as economy.store etc.)
|
from . import ( # noqa: E402 (submodules addressable as economy.store etc.)
|
||||||
admin, fishing, gambling, heist, house, income, jail, leaderboards,
|
achievements, admin, bank, consumables, fishing, gambling, heist, house,
|
||||||
levels, prestige, quests, shop, store,
|
income, jail, leaderboards, levels, lootbox, lottery, prestige, quests,
|
||||||
|
shop, store, vanity,
|
||||||
)
|
)
|
||||||
|
|||||||
111
core/economy/achievements.py
Normal file
111
core/economy/achievements.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"""Achievements: one-time milestone badges over the lifetime stat counters.
|
||||||
|
|
||||||
|
Each achievement watches a monotonic stat the bot already tracks and unlocks once
|
||||||
|
that stat crosses a threshold, paying a modest one-time coin reward. Detection is
|
||||||
|
lazy: do_check_achievements is called when the player opens /achievements (like
|
||||||
|
quests roll on view), so no hook is needed on every command. Rewards are bounded
|
||||||
|
(each pays once) so this is a small, capped coin source.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TypedDict
|
||||||
|
|
||||||
|
from ..pb_client import DatabaseError
|
||||||
|
from .store import _commit, _locked_by, _txn, get_user
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Achievement",
|
||||||
|
"ACHIEVEMENTS",
|
||||||
|
"newly_earned",
|
||||||
|
"achievements_view",
|
||||||
|
"do_check_achievements",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Achievement(TypedDict):
|
||||||
|
stat: str # UserData counter this milestone watches
|
||||||
|
goal: int
|
||||||
|
name: str
|
||||||
|
emoji: str
|
||||||
|
reward: int # one-time coin payout
|
||||||
|
|
||||||
|
|
||||||
|
# Ordered roughly easy -> hard within each theme. Rewards scale with difficulty.
|
||||||
|
ACHIEVEMENTS: dict[str, Achievement] = {
|
||||||
|
# Grind
|
||||||
|
"work_10": {"stat": "work_count", "goal": 10, "name": "Töömesilane", "emoji": "🐝", "reward": 250},
|
||||||
|
"work_100": {"stat": "work_count", "goal": 100, "name": "Töönarkomaan", "emoji": "🛠️", "reward": 1_500},
|
||||||
|
"beg_50": {"stat": "beg_count", "goal": 50, "name": "Elukutseline kerjus", "emoji": "🥺", "reward": 500},
|
||||||
|
# Wealth
|
||||||
|
"earned_50k": {"stat": "lifetime_earned", "goal": 50_000, "name": "Jõukas", "emoji": "💰", "reward": 1_000},
|
||||||
|
"earned_500k": {"stat": "lifetime_earned", "goal": 500_000, "name": "TipiRIKAS", "emoji": "🤑", "reward": 5_000},
|
||||||
|
# Gambling
|
||||||
|
"wager_10k": {"stat": "total_wagered", "goal": 10_000, "name": "Hasartmängur", "emoji": "🎲", "reward": 750},
|
||||||
|
"wager_100k": {"stat": "total_wagered", "goal": 100_000, "name": "Kõrgete panuste mängija", "emoji": "🃏", "reward": 3_000},
|
||||||
|
"jackpot_1": {"stat": "slots_jackpots", "goal": 1, "name": "Jackpot!", "emoji": "🎰", "reward": 1_000},
|
||||||
|
# Crime & heists
|
||||||
|
"crime_25": {"stat": "crimes_succeeded", "goal": 25, "name": "Kurjategija", "emoji": "🦹", "reward": 1_000},
|
||||||
|
"heist_5": {"stat": "heists_won", "goal": 5, "name": "Pangaröövel", "emoji": "💣", "reward": 1_500},
|
||||||
|
# Fishing
|
||||||
|
"fish_25": {"stat": "total_fish_caught", "goal": 25, "name": "Kalur", "emoji": "🎣", "reward": 500},
|
||||||
|
"fish_250": {"stat": "total_fish_caught", "goal": 250, "name": "Kalapüügimeister", "emoji": "🐟", "reward": 3_000},
|
||||||
|
# Dedication
|
||||||
|
"streak_7": {"stat": "best_daily_streak", "goal": 7, "name": "Püsiv", "emoji": "🔥", "reward": 500},
|
||||||
|
"streak_30": {"stat": "best_daily_streak", "goal": 30, "name": "Pühendunud", "emoji": "🗓️", "reward": 2_500},
|
||||||
|
"prestige_1": {"stat": "prestige_level", "goal": 1, "name": "Taassünd", "emoji": "♻️", "reward": 2_000},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _earned_ids(user) -> set[str]:
|
||||||
|
return set(user.get("achievements_earned") or [])
|
||||||
|
|
||||||
|
|
||||||
|
def newly_earned(user) -> list[str]:
|
||||||
|
"""Achievement ids whose threshold is met but which are not yet claimed."""
|
||||||
|
earned = _earned_ids(user)
|
||||||
|
return [
|
||||||
|
aid for aid, a in ACHIEVEMENTS.items()
|
||||||
|
if aid not in earned and int(user.get(a["stat"], 0) or 0) >= a["goal"]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def achievements_view(user) -> list[dict]:
|
||||||
|
"""Display rows for every achievement: progress + earned flag (ordered as
|
||||||
|
defined, earned last so unfinished goals surface first)."""
|
||||||
|
earned = _earned_ids(user)
|
||||||
|
rows = []
|
||||||
|
for aid, a in ACHIEVEMENTS.items():
|
||||||
|
prog = int(user.get(a["stat"], 0) or 0)
|
||||||
|
rows.append({
|
||||||
|
"id": aid, "name": a["name"], "emoji": a["emoji"],
|
||||||
|
"goal": a["goal"], "reward": a["reward"],
|
||||||
|
"progress": min(prog, a["goal"]), "earned": aid in earned,
|
||||||
|
})
|
||||||
|
rows.sort(key=lambda r: r["earned"]) # unearned first
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
@_locked_by(0)
|
||||||
|
async def do_check_achievements(user_id: int) -> dict:
|
||||||
|
"""Claim any newly-earned achievements and pay their one-time rewards."""
|
||||||
|
try:
|
||||||
|
user = await get_user(user_id)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
|
new = newly_earned(user)
|
||||||
|
if not new:
|
||||||
|
return {"ok": True, "new": [], "reward": 0, "balance": user["balance"]}
|
||||||
|
|
||||||
|
earned = list(user.get("achievements_earned") or [])
|
||||||
|
total = 0
|
||||||
|
for aid in new:
|
||||||
|
earned.append(aid)
|
||||||
|
total += ACHIEVEMENTS[aid]["reward"]
|
||||||
|
user["achievements_earned"] = earned
|
||||||
|
user["balance"] += total
|
||||||
|
user["lifetime_earned"] = user.get("lifetime_earned", 0) + total
|
||||||
|
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
|
||||||
|
await _commit(user_id, user)
|
||||||
|
_txn("ACHIEVEMENTS", user=user_id, unlocked=",".join(new), reward=f"+{total}", bal=user["balance"])
|
||||||
|
return {"ok": True, "new": new, "reward": total, "balance": user["balance"]}
|
||||||
@@ -22,6 +22,7 @@ async def do_season_reset(top_n: int = 10) -> list[tuple[str, int, int]]:
|
|||||||
reset_fields = {
|
reset_fields = {
|
||||||
"exp": 0,
|
"exp": 0,
|
||||||
"balance": 0,
|
"balance": 0,
|
||||||
|
"bank_balance": 0,
|
||||||
"items": [],
|
"items": [],
|
||||||
"item_uses": {},
|
"item_uses": {},
|
||||||
"last_daily": None,
|
"last_daily": None,
|
||||||
|
|||||||
63
core/economy/bank.py
Normal file
63
core/economy/bank.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""Bank vault: rob-proof coin storage.
|
||||||
|
|
||||||
|
Coins moved to the bank are safe from /rob and /heist (those target the liquid
|
||||||
|
`balance` only), but they earn no interest and cannot be spent, gambled or given
|
||||||
|
until withdrawn. This is the deliberate trade-off against keeping coins liquid,
|
||||||
|
where Bot Farm can earn interest but a robber can take a cut. Net worth
|
||||||
|
(balance + bank_balance) is what the coins leaderboard ranks, so banking never
|
||||||
|
hides you from the leaderboard.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..pb_client import DatabaseError
|
||||||
|
from .store import _commit, _locked_by, _txn, get_user
|
||||||
|
|
||||||
|
__all__ = ["do_deposit", "do_withdraw"]
|
||||||
|
|
||||||
|
|
||||||
|
@_locked_by(0)
|
||||||
|
async def do_deposit(user_id: int, amount: int) -> dict:
|
||||||
|
"""Move `amount` coins from liquid balance into the bank vault."""
|
||||||
|
try:
|
||||||
|
user = await get_user(user_id)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
|
if user.get("eco_banned"):
|
||||||
|
return {"ok": False, "reason": "banned"}
|
||||||
|
if amount <= 0:
|
||||||
|
return {"ok": False, "reason": "invalid"}
|
||||||
|
if user["balance"] < amount:
|
||||||
|
return {"ok": False, "reason": "insufficient", "balance": user["balance"]}
|
||||||
|
user["balance"] -= amount
|
||||||
|
user["bank_balance"] = user.get("bank_balance", 0) + amount
|
||||||
|
try:
|
||||||
|
await _commit(user_id, user)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
|
_txn("BANK_DEPOSIT", user=user_id, amount=amount, bal=user["balance"], bank=user["bank_balance"])
|
||||||
|
return {"ok": True, "amount": amount, "balance": user["balance"], "bank": user["bank_balance"]}
|
||||||
|
|
||||||
|
|
||||||
|
@_locked_by(0)
|
||||||
|
async def do_withdraw(user_id: int, amount: int) -> dict:
|
||||||
|
"""Move `amount` coins from the bank vault back to liquid balance."""
|
||||||
|
try:
|
||||||
|
user = await get_user(user_id)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
|
if user.get("eco_banned"):
|
||||||
|
return {"ok": False, "reason": "banned"}
|
||||||
|
if amount <= 0:
|
||||||
|
return {"ok": False, "reason": "invalid"}
|
||||||
|
bank = user.get("bank_balance", 0)
|
||||||
|
if bank < amount:
|
||||||
|
return {"ok": False, "reason": "insufficient", "bank": bank}
|
||||||
|
user["bank_balance"] = bank - amount
|
||||||
|
user["balance"] += amount
|
||||||
|
try:
|
||||||
|
await _commit(user_id, user)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
|
_txn("BANK_WITHDRAW", user=user_id, amount=amount, bal=user["balance"], bank=user["bank_balance"])
|
||||||
|
return {"ok": True, "amount": amount, "balance": user["balance"], "bank": user["bank_balance"]}
|
||||||
170
core/economy/consumables.py
Normal file
170
core/economy/consumables.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
"""Consumables: repeatable, expiring boosts - a recurring coin sink.
|
||||||
|
|
||||||
|
Unlike SHOP items (permanent, bought once), consumables are bought over and
|
||||||
|
over. Buying either activates a timed buff (stored in user["active_buffs"] as
|
||||||
|
{kind: expiry_iso}) or applies an instant effect, and the coins are destroyed -
|
||||||
|
so this keeps the shop relevant, and the economy draining, after a player has
|
||||||
|
maxed out permanent gear.
|
||||||
|
|
||||||
|
Effect hooks live where the effect belongs:
|
||||||
|
- "earn" buff -> income.do_work / do_beg / do_crime (via earn_mult)
|
||||||
|
- "exp" buff -> levels.award_exp (via exp_buff_mult)
|
||||||
|
- instant kohv -> handled entirely here (wipes cooldown timestamps)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import TypedDict
|
||||||
|
|
||||||
|
import strings
|
||||||
|
|
||||||
|
from ..pb_client import DatabaseError
|
||||||
|
from ..emoji import EMOJI as E
|
||||||
|
from .store import _commit, _locked_by, _now, _parse_dt, _txn, get_user
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Consumable",
|
||||||
|
"CONSUMABLES",
|
||||||
|
"INSTANT_RESET_COMMANDS",
|
||||||
|
"active_buffs",
|
||||||
|
"buff_remaining",
|
||||||
|
"earn_mult",
|
||||||
|
"exp_buff_mult",
|
||||||
|
"grant_buff",
|
||||||
|
"do_buy_consumable",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Consumable(TypedDict):
|
||||||
|
name: str
|
||||||
|
emoji: str
|
||||||
|
cost: int
|
||||||
|
description: str
|
||||||
|
kind: str # active_buffs key ("earn"/"exp"), or "instant"
|
||||||
|
duration_min: int # buff lifetime in minutes (0 for instant effects)
|
||||||
|
|
||||||
|
|
||||||
|
CONSUMABLES: dict[str, Consumable] = {
|
||||||
|
"energy_xl": {
|
||||||
|
"name": "Energiajook XL",
|
||||||
|
"emoji": E["TipiBULL"],
|
||||||
|
"cost": 500,
|
||||||
|
"description": strings.CONSUMABLE_DESCRIPTIONS["energy_xl"],
|
||||||
|
"kind": "earn",
|
||||||
|
"duration_min": 60,
|
||||||
|
},
|
||||||
|
"xp_potion": {
|
||||||
|
"name": "XP jook",
|
||||||
|
"emoji": "✨",
|
||||||
|
"cost": 500,
|
||||||
|
"description": strings.CONSUMABLE_DESCRIPTIONS["xp_potion"],
|
||||||
|
"kind": "exp",
|
||||||
|
"duration_min": 60,
|
||||||
|
},
|
||||||
|
"kohv": {
|
||||||
|
"name": "Kohv",
|
||||||
|
"emoji": "☕",
|
||||||
|
"cost": 300,
|
||||||
|
"description": strings.CONSUMABLE_DESCRIPTIONS["kohv"],
|
||||||
|
"kind": "instant",
|
||||||
|
"duration_min": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Multiplier granted while a buff of each kind is active.
|
||||||
|
_BUFF_MULT: dict[str, float] = {"earn": 2.0, "exp": 2.0}
|
||||||
|
|
||||||
|
# Cooldown timestamps an instant "kohv" clears.
|
||||||
|
_COOLDOWN_FIELDS = ("last_work", "last_beg", "last_crime", "last_rob", "last_fish")
|
||||||
|
|
||||||
|
# The slash-command names whose cooldowns an instant "kohv" clears. The Discord
|
||||||
|
# layer uses these to cancel any pending reminder DMs, since the cooldowns they
|
||||||
|
# were scheduled for no longer exist.
|
||||||
|
INSTANT_RESET_COMMANDS = tuple(f.removeprefix("last_") for f in _COOLDOWN_FIELDS)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Buff inspection helpers (pure - safe to call while holding a user lock)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def active_buffs(user) -> dict[str, str]:
|
||||||
|
"""Return {kind: expiry_iso} for buffs that have not expired yet."""
|
||||||
|
buffs = user.get("active_buffs") or {}
|
||||||
|
now = _now()
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
for kind, expiry in buffs.items():
|
||||||
|
dt = _parse_dt(expiry)
|
||||||
|
if dt is not None and dt > now:
|
||||||
|
out[kind] = expiry
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def buff_remaining(user, kind: str) -> timedelta | None:
|
||||||
|
"""Remaining time on a buff kind, or None if it is inactive."""
|
||||||
|
expiry = active_buffs(user).get(kind)
|
||||||
|
if expiry is None:
|
||||||
|
return None
|
||||||
|
return _parse_dt(expiry) - _now()
|
||||||
|
|
||||||
|
|
||||||
|
def earn_mult(user) -> float:
|
||||||
|
"""Earnings multiplier from an active 'earn' buff (1.0 if none)."""
|
||||||
|
return _BUFF_MULT["earn"] if "earn" in active_buffs(user) else 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def exp_buff_mult(user) -> float:
|
||||||
|
"""EXP multiplier from an active 'exp' buff (1.0 if none)."""
|
||||||
|
return _BUFF_MULT["exp"] if "exp" in active_buffs(user) else 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def grant_buff(user, kind: str, duration_min: int) -> bool:
|
||||||
|
"""Add/extend a timed buff of `kind` on `user` in place. Stacks: extends from
|
||||||
|
the current expiry if still active, else starts now. Returns True if it
|
||||||
|
extended an existing buff. Pure - safe under a user lock (caller commits)."""
|
||||||
|
buffs = dict(user.get("active_buffs") or {})
|
||||||
|
current = _parse_dt(buffs.get(kind))
|
||||||
|
extended = current is not None and current > _now()
|
||||||
|
start = current if extended else _now()
|
||||||
|
buffs[kind] = (start + timedelta(minutes=duration_min)).isoformat()
|
||||||
|
user["active_buffs"] = buffs
|
||||||
|
return extended
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /consumables purchase
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@_locked_by(0)
|
||||||
|
async def do_buy_consumable(user_id: int, cons_id: str) -> dict:
|
||||||
|
if cons_id not in CONSUMABLES:
|
||||||
|
return {"ok": False, "reason": "not_found"}
|
||||||
|
try:
|
||||||
|
user = await get_user(user_id)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
|
if user.get("eco_banned"):
|
||||||
|
return {"ok": False, "reason": "banned"}
|
||||||
|
|
||||||
|
cons = CONSUMABLES[cons_id]
|
||||||
|
if user["balance"] < cons["cost"]:
|
||||||
|
return {"ok": False, "reason": "insufficient", "need": cons["cost"] - user["balance"]}
|
||||||
|
|
||||||
|
user["balance"] -= cons["cost"]
|
||||||
|
|
||||||
|
extended = False
|
||||||
|
if cons["kind"] == "instant":
|
||||||
|
for field in _COOLDOWN_FIELDS:
|
||||||
|
user[field] = None
|
||||||
|
else:
|
||||||
|
extended = grant_buff(user, cons["kind"], cons["duration_min"])
|
||||||
|
|
||||||
|
await _commit(user_id, user)
|
||||||
|
_txn("BUY_CONSUMABLE", user=user_id, item=cons_id, cost=f"-{cons['cost']}", bal=user["balance"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"consumable": cons,
|
||||||
|
"balance": user["balance"],
|
||||||
|
"instant": cons["kind"] == "instant",
|
||||||
|
"extended": extended,
|
||||||
|
"remaining": None if cons["kind"] == "instant" else buff_remaining(user, cons["kind"]),
|
||||||
|
}
|
||||||
@@ -3,12 +3,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import random
|
import random
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from ..pb_client import DatabaseError
|
from ..pb_client import DatabaseError
|
||||||
from .store import (
|
from .store import (
|
||||||
COOLDOWNS, _cooldown_remaining, _commit, _is_jailed, _locked_by, _now,
|
_cooldown_remaining, _commit, _is_jailed, _locked_by, _now,
|
||||||
_prestige_mult, _txn, get_user,
|
_prestige_mult, _txn, effective_cooldown, get_user,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -81,7 +80,7 @@ async def do_fish_start(user_id: int) -> dict:
|
|||||||
if jail := _is_jailed(user):
|
if jail := _is_jailed(user):
|
||||||
return {"ok": False, "reason": "jailed", "remaining": jail}
|
return {"ok": False, "reason": "jailed", "remaining": jail}
|
||||||
|
|
||||||
fish_cd = timedelta(seconds=90) if "ussipurk" in user["items"] else COOLDOWNS["fish"]
|
fish_cd = effective_cooldown("fish", user["items"])
|
||||||
if cd := _cooldown_remaining(user, "fish", override_cd=fish_cd):
|
if cd := _cooldown_remaining(user, "fish", override_cd=fish_cd):
|
||||||
return {"ok": False, "reason": "cooldown", "remaining": cd}
|
return {"ok": False, "reason": "cooldown", "remaining": cd}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import random
|
|||||||
|
|
||||||
from ..pb_client import DatabaseError
|
from ..pb_client import DatabaseError
|
||||||
from ..emoji import EMOJI as E
|
from ..emoji import EMOJI as E
|
||||||
from .store import _commit, _is_jailed, _locked_by, _txn, get_user
|
from .store import (
|
||||||
|
_commit, _is_jailed, _locked_by, _log, _txn, add_pending_wager,
|
||||||
|
clear_pending_wager, get_user,
|
||||||
|
)
|
||||||
from .house import _credit_house
|
from .house import _credit_house
|
||||||
|
|
||||||
|
|
||||||
@@ -105,6 +108,7 @@ async def do_rps_pvp_deposit(user_id: int, bet: int) -> dict:
|
|||||||
return {"ok": False, "reason": "insufficient"}
|
return {"ok": False, "reason": "insufficient"}
|
||||||
user["balance"] -= bet
|
user["balance"] -= bet
|
||||||
user["total_wagered"] = user.get("total_wagered", 0) + bet
|
user["total_wagered"] = user.get("total_wagered", 0) + bet
|
||||||
|
add_pending_wager(user, "rps", bet) # escrow survives a restart
|
||||||
try:
|
try:
|
||||||
await _commit(user_id, user)
|
await _commit(user_id, user)
|
||||||
except DatabaseError:
|
except DatabaseError:
|
||||||
@@ -125,6 +129,7 @@ async def do_rps_pvp_payout(winner_id: int, bet: int) -> dict:
|
|||||||
user["lifetime_earned"] = user.get("lifetime_earned", 0) + bet
|
user["lifetime_earned"] = user.get("lifetime_earned", 0) + bet
|
||||||
user["biggest_win"] = max(user.get("biggest_win", 0), bet)
|
user["biggest_win"] = max(user.get("biggest_win", 0), bet)
|
||||||
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
|
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
|
||||||
|
clear_pending_wager(user) # winner's escrow settled
|
||||||
try:
|
try:
|
||||||
await _commit(winner_id, user)
|
await _commit(winner_id, user)
|
||||||
except DatabaseError:
|
except DatabaseError:
|
||||||
@@ -133,6 +138,23 @@ async def do_rps_pvp_payout(winner_id: int, bet: int) -> dict:
|
|||||||
return {"ok": True, "balance": user["balance"]}
|
return {"ok": True, "balance": user["balance"]}
|
||||||
|
|
||||||
|
|
||||||
|
@_locked_by(0)
|
||||||
|
async def do_rps_pvp_forfeit(loser_id: int) -> dict:
|
||||||
|
"""Release the loser's escrow marker without refunding - their stake was paid
|
||||||
|
to the winner as part of the 2*bet payout. Without this the loser's
|
||||||
|
pending_wager would linger and be wrongly refunded on the next restart."""
|
||||||
|
try:
|
||||||
|
user = await get_user(loser_id)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
|
clear_pending_wager(user)
|
||||||
|
try:
|
||||||
|
await _commit(loser_id, user)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@_locked_by(0)
|
@_locked_by(0)
|
||||||
async def do_rps_pvp_refund(user_id: int, bet: int) -> dict:
|
async def do_rps_pvp_refund(user_id: int, bet: int) -> dict:
|
||||||
"""Refund a previously escrowed bet (tie / timeout / cancel)."""
|
"""Refund a previously escrowed bet (tie / timeout / cancel)."""
|
||||||
@@ -142,6 +164,7 @@ async def do_rps_pvp_refund(user_id: int, bet: int) -> dict:
|
|||||||
return {"ok": False, "reason": "db_error"}
|
return {"ok": False, "reason": "db_error"}
|
||||||
user["balance"] = user.get("balance", 0) + bet
|
user["balance"] = user.get("balance", 0) + bet
|
||||||
user["total_wagered"] = max(0, user.get("total_wagered", 0) - bet)
|
user["total_wagered"] = max(0, user.get("total_wagered", 0) - bet)
|
||||||
|
clear_pending_wager(user) # escrow returned
|
||||||
try:
|
try:
|
||||||
await _commit(user_id, user)
|
await _commit(user_id, user)
|
||||||
except DatabaseError:
|
except DatabaseError:
|
||||||
@@ -248,14 +271,29 @@ async def do_blackjack_bet(user_id: int, bet: int) -> dict:
|
|||||||
if user["balance"] < bet:
|
if user["balance"] < bet:
|
||||||
return {"ok": False, "reason": "insufficient", "balance": user["balance"]}
|
return {"ok": False, "reason": "insufficient", "balance": user["balance"]}
|
||||||
user["balance"] -= bet
|
user["balance"] -= bet
|
||||||
|
# Escrow the stake in the same commit (accumulates across double/split), so a
|
||||||
|
# restart mid-hand refunds it via reconcile_pending_wagers instead of eating it.
|
||||||
|
add_pending_wager(user, "blackjack", bet)
|
||||||
|
try:
|
||||||
await _commit(user_id, user)
|
await _commit(user_id, user)
|
||||||
|
except DatabaseError:
|
||||||
|
# Deduction never persisted, so the player was not charged - report it
|
||||||
|
# instead of raising through the interaction handler.
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
return {"ok": True, "balance": user["balance"]}
|
return {"ok": True, "balance": user["balance"]}
|
||||||
|
|
||||||
|
|
||||||
@_locked_by(0)
|
@_locked_by(0)
|
||||||
async def do_blackjack_payout(user_id: int, payout: int, total_invested: int = 0) -> dict:
|
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."""
|
"""Credit the net payout. House receives the difference when payout < total_invested.
|
||||||
|
|
||||||
|
The stake was already deducted in do_blackjack_bet, so a DB failure here must
|
||||||
|
not raise through the interaction handler and swallow the player's winnings:
|
||||||
|
report db_error like every other mutation so the caller can surface it."""
|
||||||
|
try:
|
||||||
user = await get_user(user_id)
|
user = await get_user(user_id)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
user["balance"] += payout
|
user["balance"] += payout
|
||||||
user["balance"] = max(0, user["balance"])
|
user["balance"] = max(0, user["balance"])
|
||||||
user["total_wagered"] = user.get("total_wagered", 0) + total_invested
|
user["total_wagered"] = user.get("total_wagered", 0) + total_invested
|
||||||
@@ -267,7 +305,18 @@ async def do_blackjack_payout(user_id: int, payout: int, total_invested: int = 0
|
|||||||
elif net < 0:
|
elif net < 0:
|
||||||
user["lifetime_lost"] = user.get("lifetime_lost", 0) + abs(net)
|
user["lifetime_lost"] = user.get("lifetime_lost", 0) + abs(net)
|
||||||
user["biggest_loss"] = max(user.get("biggest_loss", 0), abs(net))
|
user["biggest_loss"] = max(user.get("biggest_loss", 0), abs(net))
|
||||||
|
clear_pending_wager(user) # hand settled - release the escrow marker
|
||||||
|
try:
|
||||||
await _commit(user_id, user)
|
await _commit(user_id, user)
|
||||||
|
except DatabaseError:
|
||||||
|
# The stake is already gone (deducted in do_blackjack_bet) and this credit
|
||||||
|
# did not persist. Nothing to roll back locally - log the owed amount so an
|
||||||
|
# admin can reconcile with /admincoins.
|
||||||
|
_log.critical(
|
||||||
|
"blackjack payout commit failed for %s: owed payout=%s (invested=%s)",
|
||||||
|
user_id, payout, total_invested,
|
||||||
|
)
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
house_gain = total_invested - payout
|
house_gain = total_invested - payout
|
||||||
if house_gain > 0:
|
if house_gain > 0:
|
||||||
await _credit_house(house_gain)
|
await _credit_house(house_gain)
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import random
|
|||||||
from .. import pb_client
|
from .. import pb_client
|
||||||
from ..pb_client import DatabaseError
|
from ..pb_client import DatabaseError
|
||||||
from . import house
|
from . import house
|
||||||
from .store import HEIST_JAIL, _commit, _now, _txn, _user_lock, get_user
|
from .store import HEIST_JAIL, _commit, _is_jailed, _now, _txn, _user_lock, get_user
|
||||||
from .house import _credit_house, _refund_house_safe, _refund_user_safe
|
from .house import _credit_house, _refund_house_safe, _debit_house_safe
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -39,19 +39,25 @@ async def do_heist_resolve(user_ids: list[int], success: bool) -> dict:
|
|||||||
failed_users: list[int] = []
|
failed_users: list[int] = []
|
||||||
|
|
||||||
if success and house.HOUSE_ID is not None:
|
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:
|
try:
|
||||||
house = await get_user(house.HOUSE_ID)
|
house_rec = await get_user(house.HOUSE_ID)
|
||||||
pct = random.uniform(0.20, 0.55)
|
pct = random.uniform(0.20, 0.55)
|
||||||
total = max(300, int(house["balance"] * pct))
|
# Never promise more than the house actually holds: the desired pot
|
||||||
payout_each = total // len(user_ids)
|
# is capped at the current balance, and each share is floored, so the
|
||||||
# Atomic decrement (capped at the balance we read) instead of a full
|
# amount debited equals the amount paid out (no minting, no leak).
|
||||||
# record commit, so concurrent _credit_house increments aren't lost.
|
pot = min(max(300, int(house_rec["balance"] * pct)), house_rec["balance"])
|
||||||
debit = min(total, house["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:
|
if debit > 0:
|
||||||
await pb_client.update_record(house["_pb_id"], {"balance-": debit}) # type: ignore[typeddict-item]
|
await pb_client.update_record(house_rec["_pb_id"], {"balance-": debit}) # type: ignore[typeddict-item]
|
||||||
except DatabaseError:
|
except DatabaseError:
|
||||||
return {"ok": False, "reason": "db_error"}
|
return {"ok": False, "reason": "db_error"}
|
||||||
_txn("HEIST_HOUSE", change=f"-{debit}", house_bal=house["balance"] - debit)
|
_txn("HEIST_HOUSE", change=f"-{debit}", house_bal=house_rec["balance"] - debit)
|
||||||
|
|
||||||
for uid in user_ids:
|
for uid in user_ids:
|
||||||
async with _user_lock(uid):
|
async with _user_lock(uid):
|
||||||
@@ -92,6 +98,6 @@ async def do_heist_resolve(user_ids: list[int], success: bool) -> dict:
|
|||||||
if success and payout_each > 0:
|
if success and payout_each > 0:
|
||||||
await _refund_house_safe(payout_each, "heist_win_compensate", uid)
|
await _refund_house_safe(payout_each, "heist_win_compensate", uid)
|
||||||
elif not success and fine_credited:
|
elif not success and fine_credited:
|
||||||
await _refund_user_safe(house.HOUSE_ID, fine if 'fine' in locals() else 0, "heist_fail_compensate", uid)
|
await _debit_house_safe(fine if 'fine' in locals() else 0, "heist_fail_compensate", uid)
|
||||||
|
|
||||||
return {"ok": True, "payout_each": payout_each, "success": success, "failed_users": failed_users}
|
return {"ok": True, "payout_each": payout_each, "success": success, "failed_users": failed_users}
|
||||||
|
|||||||
@@ -75,9 +75,10 @@ async def _refund_house_safe(amount: int, context: str, related_uid: int) -> Non
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _refund_user_safe(_unused_house_id, amount: int, context: str, uid: int) -> None:
|
async def _debit_house_safe(amount: int, context: str, uid: int) -> None:
|
||||||
"""Best-effort atomic debit of `amount` from the house (compensates a failed
|
"""Best-effort atomic debit of `amount` from the house. Compensates a fine
|
||||||
user fine). Logs critical if it fails."""
|
that was credited to the house but whose matching user debit failed to persist
|
||||||
|
(the coins must be pulled back out of the house). Logs critical if it fails."""
|
||||||
if HOUSE_ID is None or amount <= 0:
|
if HOUSE_ID is None or amount <= 0:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -3,17 +3,19 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import random
|
import random
|
||||||
from datetime import date, timedelta
|
from datetime import date
|
||||||
|
|
||||||
import strings
|
import strings
|
||||||
|
|
||||||
from ..pb_client import DatabaseError
|
from ..pb_client import DatabaseError
|
||||||
from . import house
|
from . import house
|
||||||
from .store import (
|
from .store import (
|
||||||
COOLDOWNS, JAIL_DURATION, PRESTIGE_SHOP, _commit, _cooldown_remaining,
|
JAIL_DURATION, PRESTIGE_SHOP, _commit, _cooldown_remaining,
|
||||||
_is_jailed, _locked_by, _log, _now, _prestige_mult, _txn, get_user,
|
_is_jailed, _locked_by, _log, _now, _prestige_mult, _txn, effective_cooldown,
|
||||||
|
get_user,
|
||||||
)
|
)
|
||||||
from .house import _credit_house
|
from .house import _credit_house
|
||||||
|
from .consumables import earn_mult
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -28,7 +30,7 @@ async def do_daily(user_id: int) -> dict:
|
|||||||
if user.get("eco_banned"):
|
if user.get("eco_banned"):
|
||||||
return {"ok": False, "reason": "banned"}
|
return {"ok": False, "reason": "banned"}
|
||||||
|
|
||||||
daily_cd = timedelta(hours=18) if "korvaklapid" in user["items"] else COOLDOWNS["daily"]
|
daily_cd = effective_cooldown("daily", user["items"])
|
||||||
if cd := _cooldown_remaining(user, "daily", override_cd=daily_cd):
|
if cd := _cooldown_remaining(user, "daily", override_cd=daily_cd):
|
||||||
return {"ok": False, "reason": "cooldown", "remaining": cd}
|
return {"ok": False, "reason": "cooldown", "remaining": cd}
|
||||||
|
|
||||||
@@ -108,7 +110,7 @@ async def do_work(user_id: int) -> dict:
|
|||||||
if user.get("eco_banned"):
|
if user.get("eco_banned"):
|
||||||
return {"ok": False, "reason": "banned"}
|
return {"ok": False, "reason": "banned"}
|
||||||
|
|
||||||
work_cd = timedelta(minutes=40) if "monitor" in user["items"] else COOLDOWNS["work"]
|
work_cd = effective_cooldown("work", user["items"])
|
||||||
if cd := _cooldown_remaining(user, "work", override_cd=work_cd):
|
if cd := _cooldown_remaining(user, "work", override_cd=work_cd):
|
||||||
return {"ok": False, "reason": "cooldown", "remaining": cd}
|
return {"ok": False, "reason": "cooldown", "remaining": cd}
|
||||||
if jail := _is_jailed(user):
|
if jail := _is_jailed(user):
|
||||||
@@ -126,7 +128,7 @@ async def do_work(user_id: int) -> dict:
|
|||||||
work_plus_level = (user.get("prestige_upgrades") or {}).get("work_plus", 0)
|
work_plus_level = (user.get("prestige_upgrades") or {}).get("work_plus", 0)
|
||||||
work_plus_mult = 1.0 + work_plus_level * PRESTIGE_SHOP["work_plus"]["effect"]
|
work_plus_mult = 1.0 + work_plus_level * PRESTIGE_SHOP["work_plus"]["effect"]
|
||||||
coin_mult, _ = _prestige_mult(user)
|
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)
|
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["balance"] += earned
|
||||||
user["last_work"] = _now().isoformat()
|
user["last_work"] = _now().isoformat()
|
||||||
user["work_count"] = user.get("work_count", 0) + 1
|
user["work_count"] = user.get("work_count", 0) + 1
|
||||||
@@ -162,14 +164,14 @@ async def do_beg(user_id: int) -> dict:
|
|||||||
if user.get("eco_banned"):
|
if user.get("eco_banned"):
|
||||||
return {"ok": False, "reason": "banned"}
|
return {"ok": False, "reason": "banned"}
|
||||||
|
|
||||||
beg_cd = timedelta(minutes=3) if "hiirematt" in user["items"] else COOLDOWNS["beg"]
|
beg_cd = effective_cooldown("beg", user["items"])
|
||||||
if cd := _cooldown_remaining(user, "beg", override_cd=beg_cd):
|
if cd := _cooldown_remaining(user, "beg", override_cd=beg_cd):
|
||||||
return {"ok": False, "reason": "cooldown", "remaining": cd}
|
return {"ok": False, "reason": "cooldown", "remaining": cd}
|
||||||
|
|
||||||
jailed = bool(_is_jailed(user))
|
jailed = bool(_is_jailed(user))
|
||||||
beg_mult = 2 if "klaviatuur" in user["items"] else 1
|
beg_mult = 2 if "klaviatuur" in user["items"] else 1
|
||||||
coin_mult, _ = _prestige_mult(user)
|
coin_mult, _ = _prestige_mult(user)
|
||||||
earned = int(random.randint(10, 40) * beg_mult * coin_mult)
|
earned = int(random.randint(10, 40) * beg_mult * coin_mult * earn_mult(user))
|
||||||
user["balance"] += earned
|
user["balance"] += earned
|
||||||
user["last_beg"] = _now().isoformat()
|
user["last_beg"] = _now().isoformat()
|
||||||
user["beg_count"] = user.get("beg_count", 0) + 1
|
user["beg_count"] = user.get("beg_count", 0) + 1
|
||||||
@@ -217,6 +219,7 @@ async def do_crime(user_id: int) -> dict:
|
|||||||
earned = random.randint(200, 500)
|
earned = random.randint(200, 500)
|
||||||
if "mikrofon" in user["items"]:
|
if "mikrofon" in user["items"]:
|
||||||
earned = int(earned * 1.3)
|
earned = int(earned * 1.3)
|
||||||
|
earned = int(earned * earn_mult(user))
|
||||||
user["balance"] += earned
|
user["balance"] += earned
|
||||||
user["crimes_succeeded"] = user.get("crimes_succeeded", 0) + 1
|
user["crimes_succeeded"] = user.get("crimes_succeeded", 0) + 1
|
||||||
user["lifetime_earned"] = user.get("lifetime_earned", 0) + earned
|
user["lifetime_earned"] = user.get("lifetime_earned", 0) + earned
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ async def do_bail(user_id: int) -> dict:
|
|||||||
"""Charge bail fine after exhausting jailbreak rolls and free the user.
|
"""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."""
|
Fine = 20-30% of current balance, floored at 350. If balance < 350, stay jailed."""
|
||||||
user = await get_user(user_id)
|
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:
|
if user["balance"] < MIN_BAIL:
|
||||||
return {"ok": False, "reason": "broke", "balance": user["balance"]}
|
return {"ok": False, "reason": "broke", "balance": user["balance"]}
|
||||||
pct = random.uniform(0.20, 0.30)
|
pct = random.uniform(0.20, 0.30)
|
||||||
|
|||||||
@@ -3,14 +3,21 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .. import pb_client
|
from .. import pb_client
|
||||||
|
from . import house
|
||||||
from .levels import get_level
|
from .levels import get_level
|
||||||
|
|
||||||
|
|
||||||
|
def _net_worth(r: dict) -> int:
|
||||||
|
"""Coins that count toward wealth: liquid balance + banked vault."""
|
||||||
|
return (r.get("balance", 0) or 0) + (r.get("bank_balance", 0) or 0)
|
||||||
|
|
||||||
|
|
||||||
async def get_leaderboard(top_n: int | None = 10) -> list[tuple[str, int]]:
|
async def get_leaderboard(top_n: int | None = 10) -> list[tuple[str, int]]:
|
||||||
"""Return top_n (user_id_str, balance) pairs sorted descending."""
|
"""Return top_n (user_id_str, net_worth) pairs sorted descending.
|
||||||
|
Net worth = balance + bank_balance, so banking coins does not hide them."""
|
||||||
records = await pb_client.list_all_records()
|
records = await pb_client.list_all_records()
|
||||||
result = sorted(
|
result = sorted(
|
||||||
((r["user_id"], r.get("balance", 0)) for r in records if r.get("user_id")),
|
((r["user_id"], _net_worth(r)) for r in records if r.get("user_id")),
|
||||||
key=lambda x: x[1],
|
key=lambda x: x[1],
|
||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
@@ -80,3 +87,60 @@ async def get_leaderboard_fish(top_n: int | None = 10) -> list[tuple[str, int]]:
|
|||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
return result if top_n is None else result[:top_n]
|
return result if top_n is None else result[:top_n]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_all_leaderboards() -> dict[str, list[tuple]]:
|
||||||
|
"""Build every leaderboard view from a SINGLE collection scan.
|
||||||
|
|
||||||
|
/leaderboard shows six tabs; calling each get_leaderboard_* separately would
|
||||||
|
read the whole collection six times. This reads once and sorts in memory,
|
||||||
|
returning the same tuple shapes the individual functions produce (unbounded -
|
||||||
|
the command paginates)."""
|
||||||
|
records = await pb_client.list_all_records()
|
||||||
|
users = [r for r in records if r.get("user_id")]
|
||||||
|
|
||||||
|
def desc(keyfn) -> list[dict]:
|
||||||
|
return sorted(users, key=keyfn, reverse=True)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"coins": [(r["user_id"], _net_worth(r))
|
||||||
|
for r in desc(_net_worth)],
|
||||||
|
"exp": [(r["user_id"], r.get("exp", 0), get_level(r.get("exp", 0)))
|
||||||
|
for r in desc(lambda r: r.get("exp", 0))],
|
||||||
|
"season": [(r["user_id"], r.get("season_total_exp", 0), r.get("prestige_level", 0))
|
||||||
|
for r in desc(lambda r: r.get("season_total_exp", 0))],
|
||||||
|
"prestige": [(r["user_id"], r.get("prestige_level", 0), r.get("prestige_points", 0))
|
||||||
|
for r in desc(lambda r: (r.get("prestige_level", 0), r.get("prestige_points", 0)))],
|
||||||
|
"wagered": [(r["user_id"], r.get("total_wagered", 0))
|
||||||
|
for r in desc(lambda r: r.get("total_wagered", 0))],
|
||||||
|
"fish": [(r["user_id"], r.get("total_fish_caught", 0))
|
||||||
|
for r in desc(lambda r: r.get("total_fish_caught", 0))],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_economy_stats() -> dict[str, int]:
|
||||||
|
"""Money-supply snapshot from a single scan: total coins in circulation, how
|
||||||
|
much players hold vs. the house. Lets /status show whether the sinks (house,
|
||||||
|
vanity burn, bail, fines) are keeping pace with minted income."""
|
||||||
|
records = await pb_client.list_all_records()
|
||||||
|
house_id = str(house.HOUSE_ID) if house.HOUSE_ID is not None else None
|
||||||
|
total = 0
|
||||||
|
house_balance = 0
|
||||||
|
player_count = 0
|
||||||
|
for r in records:
|
||||||
|
uid = r.get("user_id")
|
||||||
|
if not uid:
|
||||||
|
continue
|
||||||
|
# Banked coins are still part of the money supply.
|
||||||
|
worth = (r.get("balance", 0) or 0) + (r.get("bank_balance", 0) or 0)
|
||||||
|
total += worth
|
||||||
|
if uid == house_id:
|
||||||
|
house_balance = worth
|
||||||
|
else:
|
||||||
|
player_count += 1
|
||||||
|
return {
|
||||||
|
"total_coins": total,
|
||||||
|
"house_balance": house_balance,
|
||||||
|
"player_coins": total - house_balance,
|
||||||
|
"player_count": player_count,
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import math
|
import math
|
||||||
|
|
||||||
from .store import _locked_by, _prestige_mult, get_user, _commit
|
from .store import _locked_by, _prestige_mult, get_user, _commit
|
||||||
|
from .consumables import exp_buff_mult
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -69,7 +70,7 @@ 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."""
|
"""Add EXP to a user. Applies prestige exp_mult. Returns old_level, new_level, total exp."""
|
||||||
user = await get_user(user_id)
|
user = await get_user(user_id)
|
||||||
_, exp_mult = _prestige_mult(user)
|
_, exp_mult = _prestige_mult(user)
|
||||||
gained = max(1, int(amount * exp_mult))
|
gained = max(1, int(amount * exp_mult * exp_buff_mult(user)))
|
||||||
old_exp = user.get("exp", 0)
|
old_exp = user.get("exp", 0)
|
||||||
new_exp = old_exp + gained
|
new_exp = old_exp + gained
|
||||||
old_level = get_level(old_exp)
|
old_level = get_level(old_exp)
|
||||||
|
|||||||
91
core/economy/lootbox.py
Normal file
91
core/economy/lootbox.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"""Mystery box (/lootbox): a pay-to-open coin sink with a weighted reward.
|
||||||
|
|
||||||
|
Buying always burns LOOTBOX_COST; the reward is usually worth less than the cost
|
||||||
|
(a deliberate sink, like consumables/vanity) but occasionally pays out big or
|
||||||
|
grants a timed buff. All randomness lives in do_open_lootbox so it stays a single,
|
||||||
|
testable core function; the command layer only renders the result.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
|
||||||
|
from ..pb_client import DatabaseError
|
||||||
|
from .store import _commit, _locked_by, _txn, get_user
|
||||||
|
from .consumables import grant_buff
|
||||||
|
|
||||||
|
__all__ = ["LOOTBOX_COST", "do_open_lootbox"]
|
||||||
|
|
||||||
|
LOOTBOX_COST = 1_000
|
||||||
|
_BUFF_DURATION_MIN = 30
|
||||||
|
|
||||||
|
# (weight, outcome_key). Weights need not sum to 100. Coin outcomes roll an
|
||||||
|
# amount in the ranges below; "buff" grants a random 30-min earn/exp boost.
|
||||||
|
_OUTCOMES: list[tuple[int, str]] = [
|
||||||
|
(42, "coins_small"), # usually a net loss - the sink
|
||||||
|
(28, "coins_medium"),
|
||||||
|
(15, "buff"),
|
||||||
|
(10, "coins_big"),
|
||||||
|
(5, "jackpot"),
|
||||||
|
]
|
||||||
|
|
||||||
|
_COIN_RANGES: dict[str, tuple[int, int]] = {
|
||||||
|
"coins_small": (50, 500),
|
||||||
|
"coins_medium": (500, 1_200),
|
||||||
|
"coins_big": (1_200, 2_500),
|
||||||
|
"jackpot": (4_000, 9_000),
|
||||||
|
}
|
||||||
|
|
||||||
|
_BUFF_KINDS = ("earn", "exp")
|
||||||
|
|
||||||
|
|
||||||
|
@_locked_by(0)
|
||||||
|
async def do_open_lootbox(user_id: int) -> dict:
|
||||||
|
"""Charge LOOTBOX_COST and grant one weighted reward. Returns the outcome."""
|
||||||
|
try:
|
||||||
|
user = await get_user(user_id)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
|
if user.get("eco_banned"):
|
||||||
|
return {"ok": False, "reason": "banned"}
|
||||||
|
if user["balance"] < LOOTBOX_COST:
|
||||||
|
return {"ok": False, "reason": "insufficient", "need": LOOTBOX_COST - user["balance"]}
|
||||||
|
|
||||||
|
user["balance"] -= LOOTBOX_COST
|
||||||
|
|
||||||
|
outcome = random.choices(
|
||||||
|
[k for _, k in _OUTCOMES], weights=[w for w, _ in _OUTCOMES], k=1
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
reward_coins = 0
|
||||||
|
buff_kind = None
|
||||||
|
if outcome == "buff":
|
||||||
|
buff_kind = random.choice(_BUFF_KINDS)
|
||||||
|
grant_buff(user, buff_kind, _BUFF_DURATION_MIN)
|
||||||
|
else:
|
||||||
|
lo, hi = _COIN_RANGES[outcome]
|
||||||
|
reward_coins = random.randint(lo, hi)
|
||||||
|
user["balance"] += reward_coins
|
||||||
|
user["lifetime_earned"] = user.get("lifetime_earned", 0) + reward_coins
|
||||||
|
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
|
||||||
|
if outcome == "jackpot":
|
||||||
|
user["biggest_win"] = max(user.get("biggest_win", 0), reward_coins)
|
||||||
|
|
||||||
|
net = reward_coins - LOOTBOX_COST
|
||||||
|
user["lootboxes_opened"] = user.get("lootboxes_opened", 0) + 1
|
||||||
|
await _commit(user_id, user)
|
||||||
|
_txn(
|
||||||
|
"LOOTBOX", user=user_id, outcome=outcome,
|
||||||
|
reward=f"+{reward_coins}" if reward_coins else (buff_kind or "-"),
|
||||||
|
net=f"{net:+}", bal=user["balance"],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"outcome": outcome,
|
||||||
|
"reward_coins": reward_coins,
|
||||||
|
"buff_kind": buff_kind,
|
||||||
|
"buff_min": _BUFF_DURATION_MIN if buff_kind else 0,
|
||||||
|
"net": net,
|
||||||
|
"balance": user["balance"],
|
||||||
|
}
|
||||||
149
core/economy/lottery.py
Normal file
149
core/economy/lottery.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
"""Daily lottery: buy tickets, one weighted winner takes the whole pot.
|
||||||
|
|
||||||
|
Coin flow is conserved without any shared pot record: each ticket's cost is
|
||||||
|
deducted from the buyer at purchase, and at draw time the winner is credited
|
||||||
|
exactly the sum of every ticket's cost (tickets * TICKET_COST). More tickets =
|
||||||
|
higher win chance (weighted draw). Ticket state lives on each user's own record
|
||||||
|
keyed by the draw period, so a full scan is only needed at draw time and for the
|
||||||
|
/lottery pot view - never on the hot path.
|
||||||
|
|
||||||
|
The period is a draw-date ISO string computed by the caller (Tallinn-time aware);
|
||||||
|
core functions take it explicitly so they stay timezone-agnostic and testable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from .. import pb_client
|
||||||
|
from ..pb_client import DatabaseError
|
||||||
|
from .store import _commit, _txn, _user_lock, get_user
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"TICKET_COST",
|
||||||
|
"MAX_TICKETS_PER_DRAW",
|
||||||
|
"DRAW_HOUR",
|
||||||
|
"period_for",
|
||||||
|
"do_buy_ticket",
|
||||||
|
"do_lottery_draw",
|
||||||
|
"get_lottery_state",
|
||||||
|
]
|
||||||
|
|
||||||
|
TICKET_COST = 200
|
||||||
|
MAX_TICKETS_PER_DRAW = 100 # per-user cap so one whale can't guarantee a win
|
||||||
|
DRAW_HOUR = 21 # Tallinn-time hour the daily draw fires
|
||||||
|
|
||||||
|
|
||||||
|
def period_for(now_local) -> str:
|
||||||
|
"""Draw-date (ISO) that tickets bought at `now_local` (a tz-aware local
|
||||||
|
datetime) count toward: today before DRAW_HOUR, else tomorrow (today's draw
|
||||||
|
has already fired). The draw loop itself draws for `now_local.date()`."""
|
||||||
|
d = now_local.date()
|
||||||
|
if now_local.hour >= DRAW_HOUR:
|
||||||
|
d = d + timedelta(days=1)
|
||||||
|
return d.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
async def do_buy_ticket(user_id: int, count: int, period: str) -> dict:
|
||||||
|
"""Buy `count` tickets for the draw on `period`. Deducts count*TICKET_COST."""
|
||||||
|
if count <= 0:
|
||||||
|
return {"ok": False, "reason": "invalid"}
|
||||||
|
async with _user_lock(user_id):
|
||||||
|
try:
|
||||||
|
user = await get_user(user_id)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
|
if user.get("eco_banned"):
|
||||||
|
return {"ok": False, "reason": "banned"}
|
||||||
|
# A new period resets any tickets left over from a previous draw.
|
||||||
|
held = user.get("lottery_tickets", 0) if user.get("lottery_period") == period else 0
|
||||||
|
if held + count > MAX_TICKETS_PER_DRAW:
|
||||||
|
return {"ok": False, "reason": "max_tickets", "held": held, "cap": MAX_TICKETS_PER_DRAW}
|
||||||
|
cost = count * TICKET_COST
|
||||||
|
if user["balance"] < cost:
|
||||||
|
return {"ok": False, "reason": "insufficient", "need": cost - user["balance"]}
|
||||||
|
user["balance"] -= cost
|
||||||
|
user["lottery_tickets"] = held + count
|
||||||
|
user["lottery_period"] = period
|
||||||
|
try:
|
||||||
|
await _commit(user_id, user)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
|
_txn("LOTTERY_BUY", user=user_id, tickets=count, period=period, cost=f"-{cost}", bal=user["balance"])
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"bought": count,
|
||||||
|
"tickets": user["lottery_tickets"],
|
||||||
|
"cost": cost,
|
||||||
|
"balance": user["balance"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _participants(records: list[dict], period: str) -> list[tuple[str, int]]:
|
||||||
|
"""(user_id, tickets) for everyone holding tickets for `period`."""
|
||||||
|
out = []
|
||||||
|
for r in records:
|
||||||
|
uid = r.get("user_id")
|
||||||
|
if uid and r.get("lottery_period") == period and (r.get("lottery_tickets", 0) or 0) > 0:
|
||||||
|
out.append((uid, int(r["lottery_tickets"])))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def get_lottery_state(period: str, user_id: int | None = None) -> dict:
|
||||||
|
"""Pot / participant snapshot for the /lottery view."""
|
||||||
|
records = await pb_client.list_all_records()
|
||||||
|
parts = _participants(records, period)
|
||||||
|
total_tickets = sum(t for _, t in parts)
|
||||||
|
your_tickets = 0
|
||||||
|
if user_id is not None:
|
||||||
|
your_tickets = next((t for uid, t in parts if uid == str(user_id)), 0)
|
||||||
|
return {
|
||||||
|
"pot": total_tickets * TICKET_COST,
|
||||||
|
"total_tickets": total_tickets,
|
||||||
|
"participants": len(parts),
|
||||||
|
"your_tickets": your_tickets,
|
||||||
|
"ticket_cost": TICKET_COST,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def do_lottery_draw(period: str) -> dict | None:
|
||||||
|
"""Draw the winner for `period` and credit them the whole pot (minted, since
|
||||||
|
ticket costs were burned at purchase - net conserved). Returns the result, or
|
||||||
|
None if nobody entered."""
|
||||||
|
records = await pb_client.list_all_records()
|
||||||
|
parts = _participants(records, period)
|
||||||
|
if not parts:
|
||||||
|
return None
|
||||||
|
total_tickets = sum(t for _, t in parts)
|
||||||
|
pot = total_tickets * TICKET_COST
|
||||||
|
winner_id = int(random.choices(
|
||||||
|
[uid for uid, _ in parts], weights=[t for _, t in parts], k=1
|
||||||
|
)[0])
|
||||||
|
winner_tickets = next(t for uid, t in parts if uid == str(winner_id))
|
||||||
|
|
||||||
|
async with _user_lock(winner_id):
|
||||||
|
try:
|
||||||
|
winner = await get_user(winner_id)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error", "winner_id": winner_id, "pot": pot}
|
||||||
|
winner["balance"] += pot
|
||||||
|
winner["lifetime_earned"] = winner.get("lifetime_earned", 0) + pot
|
||||||
|
winner["biggest_win"] = max(winner.get("biggest_win", 0), pot)
|
||||||
|
winner["peak_balance"] = max(winner.get("peak_balance", 0), winner["balance"])
|
||||||
|
winner["lottery_tickets"] = 0 # consumed
|
||||||
|
try:
|
||||||
|
await _commit(winner_id, winner)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error", "winner_id": winner_id, "pot": pot}
|
||||||
|
_txn("LOTTERY_DRAW", winner=winner_id, period=period, pot=f"+{pot}",
|
||||||
|
tickets=winner_tickets, total_tickets=total_tickets, players=len(parts))
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"winner_id": winner_id,
|
||||||
|
"pot": pot,
|
||||||
|
"winner_tickets": winner_tickets,
|
||||||
|
"total_tickets": total_tickets,
|
||||||
|
"participants": len(parts),
|
||||||
|
"win_chance": winner_tickets / total_tickets,
|
||||||
|
}
|
||||||
@@ -87,6 +87,26 @@ COOLDOWNS: dict[str, timedelta] = {
|
|||||||
"fish": timedelta(minutes=2),
|
"fish": timedelta(minutes=2),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Items that shorten a command's cooldown: command -> (item_id, reduced cooldown).
|
||||||
|
# Single source of truth so the cooldown check (do_*), the reminder scheduler
|
||||||
|
# (_maybe_remind) and the restart restore (_restore_reminders) never drift apart.
|
||||||
|
ITEM_COOLDOWNS: dict[str, tuple[str, timedelta]] = {
|
||||||
|
"work": ("monitor", timedelta(minutes=40)),
|
||||||
|
"beg": ("hiirematt", timedelta(minutes=3)),
|
||||||
|
"daily": ("korvaklapid", timedelta(hours=18)),
|
||||||
|
"fish": ("ussipurk", timedelta(seconds=90)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def effective_cooldown(cmd: str, items) -> timedelta | None:
|
||||||
|
"""The cooldown for `cmd` given the user's owned `items`, applying any
|
||||||
|
item-based reduction. Returns None for commands with no cooldown."""
|
||||||
|
override = ITEM_COOLDOWNS.get(cmd)
|
||||||
|
if override is not None and override[0] in items:
|
||||||
|
return override[1]
|
||||||
|
return COOLDOWNS.get(cmd)
|
||||||
|
|
||||||
|
|
||||||
JAIL_DURATION = timedelta(minutes=30)
|
JAIL_DURATION = timedelta(minutes=30)
|
||||||
HEIST_JAIL = timedelta(hours=1, minutes=30)
|
HEIST_JAIL = timedelta(hours=1, minutes=30)
|
||||||
|
|
||||||
@@ -94,7 +114,8 @@ HEIST_JAIL = timedelta(hours=1, minutes=30)
|
|||||||
# User schema
|
# User schema
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
class UserData(TypedDict, total=False):
|
class UserData(TypedDict, total=False):
|
||||||
balance: int
|
balance: int # liquid coins - spendable, gamblable, robbable
|
||||||
|
bank_balance: int # vaulted coins - safe from /rob and /heist, not spendable until withdrawn
|
||||||
exp: int # lifetime EXP (resets each season)
|
exp: int # lifetime EXP (resets each season)
|
||||||
last_daily: str | None
|
last_daily: str | None
|
||||||
last_work: str | None
|
last_work: str | None
|
||||||
@@ -106,6 +127,9 @@ class UserData(TypedDict, total=False):
|
|||||||
last_streak_date: str | None # ISO date "YYYY-MM-DD"
|
last_streak_date: str | None # ISO date "YYYY-MM-DD"
|
||||||
items: list[str]
|
items: list[str]
|
||||||
item_uses: dict # {item_id: remaining_uses} for consumables
|
item_uses: dict # {item_id: remaining_uses} for consumables
|
||||||
|
active_buffs: dict # {buff_kind: expiry_iso} for timed consumable boosts
|
||||||
|
vanity_owned: list[str] # cosmetic badge ids the user has purchased
|
||||||
|
vanity_active: str | None # currently-equipped vanity badge id (shown on /profile)
|
||||||
jailed_until: str | None # ISO datetime or None
|
jailed_until: str | None # ISO datetime or None
|
||||||
jailbreak_used: bool
|
jailbreak_used: bool
|
||||||
reminders: list[str] # command names user wants DM reminders for
|
reminders: list[str] # command names user wants DM reminders for
|
||||||
@@ -129,6 +153,10 @@ class UserData(TypedDict, total=False):
|
|||||||
total_given: int
|
total_given: int
|
||||||
total_received: int
|
total_received: int
|
||||||
best_daily_streak: int
|
best_daily_streak: int
|
||||||
|
lootboxes_opened: int
|
||||||
|
achievements_earned: list # ids of achievements already claimed
|
||||||
|
lottery_tickets: int # tickets held for the current lottery period
|
||||||
|
lottery_period: str | None # draw-date the held tickets count for (ISO date)
|
||||||
heist_global_cd_until: float
|
heist_global_cd_until: float
|
||||||
# Prestige system
|
# Prestige system
|
||||||
prestige_level: int
|
prestige_level: int
|
||||||
@@ -143,11 +171,17 @@ class UserData(TypedDict, total=False):
|
|||||||
# Quest system
|
# Quest system
|
||||||
quest_daily: dict # {"date": "YYYY-MM-DD", "quests": {qid: {snap, claimed}}}
|
quest_daily: dict # {"date": "YYYY-MM-DD", "quests": {qid: {snap, claimed}}}
|
||||||
quest_weekly: dict # {"week": "YYYY-Www", "quests": {qid: {snap, claimed}}}
|
quest_weekly: dict # {"week": "YYYY-Www", "quests": {qid: {snap, claimed}}}
|
||||||
|
# Coins a running interactive game (blackjack/RPS PvP) has deducted but not
|
||||||
|
# yet settled. {"kind": ..., "amount": int, "ts": iso} while escrowed, {}
|
||||||
|
# otherwise. Reconciled (refunded) on startup so a restart mid-game never
|
||||||
|
# eats the stake. See reconcile_pending_wagers.
|
||||||
|
pending_wager: dict
|
||||||
|
|
||||||
|
|
||||||
def _default_user() -> UserData:
|
def _default_user() -> UserData:
|
||||||
return {
|
return {
|
||||||
"balance": 0,
|
"balance": 0,
|
||||||
|
"bank_balance": 0,
|
||||||
"exp": 0,
|
"exp": 0,
|
||||||
"last_daily": None,
|
"last_daily": None,
|
||||||
"last_work": None,
|
"last_work": None,
|
||||||
@@ -159,6 +193,9 @@ def _default_user() -> UserData:
|
|||||||
"last_streak_date": None,
|
"last_streak_date": None,
|
||||||
"items": [],
|
"items": [],
|
||||||
"item_uses": {},
|
"item_uses": {},
|
||||||
|
"active_buffs": {},
|
||||||
|
"vanity_owned": [],
|
||||||
|
"vanity_active": None,
|
||||||
"jailed_until": None,
|
"jailed_until": None,
|
||||||
"jailbreak_used": False,
|
"jailbreak_used": False,
|
||||||
"reminders": ["daily", "work", "beg", "crime", "rob"],
|
"reminders": ["daily", "work", "beg", "crime", "rob"],
|
||||||
@@ -182,6 +219,10 @@ def _default_user() -> UserData:
|
|||||||
"total_given": 0,
|
"total_given": 0,
|
||||||
"total_received": 0,
|
"total_received": 0,
|
||||||
"best_daily_streak": 0,
|
"best_daily_streak": 0,
|
||||||
|
"lootboxes_opened": 0,
|
||||||
|
"achievements_earned": [],
|
||||||
|
"lottery_tickets": 0,
|
||||||
|
"lottery_period": None,
|
||||||
"heist_global_cd_until": 0.0,
|
"heist_global_cd_until": 0.0,
|
||||||
# ── Prestige ─────────────────────────────────────────────────────────
|
# ── Prestige ─────────────────────────────────────────────────────────
|
||||||
"prestige_level": 0,
|
"prestige_level": 0,
|
||||||
@@ -196,6 +237,8 @@ def _default_user() -> UserData:
|
|||||||
# ── Quests ───────────────────────────────────────────────────────────
|
# ── Quests ───────────────────────────────────────────────────────────
|
||||||
"quest_daily": {},
|
"quest_daily": {},
|
||||||
"quest_weekly": {},
|
"quest_weekly": {},
|
||||||
|
# ── Interactive-game escrow (blackjack / RPS PvP) ────────────────────
|
||||||
|
"pending_wager": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -373,6 +416,59 @@ async def _commit(user_id: int, user: UserData) -> dict | None:
|
|||||||
raise DatabaseError(f"Failed to persist user {user_id}: {exc}") from exc
|
raise DatabaseError(f"Failed to persist user {user_id}: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pending-wager escrow (interactive games survive a restart)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Interactive games (blackjack, RPS PvP) deduct a stake up front and hold it in
|
||||||
|
# an in-memory View until the hand resolves. A restart would drop the View and
|
||||||
|
# lose the coins. To prevent that, the deduction commit also records the escrowed
|
||||||
|
# amount on the user (add_pending_wager), the settlement commit clears it
|
||||||
|
# (clear_pending_wager), and reconcile_pending_wagers refunds anything still
|
||||||
|
# outstanding at startup. All three mutate the user dict in place so the escrow
|
||||||
|
# state rides along in the SAME commit as the balance change (atomic).
|
||||||
|
def add_pending_wager(user: UserData, kind: str, amount: int) -> None:
|
||||||
|
"""Record/accumulate `amount` coins as escrowed by a `kind` game."""
|
||||||
|
pw = dict(user.get("pending_wager") or {})
|
||||||
|
pw = {
|
||||||
|
"kind": kind,
|
||||||
|
"amount": int(pw.get("amount", 0) or 0) + amount,
|
||||||
|
"ts": _now().isoformat(),
|
||||||
|
}
|
||||||
|
user["pending_wager"] = pw
|
||||||
|
|
||||||
|
|
||||||
|
def clear_pending_wager(user: UserData) -> None:
|
||||||
|
"""Mark the user's escrow settled (call in the settlement commit)."""
|
||||||
|
user["pending_wager"] = {}
|
||||||
|
|
||||||
|
|
||||||
|
async def reconcile_pending_wagers() -> list[tuple[int, int, str]]:
|
||||||
|
"""Refund every stake left escrowed by a game that a restart interrupted.
|
||||||
|
|
||||||
|
Runs once at startup (before commands are served). Returns the list of
|
||||||
|
(user_id, refunded_amount, kind) so the caller can log a summary."""
|
||||||
|
refunded: list[tuple[int, int, str]] = []
|
||||||
|
for uid_str, snapshot in (await get_all_users_raw()).items():
|
||||||
|
pw = snapshot.get("pending_wager") or {}
|
||||||
|
if int(pw.get("amount", 0) or 0) <= 0:
|
||||||
|
continue
|
||||||
|
uid = int(uid_str)
|
||||||
|
async with _user_lock(uid):
|
||||||
|
user = await get_user(uid)
|
||||||
|
pw = user.get("pending_wager") or {}
|
||||||
|
amount = int(pw.get("amount", 0) or 0)
|
||||||
|
if amount <= 0:
|
||||||
|
continue
|
||||||
|
kind = str(pw.get("kind", "?"))
|
||||||
|
user["balance"] += amount
|
||||||
|
clear_pending_wager(user)
|
||||||
|
await _commit(uid, user)
|
||||||
|
_txn("WAGER_RECONCILE", user=uid, refund=f"+{amount}", kind=kind, bal=user["balance"])
|
||||||
|
_log.info("Refunded interrupted %s wager: %s coins to user %s", kind, amount, uid)
|
||||||
|
refunded.append((uid, amount, kind))
|
||||||
|
return refunded
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# /reminders
|
# /reminders
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
98
core/economy/vanity.py
Normal file
98
core/economy/vanity.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"""Vanity shop: cosmetic badges/titles - a pure status sink for the wealthy.
|
||||||
|
|
||||||
|
No gameplay effect whatsoever. Buying burns the coins (they are NOT credited
|
||||||
|
to the house, so they leave circulation for good) and unlocks a badge the
|
||||||
|
player can equip; the equipped badge shows on /profile. Prices scale steeply
|
||||||
|
on purpose - this is where the richest players dump coins for bragging rights,
|
||||||
|
draining the top end of the economy where inflation hurts most.
|
||||||
|
|
||||||
|
A single entry point, do_vanity_select, handles buy / equip / unequip so the
|
||||||
|
whole feature fits one slash command.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TypedDict
|
||||||
|
|
||||||
|
from ..pb_client import DatabaseError
|
||||||
|
from .store import _commit, _locked_by, _txn, get_user
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Vanity",
|
||||||
|
"VANITY",
|
||||||
|
"NONE_ID",
|
||||||
|
"vanity_badge",
|
||||||
|
"do_vanity_select",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Vanity(TypedDict):
|
||||||
|
name: str # short shop name
|
||||||
|
emoji: str # the badge shown next to the player
|
||||||
|
title: str # the flavour title shown on /profile
|
||||||
|
cost: int
|
||||||
|
|
||||||
|
|
||||||
|
# Ordered cheapest -> priciest: a LAN/gaming status ladder from casual couch
|
||||||
|
# gamer to LAN legend. The top rungs are the deliberate whale sink.
|
||||||
|
VANITY: dict[str, Vanity] = {
|
||||||
|
"couch": {"name": "Sohvapadi", "emoji": "🎮", "title": "Sohvasõdur", "cost": 5_000},
|
||||||
|
"cables": {"name": "Võrgukaabel", "emoji": "🔌", "title": "Kaablihaldur", "cost": 8_000},
|
||||||
|
"discord": {"name": "Peakomplekt", "emoji": "🎧", "title": "Discordi Admin", "cost": 12_000},
|
||||||
|
"aimbot": {"name": "Kahtlane Hiir", "emoji": "🖱️", "title": "Aimbot Kahtlusalune", "cost": 18_000},
|
||||||
|
"sniper": {"name": "360Hz Monitor", "emoji": "🎯", "title": "Snaipripüss", "cost": 25_000},
|
||||||
|
"champ": {"name": "Meistrikarikas", "emoji": "🏆", "title": "Turniirivõitja", "cost": 40_000},
|
||||||
|
"legend": {"name": "Mängurijaam", "emoji": "🖥️", "title": "LAN Legend", "cost": 75_000},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sentinel choice value that unequips the active badge.
|
||||||
|
NONE_ID = "none"
|
||||||
|
|
||||||
|
|
||||||
|
def vanity_badge(user) -> tuple[str, str] | None:
|
||||||
|
"""Return (emoji, title) for the user's equipped badge, or None."""
|
||||||
|
vid = user.get("vanity_active")
|
||||||
|
vanity = VANITY.get(vid) if vid else None
|
||||||
|
return (vanity["emoji"], vanity["title"]) if vanity else None
|
||||||
|
|
||||||
|
|
||||||
|
@_locked_by(0)
|
||||||
|
async def do_vanity_select(user_id: int, vanity_id: str) -> dict:
|
||||||
|
"""Buy (if unowned), equip (if owned), or unequip (NONE_ID) a badge."""
|
||||||
|
try:
|
||||||
|
user = await get_user(user_id)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
|
if user.get("eco_banned"):
|
||||||
|
return {"ok": False, "reason": "banned"}
|
||||||
|
|
||||||
|
if vanity_id == NONE_ID:
|
||||||
|
user["vanity_active"] = None
|
||||||
|
await _commit(user_id, user)
|
||||||
|
_txn("VANITY_UNEQUIP", user=user_id)
|
||||||
|
return {"ok": True, "action": "unequipped"}
|
||||||
|
|
||||||
|
if vanity_id not in VANITY:
|
||||||
|
return {"ok": False, "reason": "not_found"}
|
||||||
|
|
||||||
|
owned = list(user.get("vanity_owned") or [])
|
||||||
|
vanity = VANITY[vanity_id]
|
||||||
|
|
||||||
|
# Already owned -> just equip it (free).
|
||||||
|
if vanity_id in owned:
|
||||||
|
user["vanity_active"] = vanity_id
|
||||||
|
await _commit(user_id, user)
|
||||||
|
_txn("VANITY_EQUIP", user=user_id, item=vanity_id)
|
||||||
|
return {"ok": True, "action": "equipped", "vanity": vanity}
|
||||||
|
|
||||||
|
# Not owned -> purchase (burns the coins) and auto-equip.
|
||||||
|
if user["balance"] < vanity["cost"]:
|
||||||
|
return {"ok": False, "reason": "insufficient", "need": vanity["cost"] - user["balance"]}
|
||||||
|
|
||||||
|
user["balance"] -= vanity["cost"] # burned: not credited to the house
|
||||||
|
owned.append(vanity_id)
|
||||||
|
user["vanity_owned"] = owned
|
||||||
|
user["vanity_active"] = vanity_id
|
||||||
|
await _commit(user_id, user)
|
||||||
|
_txn("VANITY_BUY", user=user_id, item=vanity_id, cost=f"-{vanity['cost']}", bal=user["balance"])
|
||||||
|
return {"ok": True, "action": "bought", "vanity": vanity, "balance": user["balance"]}
|
||||||
@@ -7,12 +7,15 @@ Pure-cache helpers (get_cache, find_*) remain sync.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
import gspread
|
import gspread
|
||||||
from google.oauth2.service_account import Credentials
|
from google.oauth2.service_account import Credentials
|
||||||
|
|
||||||
import config
|
import config
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Scopes needed: read + write to Sheets
|
# Scopes needed: read + write to Sheets
|
||||||
SCOPES = [
|
SCOPES = [
|
||||||
"https://www.googleapis.com/auth/spreadsheets",
|
"https://www.googleapis.com/auth/spreadsheets",
|
||||||
@@ -69,12 +72,25 @@ def _get_worksheet() -> gspread.Worksheet:
|
|||||||
|
|
||||||
|
|
||||||
def _ensure_headers(ws: gspread.Worksheet) -> None:
|
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)
|
existing = ws.row_values(1)
|
||||||
if existing != EXPECTED_HEADERS:
|
if existing != EXPECTED_HEADERS:
|
||||||
for col_idx, header in enumerate(EXPECTED_HEADERS, start=1):
|
missing = [h for h in EXPECTED_HEADERS if h not in existing]
|
||||||
if col_idx > len(existing) or existing[col_idx - 1] != header:
|
log.warning(
|
||||||
ws.update_cell(1, col_idx, header)
|
"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]:
|
def _refresh_sync() -> list[dict]:
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ The codebase is split into **`core/`** (domain logic), **`commands/`** (Discord
|
|||||||
| File | Purpose |
|
| 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 |
|
| `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.) |
|
| `config.py` | Environment variables (TOKEN, GUILD_ID, PB_URL, etc.) |
|
||||||
|
|
||||||
### `core/` - domain logic, no Discord coupling
|
### `core/` - domain logic, no Discord coupling
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `core/economy.py` | All economy business logic (`do_daily`, `do_work`, ...), data model, constants (SHOP, COOLDOWNS, LEVEL_ROLES, EXP_REWARDS, JAIL_DURATION, ...) |
|
| `core/economy/` | Economy business logic **package**, re-exported via `core/economy/__init__.py` so callers use `from core import economy` + attribute access (`economy.do_daily`, `economy.SHOP`, ...). Submodules: `store.py` (user records, per-user locks, `COOLDOWNS`, `ITEM_COOLDOWNS`/`effective_cooldown`, `JAIL_DURATION`, `COIN`, `get_user`/`_commit`/`_txn`, `pending_wager` escrow + `reconcile_pending_wagers`), `income.py`, `gambling.py`, `fishing.py`, `jail.py`, `heist.py`, `prestige.py`, `shop.py`, `consumables.py` (timed buffs + `grant_buff`), `vanity.py` (cosmetic badges), `lootbox.py` (mystery box), `bank.py` (rob-proof vault), `achievements.py` (milestone badges), `lottery.py` (daily draw), `levels.py`, `quests.py`, `leaderboards.py` (incl. `get_all_leaderboards`/`get_economy_stats`, net-worth = balance+bank), `house.py`, `admin.py` |
|
||||||
| `core/pb_client.py` | Async PocketBase REST client - auth token cache, CRUD on `economy_users` collection |
|
| `core/pb_client.py` | Async PocketBase REST client - auth token cache, CRUD on `economy_users` collection |
|
||||||
| `core/sheets.py` | Google Sheets integration (member sync) |
|
| `core/sheets.py` | Google Sheets integration (member sync) |
|
||||||
| `core/member_sync.py` | Birthday/member sync helpers |
|
| `core/member_sync.py` | Birthday/member sync helpers |
|
||||||
@@ -31,10 +31,10 @@ Each file exposes a `register_<group>_commands(tree, bot, ...)` function. `bot.p
|
|||||||
| `commands/dev_member_runtime.py` | `on_member_join` flow + `birthday_daily` task body |
|
| `commands/dev_member_runtime.py` | `on_member_join` flow + `birthday_daily` task body |
|
||||||
| `commands/economy_income_commands.py` | `/daily`, `/work`, `/beg`, `/crime`, `/rob` |
|
| `commands/economy_income_commands.py` | `/daily`, `/work`, `/beg`, `/crime`, `/rob` |
|
||||||
| `commands/economy_games_commands.py` | `/roulette`, `/slots`, `/blackjack`, `/rps` |
|
| `commands/economy_games_commands.py` | `/roulette`, `/slots`, `/blackjack`, `/rps` |
|
||||||
| `commands/economy_extra_commands.py` | `/heist`, `/jailbreak`, `/reminders`, `/request`, ... |
|
| `commands/economy_extra_commands.py` | `/heist`, `/jailbreak`, `/reminders`, `/request`, `/give`, `/lottery`, `/leaderboard`, ... |
|
||||||
| `commands/economy_fish_commands.py` | `/fish`, `/fishbook`, `/fishsell` |
|
| `commands/economy_fish_commands.py` | `/fish`, `/fishbook`, `/fishsell` |
|
||||||
| `commands/economy_profile_commands.py` | `/balance`, `/rank`, `/stats`, `/cooldowns`, `/leaderboard` |
|
| `commands/economy_profile_commands.py` | `/balance`, `/rank`, `/stats`, `/cooldowns`, `/achievements` |
|
||||||
| `commands/economy_support_commands.py` | `/shop`, `/buy`, `/give`, `/economysetup` |
|
| `commands/economy_support_commands.py` | `/shop`, `/buy`, `/economysetup`, `/consumables`, `/vanity`, `/lootbox`, `/bank`, `/deposit`, `/withdraw` |
|
||||||
| `commands/economy_prestige_commands.py` | `/prestige`, `/prestigeshop`, `/prestigebuy` |
|
| `commands/economy_prestige_commands.py` | `/prestige`, `/prestigeshop`, `/prestigebuy` |
|
||||||
| `commands/economy_admin_commands.py` | `/admincoins`, `/adminexp`, `/adminitem`, `/adminjail`, `/adminban`, `/adminreset`, `/adminview` |
|
| `commands/economy_admin_commands.py` | `/admincoins`, `/adminexp`, `/adminitem`, `/adminjail`, `/adminban`, `/adminreset`, `/adminview` |
|
||||||
| `commands/ops_admin_commands.py` | `/sync`, `/restart`, `/shutdown`, `/pause`, `/send`, `/status` |
|
| `commands/ops_admin_commands.py` | `/sync`, `/restart`, `/shutdown`, `/pause`, `/send`, `/status` |
|
||||||
@@ -57,20 +57,20 @@ Pick the `commands/economy_*_commands.py` file that matches the new command's ca
|
|||||||
|
|
||||||
Checklist - do all of these, in order:
|
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
|
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.py`** - add the cooldown to `COOLDOWNS` dict if it has one
|
2. **`core/economy/store.py`** - add the cooldown to `COOLDOWNS` dict if it has one. Compute the effective cooldown in `do_<cmd>` with `effective_cooldown("<cmd>", user["items"])` rather than an inline `timedelta(...) if item in items else ...`
|
||||||
3. **`core/economy.py`** - add the EXP reward to `EXP_REWARDS` dict
|
3. **`core/economy/levels.py`** - add the EXP reward to `EXP_REWARDS` dict
|
||||||
4. **`strings.py` `CMD`** - add the slash command description
|
4. **`strings/commands.py` `CMD`** - add the slash command description
|
||||||
5. **`strings.py` `OPT`** - add any parameter descriptions
|
5. **`strings/commands.py` `OPT`** - add any parameter descriptions
|
||||||
6. **`strings.py` `TITLE`** - add embed title(s) for success/fail states
|
6. **`strings/common.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"]`)
|
7. **`strings/common.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
|
8. **`strings/common.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
|
9. **`strings/commands.py` `HELP_CATEGORIES["tipibot"]["fields"]`** - add the command to the help embed
|
||||||
10. **`commands/economy_<group>_commands.py`** - inside `register_*_commands`, add `@tree.command(name="<cmd>", ...)` `cmd_<name>`; handle all `res["reason"]` cases
|
10. **`commands/economy_<group>_commands.py`** - inside `register_*_commands`, add `@tree.command(name="<cmd>", ...)` `cmd_<name>`; handle all `res["reason"]` cases. If `do_<cmd>` can return `db_error` (any function that calls `get_user`/`_commit`), handle it first in the failure block with `await reply_db_error(interaction); return` (import from `._replies`) - otherwise a DB outage hangs the deferred interaction or shows a misleading message
|
||||||
11. **`commands/economy_<group>_commands.py`** - call `maybe_remind(user_id, "<cmd>")` if the command has a cooldown and reminders make sense (the helper is passed in via the `register_*` signature)
|
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
|
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)
|
14. **`core/economy/store.py` `ITEM_COOLDOWNS`** - if an item shortens the command's cooldown, add `"<cmd>": ("<item_id>", timedelta(...))` here. This is the single source of truth: `effective_cooldown` (used by `do_<cmd>`), `_maybe_remind`, and `_restore_reminders` all read it, so you no longer edit the reminder helpers by hand
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -78,13 +78,13 @@ Checklist - do all of these, in order:
|
|||||||
|
|
||||||
Checklist:
|
Checklist:
|
||||||
|
|
||||||
1. **`core/economy.py` `SHOP`** - add the item dict `{name, emoji, cost, description: strings.ITEM_DESCRIPTIONS["key"]}`
|
1. **`core/economy/shop.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)
|
2. **`core/economy/shop.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)
|
3. **`core/economy/shop.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)
|
4. **`strings/economy.py` `ITEM_DESCRIPTIONS`** - add the item description (Estonian flavour + English effect)
|
||||||
5. **`strings.py` `HELP_CATEGORIES["shop"]["fields"]`** - add display entry (sorted by cost)
|
5. **`strings/commands.py` `HELP_CATEGORIES["shop"]["fields"]`** - add display entry (sorted by cost)
|
||||||
6. If the item modifies a cooldown:
|
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
|
- **`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
|
- **`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
|
## 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)
|
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
|
3. Run **`/economysetup`** in the server to create the role and set its position
|
||||||
|
|
||||||
@@ -100,8 +100,8 @@ Checklist:
|
|||||||
|
|
||||||
## Adding a New Admin Command
|
## Adding a New Admin Command
|
||||||
|
|
||||||
1. **`strings.py` `CMD`** - add `"[Admin] ..."` description
|
1. **`strings/commands.py` `CMD`** - add `"[Admin] ..."` description
|
||||||
2. **`strings.py` `HELP_CATEGORIES["admin"]["fields"]`** - add the entry
|
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()`
|
3. **`commands/economy_admin_commands.py`** (or `commands/ops_admin_commands.py` for non-economy ops) - add the handler with `@app_commands.default_permissions(manage_guild=True)` and `@app_commands.guild_only()`
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -110,13 +110,13 @@ Checklist:
|
|||||||
|
|
||||||
### Storage
|
### 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
|
### Currency & Income Sources
|
||||||
|
|
||||||
| Command | Cooldown | Base Earn | Notes |
|
| Command | Cooldown | Base Earn | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `/daily` | 20h (18h w/ korvaklapid) | 150⬡ | ×streak multiplier, ×2 w/ lan_pass, +5% interest w/ gaming_laptop |
|
| `/daily` | 20h (18h w/ korvaklapid) | 150⬡ | ×streak multiplier, ×2 w/ lan_pass, +25⬡ w/ korvaklapid, +5% interest w/ gaming_laptop |
|
||||||
| `/work` | 1h (40min w/ monitor) | 15-75⬡ | ×1.5 w/ gaming_hiir, ×1.25 w/ reguleeritav_laud, ×3 30% chance w/ energiajook |
|
| `/work` | 1h (40min w/ monitor) | 15-75⬡ | ×1.5 w/ gaming_hiir, ×1.25 w/ reguleeritav_laud, ×3 30% chance w/ energiajook |
|
||||||
| `/beg` | 5min (3min w/ hiirematt) | 10-40⬡ | ×2 w/ klaviatuur |
|
| `/beg` | 5min (3min w/ hiirematt) | 10-40⬡ | ×2 w/ klaviatuur |
|
||||||
| `/crime` | 2h | 200-500⬡ win | 60% success (75% w/ cat6), +30% w/ mikrofon; fail = fine + jail |
|
| `/crime` | 2h | 200-500⬡ win | 60% success (75% w/ cat6), +30% w/ mikrofon; fail = fine + jail |
|
||||||
@@ -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.
|
- `/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`)
|
- **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.
|
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 2–3, uncommon 6–7, rare 10, epic 14–15, legendary 25).
|
Gambling EXP is bet-scaled via `gamble_exp(bet)`; fish EXP is per-species in `FISH_CATALOGUE` (common 2–3, uncommon 6–7, rare 10, epic 14–15, legendary 25).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -179,49 +179,51 @@ Role assignment:
|
|||||||
| T3 | 20 | monitor_360, karikas, gaming_tool |
|
| T3 | 20 | monitor_360, karikas, gaming_tool |
|
||||||
|
|
||||||
Shop display is sorted by cost (ascending) within each tier.
|
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 |
|
| Section | Dict | Module | Typical usage |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| Flavour text | `WORK_JOBS`, `BEG_LINES`, `CRIME_WIN`, `CRIME_LOSE` | Randomised descriptions |
|
| Flavour text | `WORK_JOBS`, `BEG_LINES`, `CRIME_WIN`, `CRIME_LOSE` | `economy.py` | Randomised descriptions |
|
||||||
| Command descriptions | `CMD["key"]` | `@tree.command(description=S.CMD["key"])` |
|
| Command descriptions | `CMD["key"]` | `commands.py` | `@tree.command(description=S.CMD["key"])` |
|
||||||
| Parameter descriptions | `OPT["key"]` | `@app_commands.describe(param=S.OPT["key"])` |
|
| Parameter descriptions | `OPT["key"]` | `commands.py` | `@app_commands.describe(param=S.OPT["key"])` |
|
||||||
| Help embed | `HELP_CATEGORIES["cat"]` | `cmd_help` (in `bot.py`) |
|
| Help embed | `HELP_CATEGORIES["cat"]` | `commands.py` | `cmd_help` (in `bot.py`) |
|
||||||
| Banned message | `MSG_BANNED` | All banned checks |
|
| Banned message | `MSG_BANNED` | `common.py` | All banned checks |
|
||||||
| Maintenance mode | `MSG_MAINTENANCE` | Shown when `_PAUSED=True` in `bot.py` (toggled by `/pause` in `commands/ops_admin_commands.py`) |
|
| 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` | `RemindersSelect` dropdown |
|
| Reminder options | `REMINDER_OPTS` | `common.py` | `RemindersSelect` dropdown |
|
||||||
| Slots outcomes | `SLOTS_TIERS["tier"]` → `(title, color)` | `cmd_slots` (in `commands/economy_games_commands.py`) |
|
| Slots outcomes | `SLOTS_TIERS["tier"]` → `(title, color)` | `games.py` | `cmd_slots` (in `commands/economy_games_commands.py`) |
|
||||||
| Embed titles | `TITLE["key"]` | `discord.Embed(title=S.TITLE["key"])` |
|
| Embed titles | `TITLE["key"]` | `common.py` | `discord.Embed(title=S.TITLE["key"])` |
|
||||||
| Error messages | `ERR["key"]` | `send_message(S.ERR["key"])` - use `.format(**kwargs)` for dynamic parts |
|
| 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(...))` | Cooldown responses (`cd_ts` helper passed in by `bot.py`) |
|
| 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"]` | `_shop_embed` (in `commands/economy_support_commands.py`) |
|
| Shop UI | `SHOP_UI["key"]` | `economy.py` | `_shop_embed` (in `commands/economy_support_commands.py`) |
|
||||||
| Item descriptions | `ITEM_DESCRIPTIONS["item_key"]` | `core/economy.py` `SHOP[key]["description"]` |
|
| Item descriptions | `ITEM_DESCRIPTIONS["item_key"]` | `economy.py` | `core/economy/shop.py` `SHOP[key]["description"]` |
|
||||||
| Patch notes UI | `PATCHNOTES_UI["key"]` | `commands/info_commands.py` (`/patchnotes`) |
|
| Patch notes UI | `PATCHNOTES_UI["key"]` | `common.py` | `commands/info_commands.py` (`/patchnotes`) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Constants Location Quick-Reference
|
## 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` | `core/economy/shop.py` | All shop items (name, emoji, cost, description) |
|
||||||
| `SHOP_TIERS` | `core/economy.py` | Which items are in T1/T2/T3 |
|
| `SHOP_TIERS` | `core/economy/shop.py` | Which items are in T1/T2/T3 |
|
||||||
| `SHOP_LEVEL_REQ` | `core/economy.py` | Min level per item |
|
| `SHOP_LEVEL_REQ` | `core/economy/shop.py` | Min level per item |
|
||||||
| `COOLDOWNS` | `core/economy.py` | Base cooldown per command |
|
| `COOLDOWNS` | `core/economy/store.py` | Base cooldown per command |
|
||||||
| `JAIL_DURATION` | `core/economy.py` | How long jail lasts |
|
| `JAIL_DURATION` | `core/economy/store.py` | How long jail lasts |
|
||||||
| `LEVEL_ROLES` | `core/economy.py` | `[(min_level, "RoleName"), ...]` highest first |
|
| `LEVEL_ROLES` | `core/economy/levels.py` | `[(min_level, "RoleName"), ...]` highest first |
|
||||||
| `ECONOMY_ROLE` | `core/economy.py` | Name of the base economy participation role |
|
| `ECONOMY_ROLE` | `core/economy/levels.py` | Name of the base economy participation role |
|
||||||
| `EXP_REWARDS` | `core/economy.py` | EXP per command |
|
| `EXP_REWARDS` | `core/economy/levels.py` | EXP per command |
|
||||||
| `FISH` | `core/economy.py` | Fish species table (rarity, weight, coins, exp) |
|
| `FISH_CATALOGUE` | `core/economy/fishing.py` | Fish species table (rarity, weight, coins, exp) |
|
||||||
| `HOUSE_ID` | `core/economy.py` | Bot's user ID (house account for /rob) |
|
| `HOUSE_ID` | `core/economy/house.py` | Bot's user ID (house account for /rob) |
|
||||||
| `MIN_BAIL` | `core/economy.py` | Minimum bail payment (350⬡) |
|
| `MIN_BAIL` | `core/economy/jail.py` | Minimum bail payment (350⬡) |
|
||||||
| `COIN` | `core/economy.py` | The coin emoji string |
|
| `COIN` | `core/economy/store.py` | The coin emoji string |
|
||||||
| `_PAUSED` | `bot.py` | In-memory maintenance flag; toggled by `/pause`; blocks all non-admin commands |
|
| `_PAUSED` | `bot.py` | In-memory maintenance flag; toggled by `/pause`; blocks all non-admin commands |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
Here you'll find an overview of TipiBOT updates. Latest changes are at the top.
|
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`).
|
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
|
## 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.
|
- 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.
|
||||||
|
|||||||
@@ -34,10 +34,12 @@ from core import economy # noqa: E402
|
|||||||
PB_URL = config.PB_URL
|
PB_URL = config.PB_URL
|
||||||
COLLECTIONS = sorted({config.PB_ECONOMY_COLLECTION_DEV, config.PB_ECONOMY_COLLECTION_ECONOMY})
|
COLLECTIONS = sorted({config.PB_ECONOMY_COLLECTION_DEV, config.PB_ECONOMY_COLLECTION_ECONOMY})
|
||||||
|
|
||||||
# _default_user() keys whose default is None, all ISO date/datetime strings
|
# _default_user() keys with a None/str default that PocketBase stores as text
|
||||||
|
# (ISO date/datetime strings, plus the vanity badge id).
|
||||||
_TEXT_FIELDS = {
|
_TEXT_FIELDS = {
|
||||||
"last_daily", "last_work", "last_beg", "last_crime", "last_rob",
|
"last_daily", "last_work", "last_beg", "last_crime", "last_rob",
|
||||||
"last_heist", "last_fish", "last_streak_date", "jailed_until",
|
"last_heist", "last_fish", "last_streak_date", "jailed_until",
|
||||||
|
"vanity_active", "lottery_period",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
1414
strings.py
1414
strings.py
File diff suppressed because it is too large
Load Diff
200
strings/__init__.py
Normal file
200
strings/__init__.py
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
"""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,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .economy import (
|
||||||
|
WORK_JOBS,
|
||||||
|
BEG_LINES,
|
||||||
|
BEG_JAIL_LINES,
|
||||||
|
CRIME_WIN,
|
||||||
|
CRIME_LOSE,
|
||||||
|
QUEST_UI,
|
||||||
|
QUEST_DESCRIPTIONS,
|
||||||
|
SHOP_UI,
|
||||||
|
ITEM_DESCRIPTIONS,
|
||||||
|
CONSUMABLES_UI,
|
||||||
|
CONSUMABLE_DESCRIPTIONS,
|
||||||
|
VANITY_UI,
|
||||||
|
LOOTBOX_UI,
|
||||||
|
BANK_UI,
|
||||||
|
ACHIEVEMENTS_UI,
|
||||||
|
LOTTERY_UI,
|
||||||
|
JAILED_UI,
|
||||||
|
SHOP_BTN,
|
||||||
|
DAILY_UI,
|
||||||
|
STATS_UI,
|
||||||
|
PROFILE_UI,
|
||||||
|
BALANCE_UI,
|
||||||
|
COOLDOWNS_UI,
|
||||||
|
RANK_UI,
|
||||||
|
WORK_UI,
|
||||||
|
BEG_UI,
|
||||||
|
CRIME_UI,
|
||||||
|
ROB_UI,
|
||||||
|
GIVE_UI,
|
||||||
|
BUY_UI,
|
||||||
|
LEADERBOARD_UI,
|
||||||
|
REQUEST_UI,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .admin import (
|
||||||
|
ADMINVIEW_UI,
|
||||||
|
ADMIN,
|
||||||
|
SEASON,
|
||||||
|
PRESTIGE_SHOP_NAMES,
|
||||||
|
PRESTIGE_SHOP_DESCRIPTIONS,
|
||||||
|
PRESTIGE_UI,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .games import (
|
||||||
|
SLOTS_TIERS,
|
||||||
|
ROULETTE,
|
||||||
|
HEIST_STORY,
|
||||||
|
HEIST_UI,
|
||||||
|
BJ,
|
||||||
|
JAILBREAK_UI,
|
||||||
|
SLOTS_UI,
|
||||||
|
RPS_UI,
|
||||||
|
RPS_CHOICES,
|
||||||
|
BJ_UI,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .fishing import (
|
||||||
|
FISH_NAMES,
|
||||||
|
FISH_RARITY_NAMES,
|
||||||
|
FISH_RARITY_EMOJI,
|
||||||
|
FISH_JUNK_LINES,
|
||||||
|
FISH_UI,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'MSG_BANNED',
|
||||||
|
'MSG_SPAM_JAIL',
|
||||||
|
'MSG_PONG',
|
||||||
|
'MSG_RESTART_DONE',
|
||||||
|
'MSG_RESTARTING',
|
||||||
|
'MSG_SHUTTING_DOWN',
|
||||||
|
'MSG_PAUSED',
|
||||||
|
'MSG_UNPAUSED',
|
||||||
|
'MSG_MAINTENANCE',
|
||||||
|
'MSG_SYNC_DONE',
|
||||||
|
'MSG_REMINDER',
|
||||||
|
'MSG_LEVELUP',
|
||||||
|
'MSG_LEVELUP_ROLE',
|
||||||
|
'REMINDER_OPTS',
|
||||||
|
'TITLE',
|
||||||
|
'ERR',
|
||||||
|
'CD_MSG',
|
||||||
|
'REMINDERS_UI',
|
||||||
|
'SEND_UI',
|
||||||
|
'PATCHNOTES_UI',
|
||||||
|
'STATUS_UI',
|
||||||
|
'CMD',
|
||||||
|
'OPT',
|
||||||
|
'HELP_CATEGORIES',
|
||||||
|
'HELP_UI',
|
||||||
|
'MEMBER_UI',
|
||||||
|
'MEMBER_FIELDS',
|
||||||
|
'ECONOMYSETUP_UI',
|
||||||
|
'CHANNEL_UI',
|
||||||
|
'BIRTHDAY_UI',
|
||||||
|
'BIRTHDAY_MONTHS',
|
||||||
|
'CHECK_UI',
|
||||||
|
'WORK_JOBS',
|
||||||
|
'BEG_LINES',
|
||||||
|
'BEG_JAIL_LINES',
|
||||||
|
'CRIME_WIN',
|
||||||
|
'CRIME_LOSE',
|
||||||
|
'QUEST_UI',
|
||||||
|
'QUEST_DESCRIPTIONS',
|
||||||
|
'SHOP_UI',
|
||||||
|
'ITEM_DESCRIPTIONS',
|
||||||
|
'CONSUMABLES_UI',
|
||||||
|
'CONSUMABLE_DESCRIPTIONS',
|
||||||
|
'VANITY_UI',
|
||||||
|
'LOOTBOX_UI',
|
||||||
|
'BANK_UI',
|
||||||
|
'ACHIEVEMENTS_UI',
|
||||||
|
'LOTTERY_UI',
|
||||||
|
'JAILED_UI',
|
||||||
|
'SHOP_BTN',
|
||||||
|
'DAILY_UI',
|
||||||
|
'STATS_UI',
|
||||||
|
'PROFILE_UI',
|
||||||
|
'BALANCE_UI',
|
||||||
|
'COOLDOWNS_UI',
|
||||||
|
'RANK_UI',
|
||||||
|
'WORK_UI',
|
||||||
|
'BEG_UI',
|
||||||
|
'CRIME_UI',
|
||||||
|
'ROB_UI',
|
||||||
|
'GIVE_UI',
|
||||||
|
'BUY_UI',
|
||||||
|
'LEADERBOARD_UI',
|
||||||
|
'REQUEST_UI',
|
||||||
|
'ADMINVIEW_UI',
|
||||||
|
'ADMIN',
|
||||||
|
'SEASON',
|
||||||
|
'PRESTIGE_SHOP_NAMES',
|
||||||
|
'PRESTIGE_SHOP_DESCRIPTIONS',
|
||||||
|
'PRESTIGE_UI',
|
||||||
|
'SLOTS_TIERS',
|
||||||
|
'ROULETTE',
|
||||||
|
'HEIST_STORY',
|
||||||
|
'HEIST_UI',
|
||||||
|
'BJ',
|
||||||
|
'JAILBREAK_UI',
|
||||||
|
'SLOTS_UI',
|
||||||
|
'RPS_UI',
|
||||||
|
'RPS_CHOICES',
|
||||||
|
'BJ_UI',
|
||||||
|
'FISH_NAMES',
|
||||||
|
'FISH_RARITY_NAMES',
|
||||||
|
'FISH_RARITY_EMOJI',
|
||||||
|
'FISH_JUNK_LINES',
|
||||||
|
'FISH_UI',
|
||||||
|
]
|
||||||
128
strings/admin.py
Normal file
128
strings/admin.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"""Economy admin responses, season reset, and prestige system.
|
||||||
|
|
||||||
|
Auto-split from the original strings.py; edit strings here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from core.emoji import EMOJI as E
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'ADMINVIEW_UI',
|
||||||
|
'ADMIN',
|
||||||
|
'SEASON',
|
||||||
|
'PRESTIGE_SHOP_NAMES',
|
||||||
|
'PRESTIGE_SHOP_DESCRIPTIONS',
|
||||||
|
'PRESTIGE_UI',
|
||||||
|
]
|
||||||
|
|
||||||
|
ADMINVIEW_UI: dict[str, str] = {
|
||||||
|
"title": "🔍 {name} - majandusandmed",
|
||||||
|
"banned_yes": "🚫 JAH",
|
||||||
|
"banned_no": "✅ Ei",
|
||||||
|
"f_balance": "💰 Saldo",
|
||||||
|
"f_bank": "🏦 Pangas",
|
||||||
|
"f_extras": "🏅 Muu",
|
||||||
|
"extras_val": "Saavutusi: {ach}\nÕnnekaste: {lootboxes}\nOotel panus: {wager}",
|
||||||
|
"f_exp": "📊 EXP / Tase",
|
||||||
|
"f_streak": "🔥 Streak",
|
||||||
|
"f_banned": "🚫 Keelatud",
|
||||||
|
"f_jailed": "🚔 Vangis kuni",
|
||||||
|
"f_prestige": "🔥 Prestiiž",
|
||||||
|
"f_items": "🎒 Esemed",
|
||||||
|
"f_uses": "🔢 Kasutused",
|
||||||
|
"f_fish": "🎣 Kala",
|
||||||
|
"f_last_daily": "⏱️ Viimati daily",
|
||||||
|
"f_last_work": "⏱️ Viimati work",
|
||||||
|
"f_last_crime": "⏱️ Viimati crime",
|
||||||
|
"f_last_fish": "⏱️ Viimati fish",
|
||||||
|
"footer": "ID: {uid}",
|
||||||
|
"exp_val": "{exp} EXP (Tase {level})",
|
||||||
|
"prestige_val": "Prestiiž {level} · {pp} PP",
|
||||||
|
"fish_val": "{caught} püütud · {inv} inventaris",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Admin command responses and DMs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
ADMIN: dict[str, str] = {
|
||||||
|
"coins_done": "{emoji} **{name}**: {verb} {coin} → saldo **{balance}**\n📝 Põhjus: {reason}",
|
||||||
|
"coins_dm": "{emoji} Admin muutis sinu TipiCOINide saldot: **{verb} {coin}**\n📝 Põhjus: *{reason}*\nUus saldo: **{balance} {coin}**",
|
||||||
|
"jail_done": "🚔 **{name}** on vangistatud {minutes} minutiks (vabaneb {ts}).\n📝 Põhjus: {reason}",
|
||||||
|
"jail_dm": "🚔 Admin saatis sind vanglasse **{minutes} minutiks**.\n📝 Põhjus: *{reason}*\nVabaneb {ts}.",
|
||||||
|
"unjail_done": "✅ **{name}** on vanglast vabastatud.",
|
||||||
|
"unjail_dm": "✅ Admin vabastas sind vanglast.",
|
||||||
|
"ban_done": "🚫 **{name}** keelati majandussüsteemis osaleda.\n📝 Põhjus: {reason}",
|
||||||
|
"ban_dm": "🚫 Sul keelati TipiBOTi majandussüsteemis osalemine.\n📝 Põhjus: *{reason}*",
|
||||||
|
"unban_done": "✅ **{name}** on majandussüsteemi osalemise keelust eemaldatud.",
|
||||||
|
"unban_dm": "✅ Sinu TipiBOTi majandussüsteemis osalemise keeld on tühistatud. Saad taas käske kasutada.",
|
||||||
|
"reset_done": "🗑️ **{name}** majandusandmed on lähtestatud.\n📝 Põhjus: {reason}",
|
||||||
|
"reset_dm": "🗑️ Admin lähtestas sinu TipiBOTi majandusandmed (saldo, esemed, streak).\n📝 Põhjus: *{reason}*",
|
||||||
|
"exp_done": "{emoji} **{name}**: {verb} EXP → kokku **{exp} EXP** (Tase {level}).\n📝 Põhjus: {reason}",
|
||||||
|
"exp_dm": "{emoji} Admin muutis sinu EXP-i: **{verb} EXP**\n📝 Põhjus: *{reason}*\nUus EXP: **{exp}** (Tase {level})",
|
||||||
|
"item_given": "✅ **{item}** antud kasutajale **{name}** (tasuta).",
|
||||||
|
"item_removed":"🗑️ **{item}** eemaldatud kasutajalt **{name}**.",
|
||||||
|
"item_invalid":"❌ Tundmatu ese: `{item_id}`. Kontrolli `/shop` eseme ID-d.",
|
||||||
|
"item_not_owned": "❌ **{name}** ei oma eset `{item_id}`.",
|
||||||
|
"item_dm_given": "✅ Admin andis sulle eseme: **{item}**.",
|
||||||
|
"item_dm_removed":"🗑️ Admin eemaldas sult eseme: **{item}**.",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /adminseason strings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SEASON: dict[str, str] = {
|
||||||
|
"top_header": "**Top mängijad EXP järgi:**",
|
||||||
|
"no_players": "Keegi ei teeninud EXP-i.",
|
||||||
|
"entry": "{prefix} {name} - {exp} EXP *(Tase {level})*",
|
||||||
|
"footer": "Kõigi EXP, mündid ja esemed lähtestati. Uus hooaeg algab kohe!",
|
||||||
|
"done": "✅ Hooaeg lõpetatud - EXP, mündid ja esemed lähtestatud.",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Prestige system strings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PRESTIGE_SHOP_NAMES: dict[str, str] = {
|
||||||
|
"coin_mult": "Mündiboost",
|
||||||
|
"exp_mult": "EXP-boost",
|
||||||
|
"daily_plus": "Päevabonus+",
|
||||||
|
"work_plus": "Töötaja+",
|
||||||
|
}
|
||||||
|
PRESTIGE_SHOP_DESCRIPTIONS: dict[str, str] = {
|
||||||
|
"coin_mult": "Kõik TipiCOINide teenimisallikad (daily, töö, kerja, kala) teenivad +8% rohkem iga taseme kohta. Max 5 taset → +40%.",
|
||||||
|
"exp_mult": "Kõik EXP allikad teenivad +8% rohkem iga taseme kohta. Max 5 taset → +40%.",
|
||||||
|
"daily_plus": "Päevase boonuse alussumma tõuseb +20% iga taseme kohta. Max 3 taset → +60%.",
|
||||||
|
"work_plus": "/work teenib +20% rohkem iga taseme kohta. Max 3 taset → +60%.",
|
||||||
|
}
|
||||||
|
PRESTIGE_UI: dict[str, str] = {
|
||||||
|
"confirm_desc": (
|
||||||
|
"Oled tasemel **{level}** ({exp} EXP).\n\n"
|
||||||
|
"Prestiiži korral saad **{pp}** " + E["TipiFIRE"] + " ja kõik lähtestub:\n"
|
||||||
|
"• Saldo, EXP, esemed, ooteajad\n\n"
|
||||||
|
"**Kalakogu jääb alles!**\n\nKas oled kindel?"
|
||||||
|
),
|
||||||
|
"btn_confirm": "🔥 Jah, prestiiži!",
|
||||||
|
"btn_cancel": "❌ Tühista",
|
||||||
|
"btn_tab_status": "⭐ Prestiiz",
|
||||||
|
"btn_tab_shop": "🛍️ Uuendused",
|
||||||
|
"success_desc": (
|
||||||
|
"Said **{pp}** " + E["TipiFIRE"] + "\n"
|
||||||
|
"Prestiiži tase: **{level}**\n"
|
||||||
|
"Kogutud PP: **{total_pp}** " + E["TipiFIRE"] + "\n\n"
|
||||||
|
"*Kõik lähtestati. Alusta otsast!*"
|
||||||
|
),
|
||||||
|
"too_low_desc": "Prestiiži jaoks vajad taset **{required}** (sul on tase {level}).",
|
||||||
|
"shop_desc": "Sul on **{pp}** " + E["TipiFIRE"] + " · Vajuta nuppu uuenduse ostmiseks",
|
||||||
|
"shop_maxed": "✅ Max",
|
||||||
|
"shop_level_fmt": "Tase {cur}/{max}",
|
||||||
|
"shop_cost_fmt": "{cost} " + E["TipiFIRE"],
|
||||||
|
"buy_success_desc":"**{name}** uuendatud tasemele **{new_level}/{max_level}**!\nPP alles: **{pp}** " + E["TipiFIRE"],
|
||||||
|
"buy_no_pp": E["TipICRY"] + " Sul pole piisavalt PP. Sul on **{have}**, vajad **{need}** " + E["TipiFIRE"] + ".",
|
||||||
|
"buy_maxed": "❌ See uuendus on juba maksimumtasemel.",
|
||||||
|
"buy_not_found": "❌ Sellist uuendust ei leitud. Vaata `/prestigeshop`.",
|
||||||
|
"rank_line": E["TipiFIRE"] + " Prestiiž **{level}** · {pp} PP",
|
||||||
|
"rank_season": "🏆 Hooaja EXP: **{exp}**",
|
||||||
|
"btn_buy_upgrade": "{emoji} {name} +1 ({cost} PP)",
|
||||||
|
"status_footer": "⭐ Prestiiž {level} · {pp} PP",
|
||||||
|
}
|
||||||
244
strings/commands.py
Normal file
244
strings/commands.py
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
"""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",
|
||||||
|
"restart": "Tee taaskäivitus botile",
|
||||||
|
"shutdown": "Lülita bot välja (ilma taaskäivituseta)",
|
||||||
|
"pause": "Peata / jätka kõik käsklused (hooldusrežiim)",
|
||||||
|
"send": "Saada sõnum valitud kanalisse",
|
||||||
|
"profile": "Vaata oma profiili: saldo, tase, esemed, statistika ja kalakogu",
|
||||||
|
"balance": "Vaata enda (või kellegi teise) TipiCOINide saldot",
|
||||||
|
"daily": "Võta enda päevane TipiCOINi boonus",
|
||||||
|
"work": "Tööta ja teeni TipiCOINe (1h ooteaeg)",
|
||||||
|
"beg": "Kerja TipiCOINe (5min ooteaeg)",
|
||||||
|
"crime": "Proovi oma õnne kriminaalse tegevusega (2h ooteaeg)",
|
||||||
|
"rob": "Proovi kelleltki TipiCOINe varastada",
|
||||||
|
"heist": "Alusta grupirööv pangahoidlasse (min 2 mängijat, max 8, 4h serveri ooteaeg)",
|
||||||
|
"roulette": "Panusta TipiCOINe punasele, mustale või rohelisele (1/37 võimalus, 14x payout)",
|
||||||
|
"give": "Anna TipiCOINe teisele mängijale",
|
||||||
|
"leaderboard": "TipiBOTi edetabel - kes on kõige rikkam?",
|
||||||
|
"shop": "Sirvi TipiBOTi poodi",
|
||||||
|
"buy": "Osta ese TipiBOTi poodist",
|
||||||
|
"rps": "Kivi-paber-käärid mõne teise mängija vastu",
|
||||||
|
"slots": "Proovi oma õnne TipiBOTi mänguautomaadiga",
|
||||||
|
"request": "Kerja TipiCOINe teistelt mängijatelt - nagu crowdfunding, aga halvem",
|
||||||
|
"reminders": "Halda DM meeldetuletusi - kõik on vaikimisi sees, lülita need siin välja",
|
||||||
|
"cooldowns": "Vaata kõikide käskude ooteaegu",
|
||||||
|
"jailed": "Vaata, kes on praegu vanglas",
|
||||||
|
"rank": "Vaata oma EXP, taset ja edetabeli positsiooni",
|
||||||
|
"stats": "Vaata oma mängustatistikat",
|
||||||
|
"jailbreak": "Proovi vanglast põgeneda kasutades täringuid",
|
||||||
|
"adminseason": "[Admin] Lõpeta võistlus, teavita võitjaid ja lähtesta EXP",
|
||||||
|
"admincoins": "[Admin] Anna või võta TipiCOINe kasutajale/kasutajalt",
|
||||||
|
"adminjail": "[Admin] Saada kasutaja vangi",
|
||||||
|
"adminunjail": "[Admin] Vabasta kasutaja vangist",
|
||||||
|
"adminban": "[Admin] Keela kasutajal majandussüsteemis osaleda",
|
||||||
|
"adminunban": "[Admin] Eemalda majandussüsteemi keeld kasutajalt",
|
||||||
|
"adminreset": "[Admin] Lähtesta kasutaja majandusandmed",
|
||||||
|
"adminview": "[Admin] Vaata kasutaja majandusandmeid",
|
||||||
|
"adminexp": "[Admin] Anna v\u00f5i v\u00f5ta EXP kasutajalt",
|
||||||
|
"adminitem": "[Admin] Anna v\u00f5i eemalda ese kasutajalt (tasuta)",
|
||||||
|
"allowchannel": "[Admin] Lisa kanal, kus bot võib vastata käsklustele",
|
||||||
|
"denychannel": "[Admin] Eemalda kanal lubatud kanalite nimekirjast",
|
||||||
|
"channels": "[Admin] Näita lubatud kanalite nimekirja",
|
||||||
|
"economysetup": "[Admin] Loo ja sea korda majandussüsteemi rollid",
|
||||||
|
"blackjack": "Mängi blackjacki TipiBOTi vastu",
|
||||||
|
"prestige": "Prestiiži (nõuab taset 30) ja teeni Prestiižipunkte",
|
||||||
|
"prestigeshop": "Vaata prestiižipoodi ja sinu uuenduste taset",
|
||||||
|
"prestigebuy": "Osta prestiižiuuendus Prestiižipunktide eest",
|
||||||
|
"fish": "Mine kalastama (interaktiivne mäng, 2min ooteaeg)",
|
||||||
|
"fishbook": "Vaata oma kalakogu ja kogutud kalaliike",
|
||||||
|
"fishsell": "Müü kalu oma inventarist",
|
||||||
|
"patchnotes": "Vaata TipiBOTi viimaseid muudatusi ja uuendusi",
|
||||||
|
"quests": "Vaata oma päeva- ja nädalaülesandeid ning nõua auhinnad",
|
||||||
|
"consumables": "Sirvi ja osta turgutusi (korduvostetavad boostid)",
|
||||||
|
"vanity": "Staatusepood - osta ja kanna kosmeetilisi tiitleid",
|
||||||
|
"lootbox": "Ava õnnekast - juhuslik auhind müntide või boonuse näol",
|
||||||
|
"achievements": "Vaata oma saavutusi ja teeni märkide eest ühekordseid preemiaid",
|
||||||
|
"bank": "Vaata oma panka - röövikindel hoius",
|
||||||
|
"deposit": "Pane münte panka (röövikindel, aga ei teeni intressi)",
|
||||||
|
"withdraw": "Võta münte pangast rahakotti",
|
||||||
|
"lottery": "Vaata TipiLOTO potti või osta pileteid (tühjaks jättes näeb infot)",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Option descriptions (shown next to each parameter in Discord)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
OPT: dict[str, str] = {
|
||||||
|
"admin_kasutaja": "Kasutaja",
|
||||||
|
"admin_põhjus": "Põhjus (saadetakse kasutajale DM kaudu)",
|
||||||
|
"admincoins_kogus": "Positiivne = anna, negatiivne = võta",
|
||||||
|
"adminjail_minutid": "Vangistamise kestus minutites",
|
||||||
|
"balance_kasutaja": "Mängija, kelle saldot vaadata (vaikimisi sina)",
|
||||||
|
"roulette_panus": "Panus TipiCOINides ('all' = kogu saldo)",
|
||||||
|
"roulette_värv": "Punane, must või roheline",
|
||||||
|
"give_kasutaja": "Kellele annad?",
|
||||||
|
"give_summa": "Kui palju annad? ('all' = kogu saldo)",
|
||||||
|
"buy_ese": "Eseme nimi (vaata /shop)",
|
||||||
|
"deposit_summa": "Kui palju panna panka? ('all' = kogu vaba raha)",
|
||||||
|
"withdraw_summa": "Kui palju pangast välja võtta? ('all' = kogu pangas)",
|
||||||
|
"lottery_kogus": "Mitu piletit osta (tühjaks jättes näeb potti)",
|
||||||
|
"consumable_ese": "Turgutus, mida osta (tühjaks jättes näeb menüüd)",
|
||||||
|
"vanity_ese": "Tiitel, mida osta või kanda (tühjaks jättes näeb poodi)",
|
||||||
|
"rps_panus": "Valikuline TipiCOINide panus ('all' = kogu saldo)",
|
||||||
|
"rps_vastane": "Väljakutse teisele mängijale (PvP)",
|
||||||
|
"slots_panus": "Panus TipiCOINides ('all' = kogu saldo)",
|
||||||
|
"request_summa": "Kui palju TipiCOINe vajad?",
|
||||||
|
"request_põhjus": "Miks sa vajad TipiCOINe?",
|
||||||
|
"request_sihtmärk": "Valikuline: kellelt täpselt palud (teised ei saa toetada)",
|
||||||
|
"send_kanal": "Kanal, kuhu sõnum saata",
|
||||||
|
"send_sõnum": "Sõnum, mida saata",
|
||||||
|
"allowchannel_kanal": "Kanal, kus bot võib vastata käsklustele",
|
||||||
|
"denychannel_kanal": "Kanal, kus bot ei või vastata käsklustele",
|
||||||
|
"rob_sihtmärk": "Kellelt röövid?",
|
||||||
|
"member_user": "Mängija, kelle infot tahad vaadata",
|
||||||
|
"rank_kasutaja": "Mängija, kelle taset tahad vaadata (vaikimisi sina)",
|
||||||
|
"stats_kasutaja": "Mängija, kelle statistikat tahad vaadata (vaikimisi sina)",
|
||||||
|
"adminseason_top_n": "Kui palju mängijaid võitis (vaikimisi 10)",
|
||||||
|
"blackjack_panus": "Panus TipiCOINides ('all' = kogu saldo)",
|
||||||
|
"prestigebuy_upgrade": "Uuenduse ID (vaata /prestigeshop)",
|
||||||
|
"fishbook_kasutaja": "Mängija, kelle kalakogu vaadata (vaikimisi sina)",
|
||||||
|
"profile_kasutaja": "Mängija, kelle profiili vaadata (vaikimisi sina)",
|
||||||
|
"adminexp_kogus": "Positiivne = anna, negatiivne = võta",
|
||||||
|
"adminitem_ese": "Eseme ID (kasutatav sisse, vaata /shop)",
|
||||||
|
"adminitem_tegevus": "'anna' või 'eemalda'",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /help categories
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
HELP_CATEGORIES: dict[str, dict] = {
|
||||||
|
"üldine": {
|
||||||
|
"label": "🤖 Üldine",
|
||||||
|
"description": "Üldised käsklused",
|
||||||
|
"color": 0x57F287,
|
||||||
|
"fields": [
|
||||||
|
("/ping", "Vaata, kas bot on üleval"),
|
||||||
|
("/status", "Näita boti staatust ja ressursside kasutust"),
|
||||||
|
("/help", "Nimekiri kõikidest käskudest kategooriate kaupa"),
|
||||||
|
("/birthdays", "Näita kõigi liikmete sünnipäevi kuu järgi"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"tipibot": {
|
||||||
|
"label": "🪙 TipiBOT",
|
||||||
|
"description": "TipiCOIN majandus",
|
||||||
|
"color": 0xF4C430,
|
||||||
|
"fields": [
|
||||||
|
("/profile [@user]", "Saldo, tase, EXP progress, prestiiz - kõik ühes kohas. Nupud: Esemed · Statistika · Kalakogu."),
|
||||||
|
("/cooldowns", "Vaata kõikide käskude ooteaegu. Näitab ka vangla ooteaega."),
|
||||||
|
("/daily", "Võta enda päevane TipiCOINide boonus. 20h ooteaeg. Streak'i boonus: 3d=+50%, 7d=+100%, 14d=+200%."),
|
||||||
|
("/work", "Tööta ja teeni TipiCOINe (1h ooteaeg)"),
|
||||||
|
("/beg", "Kerja TipiCOINe (5min ooteaeg)"),
|
||||||
|
("/crime", "Proovi oma õnne kriminaalse tegevusega. 60% edu, 40% trahv + 30min vanglas. 2h ooteaeg."),
|
||||||
|
("/rob @user", "Proovi kelleltki TipiCOINe varastada. 45% edu. Ebaõnnestumisel saad trahvi."),
|
||||||
|
("/heist", "Alusta grupiröövi pangahoidlasse. Min 2 mängijat, max 8. 5 min ühinemisaeg. Õnnestumisel jagatakse saak võrdselt - ebaõnnestumisel 1h 30min vangis + trahv. 4h serveri ooteaeg (ei ole isiklik)."),
|
||||||
|
("/jailbreak", "Proovi vanglas olles täringuid visata, et duublit saada (3 katset). Duubli korral saad vabaks. Ebaõnnestumisel saad valida: maksa kautsjon (20-30% saldost, min 350 ⬡) või jää vanglasse kuni aja lõpuni."),
|
||||||
|
("/give @user <amount>", "Anna TipiCOINe teisele mängijale"),
|
||||||
|
("/bank", "Vaata oma panka. Panka pandud mündid on röövikindlad (/rob ja /heist ei puuduta)."),
|
||||||
|
("/deposit <amount>", "Pane münte panka (röövikindel hoius, ei teeni intressi)."),
|
||||||
|
("/withdraw <amount>", "Võta münte pangast rahakotti."),
|
||||||
|
("/quests", "Vaata oma päeva- ja nädalaülesandeid ning nõua auhinnad (uueneb iga päev/nädal)."),
|
||||||
|
("/achievements", "Vaata oma saavutusi. Iga lukust lahti saanud märk annab ühekordse müntipreemia."),
|
||||||
|
("/lottery [kogus]", "Vaata TipiLOTO potti või osta pileteid. Loosimine iga päev - üks võitja saab kogu poti (rohkem pileteid = suurem võiduvõimalus)."),
|
||||||
|
("/leaderboard", "TipiBOTi edetabel - kes on kõige rikkam?"),
|
||||||
|
("/shop", "Sirvi TipiBOTi poodi"),
|
||||||
|
("/buy <item>", "Osta ese TipiBOTi poodist"),
|
||||||
|
("/consumables", "Osta korduvostetavaid turgutusi (ajutised boostid)."),
|
||||||
|
("/lootbox", "Ava õnnekast (1000 ⬡) - juhuslik auhind: mündid või ajutine boonus."),
|
||||||
|
("/vanity", "Staatusepood - osta ja kanna kosmeetilisi tiitleid (näha /profile-l)."),
|
||||||
|
("/request <amount> <reason> [target]", "Saada crowdfundingu taotlus. Keegi saab 'Toeta' nuppu vajutades raha kanda (taotlus kehtib 5 minutit)."),
|
||||||
|
("/reminders", "DM meeldetuletused on vaikimisi sees. Kasuta seda käsku, et lülitada sisse/välja, milliseid käsklusi meelde tuletada."),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"shop": {
|
||||||
|
"label": "🛍️ Pood",
|
||||||
|
"description": "TipiBOTi poe esemed ja nende efektid",
|
||||||
|
"color": 0xF4C430,
|
||||||
|
"fields": [
|
||||||
|
(f"{E['TipiHIIR']} Mängurihiir - 500 ⬡", "Teeni töötades 50% rohkem TipiCOINe."),
|
||||||
|
(f"{E['TipiMATT']} XL hiirematt - 600 ⬡", "Kerjamise ooteaeg 5min → 3min."),
|
||||||
|
(f"{E['TipiKLAPID']} Kõrvaklapid - 1200 ⬡", "Päevase boonuse ooteaeg 20h → 18h."),
|
||||||
|
(f"{E['TipiPILET']} LAN pilet (2025) - 1200 ⬡", "Päevane boonus on duubeldatud."),
|
||||||
|
(f"{E['TipiVAC']} Anticheat - 750 ⬡", "Röövimine sinu vastu ebaõnnestub. Pärast 2 kasutust pead ostma uue."),
|
||||||
|
(f"{E['TipiBULL']} Red Bull - 800 ⬡", "30% tõenäosus, et teenid töötades 3x rohkem."),
|
||||||
|
(f"{E['TipiLAP']} Botikoobas - 1500 ⬡", "RTX 5090 jooksutab botte 24/7. Päevane boonus genereerib 5% intressi sinu saldo pealt."),
|
||||||
|
(f"{E['TipiLAUD']} Reguleeritav laud - 3500 ⬡ *(T2)*", "/work teenib 25% rohkem (stackib mängurihiirega)."),
|
||||||
|
(f"{E['TipiSERVER']} Jellyfin server - 4000 ⬡ *(T2)*", "Röövimise edu tõenäosus 45% → 60%."),
|
||||||
|
(f"{E['TipiMIC']} Mikrofon - 2800 ⬡ *(T2)*", "Teeni 30% rohkem eduka /crime puhul."),
|
||||||
|
(f"{E['TipiKLAVA']} Mehhaaniline klaviatuur - 1800 ⬡ *(T2)*", "/beg teenib 2x rohkem."),
|
||||||
|
(f"{E['TipiMONITOR']} Ultralai monitor - 2500 ⬡ *(T2)*", "/work ooteaeg: 1h → 40min."),
|
||||||
|
(f"{E['TipiCAT']} CAT6 netikaabel - 3500 ⬡ *(T2)*", "/crime edu tõenäosus tõuseb 60% → 75%."),
|
||||||
|
(f"{E['TipiMONITOR2']} 360hz monitor - 7500 ⬡ *(T3)*", "Mänguautomaadi jackpot 10x → 15x, kolmik 4x → 6x."),
|
||||||
|
(f"{E['TipiKARIKAS']} TipiLANi trofee - 6000 ⬡ *(T3)*", "Streak ei nulli, kui sa mõne päeva vahele jätad."),
|
||||||
|
(f"{E['TipiTOOL']} Mänguritool - 9000 ⬡ *(T3)*", "/crime ebaõnnestumine ei saada sind vanglasse."),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"games": {
|
||||||
|
"label": "🎮 Mängud",
|
||||||
|
"description": "Lõbusad mängud",
|
||||||
|
"color": 0x5865F2,
|
||||||
|
"fields": [
|
||||||
|
("/roulette <bet> <colour>", "Panusta TipiCOINe punasele, mustale või rohelisele (1/37 võimalus, 14x payout)"),
|
||||||
|
("/rps [bet] [opponent]", "Kivi-paber-käärid mõne teise mängija vastu"),
|
||||||
|
("/slots <bet>", "Proovi oma õnne TipiBOTi mänguautomaadiga"),
|
||||||
|
("/blackjack <bet>", "Mängi blackjacki TipiBOTi vastu. Blackjack maksab 3:2. Kakskordistamine (double down) on võimalik vaid esimese käigu ajal."),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"label": "🔧 Admin",
|
||||||
|
"description": "Admin käsklused (peidetud tavaliste kasutajate eest)",
|
||||||
|
"color": 0xED4245,
|
||||||
|
"fields": [
|
||||||
|
("/check", "Laadi andmed, täida ID'd ja sünkroniseeri kõik liikmed"),
|
||||||
|
("/member @user", "Näita liikme andmeid tabelist"),
|
||||||
|
("/sync", "Sünkroniseeri käsklused Discordi serveriga"),
|
||||||
|
("/restart", "Tee taaskäivitus botile"),
|
||||||
|
("/send #channel message", "Saada sõnum valitud kanalisse"),
|
||||||
|
("/admincoins @user <amount> <reason>", "Anna või võta TipiCOINe kasutajale/kasutajalt"),
|
||||||
|
("/adminjail @user <minutes> <reason>", "Saada kasutaja vangi"),
|
||||||
|
("/adminunjail @user", "Vabasta kasutaja vangist"),
|
||||||
|
("/adminban @user <reason>", "Keela kasutajal majandussüsteemis osaleda"),
|
||||||
|
("/adminunban @user", "Eemalda majandussüsteemi keeld kasutajalt"),
|
||||||
|
("/adminreset @user <reason>", "Lähtesta kasutaja majandusandmed"),
|
||||||
|
("/adminview @user", "Vaata kasutaja majandusandmeid"),
|
||||||
|
("/allowchannel #channel", "Lisa kanal, kus bot võib vastata käsklustele"),
|
||||||
|
("/denychannel #channel", "Eemalda kanal lubatud kanalite nimekirjast"),
|
||||||
|
("/channels", "Näita lubatud kanalite nimekirja"),
|
||||||
|
("/adminseason [top_n]", "Lõpeta võistlus, teavita võitjaid ja lähtesta EXP"),
|
||||||
|
("/economysetup", "Loo ja sea korda majandussüsteemi rollid (ECONOMY + taseme rollid) boti rolli alla"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /help UI
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
HELP_UI: dict[str, str] = {
|
||||||
|
"footer": "TipiBOT • Vali kategooria allmenüüst",
|
||||||
|
"select_placeholder": "Vali kategooria...",
|
||||||
|
}
|
||||||
251
strings/common.py
Normal file
251
strings/common.py
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
"""Shared / system-wide strings (messages, titles, errors, cooldowns).
|
||||||
|
|
||||||
|
Auto-split from the original strings.py; edit strings here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from core.emoji import EMOJI as E
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'MSG_BANNED',
|
||||||
|
'MSG_SPAM_JAIL',
|
||||||
|
'MSG_PONG',
|
||||||
|
'MSG_RESTART_DONE',
|
||||||
|
'MSG_RESTARTING',
|
||||||
|
'MSG_SHUTTING_DOWN',
|
||||||
|
'MSG_PAUSED',
|
||||||
|
'MSG_UNPAUSED',
|
||||||
|
'MSG_MAINTENANCE',
|
||||||
|
'MSG_SYNC_DONE',
|
||||||
|
'MSG_REMINDER',
|
||||||
|
'MSG_LEVELUP',
|
||||||
|
'MSG_LEVELUP_ROLE',
|
||||||
|
'REMINDER_OPTS',
|
||||||
|
'TITLE',
|
||||||
|
'ERR',
|
||||||
|
'CD_MSG',
|
||||||
|
'REMINDERS_UI',
|
||||||
|
'SEND_UI',
|
||||||
|
'PATCHNOTES_UI',
|
||||||
|
'STATUS_UI',
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Repeated system messages
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
MSG_BANNED = "🚫 Sul keelati TipiBOTi majandussüsteemis osaleda."
|
||||||
|
MSG_SPAM_JAIL = "⚠️ Liiga kiire! Automaatsed skriptid/spam on keelatud. Oled **30 minutit vangis**. Kasuta `/jailbreak`, et varem välja pääseda."
|
||||||
|
MSG_PONG = "🏓 Pong!"
|
||||||
|
MSG_RESTART_DONE = "✅ Bot on taaskäivitatud!"
|
||||||
|
MSG_RESTARTING = "🔄 Taaskäivitan..."
|
||||||
|
MSG_SHUTTING_DOWN = "⛔ Lülitan boti välja..."
|
||||||
|
MSG_PAUSED = "⏸️ Bot on hooldusrežiimis - kõik käsklused on peatatud."
|
||||||
|
MSG_UNPAUSED = "▶️ Hooldusrežiim lõpetatud - käsklused on taas saadaval."
|
||||||
|
MSG_MAINTENANCE = "🔧 Bot on hetkel hoolduses. Proovi mõne hetke pärast uuesti."
|
||||||
|
MSG_SYNC_DONE = "✅ Käsud sünkroniseeritud!"
|
||||||
|
MSG_REMINDER = "⏰ **/{cmd}** on taas kasutamiseks valmis!\n-# Meeldetuletuste haldamiseks kasuta serveris `/reminders`."
|
||||||
|
MSG_LEVELUP = "⬆️ **{name}** jõudis **{level}. tasemele**!{extra}"
|
||||||
|
MSG_LEVELUP_ROLE = " 🎊 Uus tiitel: **{role}**!"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /reminders dropdown options (cmd_key, label, description)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
REMINDER_OPTS: list[tuple[str, str, str]] = [
|
||||||
|
("daily", "📅 /daily", "Päevane boonus (20t ooteaeg)"),
|
||||||
|
("work", "💼 /work", "Töö (1t ooteaeg, 40min monitoriga)"),
|
||||||
|
("beg", "🙏 /beg", "Kerjamine (5min ooteaeg)"),
|
||||||
|
("crime", "🦹 /crime", "Kuritegu (2t ooteaeg)"),
|
||||||
|
("rob", "🔫 /rob", "Rööv (2t ooteaeg)"),
|
||||||
|
("fish", "🎣 /fish", "Kalapüük (2min ooteaeg)"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Embed titles
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TITLE: dict[str, str] = {
|
||||||
|
"daily": "📅 Päevane boonus",
|
||||||
|
"work": "💼 Töö",
|
||||||
|
"beg": "🙏 Kerjamine",
|
||||||
|
"crime_win": f"{E['TipiFIRE']} Kuritegu õnnestus!",
|
||||||
|
"crime_fail": f"{E['TipiTROLL']} Vahele jäid!",
|
||||||
|
"rob_win": f"{E['TipiFIRE']} Rööv õnnestus!",
|
||||||
|
"rob_fail": f"{E['TipiTROLL']} Rööv ebaõnnestus!",
|
||||||
|
"rob_anticheat": f"{E['TipiVAC']} Anticheat peatas sind!",
|
||||||
|
"jailbreak": "🎲 Vanglast põgenemine",
|
||||||
|
"jailbreak_free": f"🎲 {E['TipiFIRE']} DUUBEL! Oled vaba!",
|
||||||
|
"jailbreak_fail": f"{E['TipICRY']} Kolm katset läbi!",
|
||||||
|
"jailbreak_miss": "🎲 " + E["TipICRY"] + " Ei saanud duublit ({tries}/{max})",
|
||||||
|
"jailbreak_bail": "💸 Kautsjon",
|
||||||
|
"give": f"{E['TipiHEART']} TipiCOINi ülekanne",
|
||||||
|
"stats": "📊 Mängustatistika",
|
||||||
|
"leaderboard_coins": "🪙 TipiBOTi edetabel - Mündid",
|
||||||
|
"leaderboard_exp": "📊 TipiBOTi edetabel - EXP / Tase",
|
||||||
|
"leaderboard_season": "🏆 TipiBOTi edetabel - Hooaja EXP",
|
||||||
|
"leaderboard_prestige": f"{E['TipiFIRE']} TipiBOTi edetabel - Prestiiž",
|
||||||
|
"leaderboard_wagered": "🎲 TipiBOTi edetabel - Hasartmängud",
|
||||||
|
"leaderboard_fish": "🎣 TipiBOTi edetabel - Kalapüük",
|
||||||
|
"rps": "⚔️ Kivi, Paber, Käärid",
|
||||||
|
"rps_duel": "⚔️ KPK duell",
|
||||||
|
"rps_duel_active": "⚔️ KPK duell - käimas",
|
||||||
|
"rps_duel_done": "⚔️ KPK duell - lõppenud",
|
||||||
|
"rps_duel_cancel": "⚔️ KPK duell - tühistatud",
|
||||||
|
"rps_duel_expire": "⚔️ KPK duell - aegus",
|
||||||
|
"rps_duel_decline": "⚔️ KPK duell - keelduti",
|
||||||
|
"heist_lobby": "🔫 Grupirööv - kogunemine",
|
||||||
|
"heist_win": f"{E['TipiFIRE']} Grupirööv õnnestus!",
|
||||||
|
"heist_fail": f"{E['TipiSKULL']} Grupirööv ebaõnnestus!",
|
||||||
|
"heist_cancel": "🔫 Grupirööv tühistatud",
|
||||||
|
"request": f"{E['TipiHEART']} Rahataotlus",
|
||||||
|
"reminders": "⏰ Meeldetuletused",
|
||||||
|
"cooldowns": "⏱️ Sinu ooteajad",
|
||||||
|
"adminseason": "🏆 Hooaeg lõppes!",
|
||||||
|
"economysetup": "⚙️ Majanduse seadistamine",
|
||||||
|
"blackjack": "🃏 Blackjack",
|
||||||
|
"blackjack_bj": f"🃏 {E['TipiFIRE']} BLACKJACK!",
|
||||||
|
"blackjack_win": f"{E['TipiFIRE']} Võitsid!",
|
||||||
|
"blackjack_lose": f"{E['TipiSKULL']} Kaotasid!",
|
||||||
|
"blackjack_bust": f"{E['TipiSKULL']} Üle 21 - kaotasid!",
|
||||||
|
"blackjack_push": "🤝 Viik!",
|
||||||
|
"blackjack_dbust": f"{E['TipiSKULL']} Üle 21 - mõlemad kaotasid!",
|
||||||
|
"blackjack_dwin": f"{E['TipiFIRE']} Topeltpanus võitis!",
|
||||||
|
"prestige_confirm": "🔥 Prestiiž - kinnita",
|
||||||
|
"prestige_success": E["TipiFIRE"] + " Prestiiž {level} saavutatud!",
|
||||||
|
"prestige_too_low": "❌ Prestiiž pole saadaval",
|
||||||
|
"prestige_shop": f"{E['TipiFIRE']} Prestiižipood",
|
||||||
|
"prestige_buy_ok": "✅ Uuendus ostetud!",
|
||||||
|
"fish_cast": "🎣 Otsid kala...",
|
||||||
|
"fish_bite": "🐟 KALA NÄKKAB!",
|
||||||
|
"fish_escape": "🎣 Kala pääses!",
|
||||||
|
"fish_junk": "🗑️ Ai ai ai...",
|
||||||
|
"fishbook": "📖 Kalakogu",
|
||||||
|
"quests": "🎯 Ülesanded",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Error / validation messages (use .format(**kwargs) for dynamic parts)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
ERR: dict[str, str] = {
|
||||||
|
"positive_bet": "❌ Panus peab olema positiivne.",
|
||||||
|
"positive_amount": "❌ Summa peab olema positiivne.",
|
||||||
|
"positive_duration": "❌ Aeg peab olema positiivne.",
|
||||||
|
"admincoins_zero": "Kogus ei tohi olla 0.",
|
||||||
|
"missing_perms": "❌ Sul on vaja **Rollide haldamise** õigust selle käsu kasutamiseks.",
|
||||||
|
"generic_error": "❌ Tekkis viga: ```{error}```",
|
||||||
|
"send_failed": "❌ Sõnumi saatmine ebaõnnestus: ```{error}```",
|
||||||
|
"rob_self": "❌ Sa ei saa iseennast röövida.",
|
||||||
|
"rob_bot": "❌ Botti ei saa röövida.",
|
||||||
|
"rob_no_house": "❌ Kassa pole veel avatud.",
|
||||||
|
"rob_house_blocked": "❌ Panga seif on liiga hästi kaitstud. Kasuta `/heist` selle rüüstamiseks.",
|
||||||
|
"rob_too_poor": "❌ **{name}** on liiga vaene röövimiseks.",
|
||||||
|
"rob_target_jailed": "❌ **{name}** on vanglas - vabad inimesed ei saa vanglas olevaid röövida.",
|
||||||
|
"heist_active": "❌ Serveris on juba aktiivne grupirööv käimas! Oota, kuni see lõpeb.",
|
||||||
|
"heist_full": "❌ Grupirööv on täis!",
|
||||||
|
"heist_min_players": "❌ Grupiröövi alustamiseks on vaja vähemalt **{min}** osalejat.",
|
||||||
|
"broke": E["TipICRY"] + " Sul pole piisavalt TipiCOINe. Saldo: {bal}",
|
||||||
|
"broke_need": E["TipICRY"] + " Sul pole piisavalt TipiCOINe. Vajad veel {need}.",
|
||||||
|
"item_owned": "❌ Sul on see ese juba olemas.",
|
||||||
|
"item_not_found": "❌ Eset ei leitud.",
|
||||||
|
"item_level_req": "🔒 Selle eseme ostmiseks vajad **taset {min_level}** (sul on tase {user_level}). Teeni EXP-id kõiki käske kasutades.",
|
||||||
|
"not_your_game": "❌ See pole sinu mäng!",
|
||||||
|
"game_in_progress": "❌ Sul on juba mäng käimas! Lõpeta see enne.",
|
||||||
|
"not_your_challenge":"❌ See väljakutse pole sulle!",
|
||||||
|
"not_your_menu": "❌ See ei ole sinu menüü.",
|
||||||
|
"give_self": "❌ Sa ei saa iseendale TipiCOINe anda.",
|
||||||
|
"give_bot": "❌ Botile ei saa TipiCOINe anda.",
|
||||||
|
"give_jailed": "❌ Oled vangis - vanglas ei saa TipiCOINe anda. Pääsed välja {ts}.",
|
||||||
|
"rps_self": "❌ Sa ei saa iseendale väljakutset esitada.",
|
||||||
|
"rps_bot": "❌ Botid on KPK-is liiga head.",
|
||||||
|
"not_jailed": "❌ Sa pole praegu vangis.",
|
||||||
|
"not_in_leaderboard":"❌ Sind pole veel edetabelis.",
|
||||||
|
"admin_ban_bot": "❌ Botti ei saa bannida.",
|
||||||
|
"admin_reset_bot": "❌ Kassa andmeid ei saa lähtestada.",
|
||||||
|
"member_not_found": "❌ **{name}** ei leitud tabelist.",
|
||||||
|
"request_self_fund": "❌ Sa ei saa oma taotlust ise rahastada.",
|
||||||
|
"request_self": "❌ Sa ei saa iseendalt anuda.",
|
||||||
|
"request_bot": "❌ Botid on teatavasti kitsid.",
|
||||||
|
"request_targeted": "❌ See taotlus on suunatud kasutajale **{name}**.",
|
||||||
|
"request_closed": "❌ See taotlus on juba rahastatud või aegunud.",
|
||||||
|
"already_in_game": "❌ Sul on juba aktiivne mäng käimas!",
|
||||||
|
"invalid_amount": "❌ Sisesta kehtiv summa või 'all'.",
|
||||||
|
"fund_range": "❌ Sisesta summa vahemikus 1-{max}.",
|
||||||
|
"channel_only": "❌ Boti käske saab kasutada ainult nendes kanalites: {channels}",
|
||||||
|
"guild_only": "Seda käsku saab kasutada ainult serveris.",
|
||||||
|
"sheet_error": "❌ Tabeli laadimine ebaõnnestus: ```{error}```",
|
||||||
|
"gamble_cooldown": "🎰 Oled just mänginud! Saad uuesti mängida {ts}.",
|
||||||
|
"payout_failed": "⚠️ Tehniline viga võidu väljamaksmisel - see on logitud ja admin taastab su TipiCOINid. Vabandame!",
|
||||||
|
"db_error": "⚠️ Andmebaas ei vasta praegu. Proovi hetke pärast uuesti.",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cooldown messages (use .format(ts=...) - ts is a Discord timestamp string)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CD_MSG: dict[str, str] = {
|
||||||
|
"daily": "⏳ Järgmine boonus {ts}.",
|
||||||
|
"work": "⏳ Saad töötada {ts}.",
|
||||||
|
"beg": "⏳ Saad kerjata {ts}.",
|
||||||
|
"crime": "⏳ Saad uuesti proovida {ts}.",
|
||||||
|
"rob": "⏳ Saad uuesti röövida {ts}.",
|
||||||
|
"heist": "⏳ Saad uuesti heisti teha {ts}.",
|
||||||
|
"heist_global": "⏳ Pangahoidla alles kosub eelmisest röövist. Järgmine heist võimalik {ts}.",
|
||||||
|
"jailed": E["TipiTROLL"] + " Oled vangis! Pääsed välja {ts}. Kasuta `/jailbreak`, et varem välja pääseda.",
|
||||||
|
"fish": "🎣 Saad uuesti kalastada {ts}.",
|
||||||
|
}
|
||||||
|
REMINDERS_UI: dict[str, str] = {
|
||||||
|
"select_placeholder": "Vali käsud, millest soovid meeldetuletust...",
|
||||||
|
"saved_on": "✅ Meeldetuletused sisse lülitatud: {names}",
|
||||||
|
"saved_off": "🔕 Kõik meeldetuletused välja lülitatud.",
|
||||||
|
"desc_active": "Praegu aktiivne: {status}\n\nMuuda valikut allpool.",
|
||||||
|
"desc_none": "Hetkel pole ühtegi meeldetuletust sisse lülitatud.\n\nVali allpool, millest soovid DM-i saada.",
|
||||||
|
"footer": "Bot DM-ib sulle, kui valitud käsk on taas kasutamiseks valmis.",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /send UI strings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SEND_UI: dict[str, str] = {
|
||||||
|
"sent": "✅ Sõnum saadetud kanalisse {channel}!",
|
||||||
|
"forbidden": "❌ Mul pole õigust kanalisse {channel} kirjutada.",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /patchnotes UI strings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PATCHNOTES_UI: dict[str, str] = {
|
||||||
|
"title": "📝 Muudatuste logi — {version}",
|
||||||
|
"footer": "Versioon {idx}/{total}",
|
||||||
|
"btn_newer": "◀ Uuem",
|
||||||
|
"btn_older": "Vanem ▶",
|
||||||
|
"select_placeholder": "Vali versioon…",
|
||||||
|
"empty_file": "ℹ️ Muudatuste logi on hetkel tühi.",
|
||||||
|
"empty_version": "_(selle versiooni kohta märkmeid pole)_",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /status UI
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
STATUS_UI: dict[str, str] = {
|
||||||
|
"title": "🖥️ Boti olek",
|
||||||
|
"uptime_field": "🕐 Uptime",
|
||||||
|
"uptime_val": "{hours}t {minutes}m {seconds}s",
|
||||||
|
"latency_field": "📡 Latency",
|
||||||
|
"latency_val": "{ms} ms",
|
||||||
|
"ram_field": "🧠 RAM (RSS)",
|
||||||
|
"ram_val": "{mb} MB",
|
||||||
|
"cpu_field": "⚙️ CPU",
|
||||||
|
"cpu_val": "{percent}%",
|
||||||
|
"tasks_field": "🔄 Async tasks",
|
||||||
|
"eco_players_field": "👤 Eco players",
|
||||||
|
"members_cache_field": "📋 Liikmed (cache)",
|
||||||
|
"supply_field": "💰 Rahavaru",
|
||||||
|
"supply_val": "Kokku {total}\nMängijatel {players}\nKassas {house} ({house_pct}%)",
|
||||||
|
"log_files_field": "📂 Log files",
|
||||||
|
"log_line": "`{name}` - {size_kb} KB",
|
||||||
|
"none": "-",
|
||||||
|
}
|
||||||
552
strings/economy.py
Normal file
552
strings/economy.py
Normal file
@@ -0,0 +1,552 @@
|
|||||||
|
"""Core economy: income, profile, shop, quests, leaderboard, requests.
|
||||||
|
|
||||||
|
Auto-split from the original strings.py; edit strings here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from core.emoji import EMOJI as E
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'WORK_JOBS',
|
||||||
|
'BEG_LINES',
|
||||||
|
'BEG_JAIL_LINES',
|
||||||
|
'CRIME_WIN',
|
||||||
|
'CRIME_LOSE',
|
||||||
|
'QUEST_UI',
|
||||||
|
'QUEST_DESCRIPTIONS',
|
||||||
|
'SHOP_UI',
|
||||||
|
'ITEM_DESCRIPTIONS',
|
||||||
|
'CONSUMABLES_UI',
|
||||||
|
'CONSUMABLE_DESCRIPTIONS',
|
||||||
|
'VANITY_UI',
|
||||||
|
'LOOTBOX_UI',
|
||||||
|
'BANK_UI',
|
||||||
|
'ACHIEVEMENTS_UI',
|
||||||
|
'LOTTERY_UI',
|
||||||
|
'JAILED_UI',
|
||||||
|
'SHOP_BTN',
|
||||||
|
'DAILY_UI',
|
||||||
|
'STATS_UI',
|
||||||
|
'PROFILE_UI',
|
||||||
|
'BALANCE_UI',
|
||||||
|
'COOLDOWNS_UI',
|
||||||
|
'RANK_UI',
|
||||||
|
'WORK_UI',
|
||||||
|
'BEG_UI',
|
||||||
|
'CRIME_UI',
|
||||||
|
'ROB_UI',
|
||||||
|
'GIVE_UI',
|
||||||
|
'BUY_UI',
|
||||||
|
'LEADERBOARD_UI',
|
||||||
|
'REQUEST_UI',
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Flavour text
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
WORK_JOBS: list[tuple[str, float]] = [
|
||||||
|
("paigaldasid aulas netikaableid", 1.0),
|
||||||
|
("paigaldasid aulas voolukaableid", 1.0),
|
||||||
|
("tõid poest korraldajatele pitsat", 1.0),
|
||||||
|
("konfigureerisid uue switchi", 1.0),
|
||||||
|
("vedasid laudu aulasse", 1.0),
|
||||||
|
("vedasid toole aulasse", 1.0),
|
||||||
|
("paigaldasid aulasse vahekardinaid", 1.0),
|
||||||
|
("aitasid üles seada turniiri lava", 1.0),
|
||||||
|
("töötasid baaris", 1.0),
|
||||||
|
("testisid helisüsteemi \"Erootikapoodi\" blastides", 1.0),
|
||||||
|
("juhendasid külastajaid infolauas", 1.0),
|
||||||
|
("seadistasid LAN serverit", 1.0),
|
||||||
|
("paigaldasid monitore", 1.0),
|
||||||
|
("ühendasid aulas kilpe", 1.0),
|
||||||
|
("aitasid LANil osalejat", 1.0),
|
||||||
|
("müüsid energiajooke", 1.0),
|
||||||
|
("tegid netikaableid", 1.0),
|
||||||
|
("lahendasid IP-konflikti", 1.0),
|
||||||
|
("parandasid kellegi arvutit", 1.0),
|
||||||
|
("tegid korda striimi heli (mitte nagu PGL)", 1.2),
|
||||||
|
("kirjutasid koodi miniturniiri jaoks", 1.2),
|
||||||
|
("võitsid turniiri", 1.5),
|
||||||
|
]
|
||||||
|
BEG_LINES: list[str] = [
|
||||||
|
"istusid kurva näoga oma monitori taga",
|
||||||
|
"palusid abi kellegi DMis",
|
||||||
|
"postitasid #abi kanalisse",
|
||||||
|
"vaatasid kadedusega teiste asju",
|
||||||
|
"kirjutasid /beg kolmandat korda järjest",
|
||||||
|
"küsisid oma tiimiliikmelt laenu",
|
||||||
|
"võtsid kiirlaenu",
|
||||||
|
"müüsid 3 sendi eest oma CSi skinne",
|
||||||
|
"korjasid kampuselt taarat",
|
||||||
|
"tegid TikToki live'i",
|
||||||
|
"refreshisid pangaäppi lootuses, et raha tekib",
|
||||||
|
"kirjutasid \"pls donate\" chatti",
|
||||||
|
"vaatasid oma tühja rahakotti",
|
||||||
|
"püüdsid kedagi guilt-trippida",
|
||||||
|
"lubasid exposure'it raha eest",
|
||||||
|
"mängisid kurba muusikat taustaks",
|
||||||
|
"teesklesid, et see on sotsiaalne eksperiment",
|
||||||
|
"kirjutasid emotsionaalse loo oma olukorrast",
|
||||||
|
"pakkusid vastu future equity't",
|
||||||
|
"tegite sõbraga koos kerjamisplaani",
|
||||||
|
"otsisid diivani vahelt sente",
|
||||||
|
"lubasid boostida kellegi ranki viieka eest",
|
||||||
|
"küsisid chatist, kas keegi teab kust tasuta V-Buckse saab",
|
||||||
|
"avastasid, et su krüptoportfell on sügavas miinuses",
|
||||||
|
]
|
||||||
|
BEG_JAIL_LINES: list[str] = [
|
||||||
|
"karjusid läbi trellide, et keegi münte läbi lükkaks",
|
||||||
|
"kirjutasid kongi seina peale oma PayPali aadressi",
|
||||||
|
"lubasid, et maksad järgmine kord vangi minnes tagasi",
|
||||||
|
"veensid valvurit, et oled tegelikult hea inimene",
|
||||||
|
"saatsid vanglast käsitsi kirjutatud kirja oma meeskonnale",
|
||||||
|
"pakkusid vahile tulevaste vihjete eest raha",
|
||||||
|
"koputasid morset vastu naaberkongi seina",
|
||||||
|
"üritasid vangla WiFit häkkida",
|
||||||
|
"lubasid teha tasuta IT-tööd vabanemise eest",
|
||||||
|
"üritasid lusikaga tunnelit kaevata",
|
||||||
|
"kirjutasid bug reporti vangla süsteemile",
|
||||||
|
"proovisid trellidest läbi pugeda, aga jäid kinni",
|
||||||
|
"pakkusid vangivalvurile oma Steami kontot vabanemise eest",
|
||||||
|
]
|
||||||
|
CRIME_WIN: list[str] = [
|
||||||
|
"häkkisid turniiri tulemuste tabelit",
|
||||||
|
"varastasid oma vastase hiire",
|
||||||
|
"varastasid oma vastase klaviatuuri",
|
||||||
|
"varastasid oma vastase kõrvaklapid",
|
||||||
|
"varastasid oma vastase monitori",
|
||||||
|
"varastasid oma vastase mikrofoni",
|
||||||
|
"müüsid võltsitud LANi pileteid",
|
||||||
|
"laenatasid kellegi GPUd ja ei tagastanud seda",
|
||||||
|
"laenatasid Filmiklubilt AUX-kaablit ja ei tagastanud seda",
|
||||||
|
"käivitasid DDoS rünnaku serverile",
|
||||||
|
"tegid oma shitcoin'idega pump and dump'i",
|
||||||
|
"kasutasid aimbotti ja keegi ei märganud",
|
||||||
|
"exploitisid mängu bugi enda kasuks",
|
||||||
|
"phishisid kellegi Steami konto",
|
||||||
|
"varastasid prize pooli raha",
|
||||||
|
"ühendasid enda krüptokaevandaja serverisse",
|
||||||
|
"tegid petukõnesid"
|
||||||
|
]
|
||||||
|
CRIME_LOSE: list[str] = [
|
||||||
|
"jäid turniiri adminile vahele",
|
||||||
|
"tõmbasid oma arvuti kaabli pistikust välja",
|
||||||
|
"kukutasid lahtise Red Bulli omale sülle",
|
||||||
|
"unustasid VPNi sisse lülitada",
|
||||||
|
"lagisid kriitilisel momendil",
|
||||||
|
"said banni otseülekande ajal",
|
||||||
|
"unustasid logid kustutada",
|
||||||
|
"sinu skript crashis valel hetkel",
|
||||||
|
"said reportitud mitme mängija poolt",
|
||||||
|
"sinu krüptokaevandaja süttis põlema",
|
||||||
|
"flashisid tervet oma tiimi",
|
||||||
|
"avastasid, et Windows otsustas keset mängu uuendama hakata",
|
||||||
|
"jäid politseile petukõnedega vahele"
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Quest system (/quests)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
QUEST_UI: dict[str, str] = {
|
||||||
|
"daily_header": "📅 Päevaülesanded",
|
||||||
|
"weekly_header": "📆 Nädalaülesanded",
|
||||||
|
"progress": "⏳ {progress}/{max}",
|
||||||
|
"ready": "✅ Valmis - nõua auhind!",
|
||||||
|
"completed": "🏆 Nõutud",
|
||||||
|
"reward": "🎁 {coins} ⬡ · {exp} EXP",
|
||||||
|
"empty": "Ühtegi ülesannet pole. Proovi hiljem uuesti!",
|
||||||
|
"claim_btn": "🎁 Nõua auhinnad",
|
||||||
|
"claimed_msg": "🏆 Nõudsid {count} ülesande auhinnad: +{coins} · +{exp} EXP!",
|
||||||
|
"nothing": "Sul pole ühtegi valmis ülesannet, mida nõuda.",
|
||||||
|
"error": "❌ Ülesannete laadimine ebaõnnestus. Proovi hiljem uuesti.",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Estonian one-line description per quest id (keys match economy.QUESTS_*).
|
||||||
|
QUEST_DESCRIPTIONS: dict[str, str] = {
|
||||||
|
# Daily
|
||||||
|
"work3": "Tööta 3 korda (/work)",
|
||||||
|
"beg5": "Kerja 5 korda (/beg)",
|
||||||
|
"wager500": "Panusta kokku 500 ⬡ hasartmängudes",
|
||||||
|
"fish2": "Püüa 2 kala (/fish)",
|
||||||
|
"crime1": "Soorita edukalt 1 kuritegu (/crime)",
|
||||||
|
"earn1000": "Teeni kokku 1 000 ⬡",
|
||||||
|
"give200": "Kingi teistele kokku 200 ⬡ (/give)",
|
||||||
|
# Weekly
|
||||||
|
"work20": "Tööta 20 korda (/work)",
|
||||||
|
"fish15": "Püüa 15 kala (/fish)",
|
||||||
|
"wager5000": "Panusta kokku 5 000 ⬡ hasartmängudes",
|
||||||
|
"crime5": "Soorita edukalt 5 kuritegu (/crime)",
|
||||||
|
"heist1": "Osale 1 grupiröövis (/heist)",
|
||||||
|
"earn10000": "Teeni kokku 10 000 ⬡",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shop UI strings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SHOP_UI: dict[str, str] = {
|
||||||
|
"tier_1": "Tier 1 - Algaja",
|
||||||
|
"tier_2": "Tier 2 - Kogenud",
|
||||||
|
"tier_3": "Tier 3 - Legend",
|
||||||
|
"desc": "Saldo: {bal} · Osta käsuga `/buy`",
|
||||||
|
"owned": "✅ Olemas",
|
||||||
|
"owned_uses_1": "✅ Olemas ({uses} kasutus järel)",
|
||||||
|
"owned_uses_n": "✅ Olemas ({uses} kasutust järel)",
|
||||||
|
"locked": "🔒 Tase {min_lvl} nõutud *(sul on {user_lvl})*",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shop item descriptions (keyed by SHOP dict key in economy.py)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
ITEM_DESCRIPTIONS: dict[str, str] = {
|
||||||
|
"gaming_hiir": "Koolist varastatud hiir? Ei, see on mängurihiir. Teeni töötades 50% rohkem TipiCOINe.",
|
||||||
|
"hiirematt": "XXL suuruses, ainult parimast materjalist. Kerjamise ooteaeg 5min → 3min.",
|
||||||
|
"korvaklapid": "Noise-cancelling - kuuled ainult TipiCOINide kõlinat. Päevase boonuse ooteaeg 20h → 18h + 25⬡ boonust.",
|
||||||
|
"lan_pass": "Ametlik TipiLANi pilet (2025). Päevane boonus on duubeldatud.",
|
||||||
|
"energiajook": "Kolm Red Bulli järjest. 30% tõenäosus, et teenid töötades 3x rohkem.",
|
||||||
|
"gaming_laptop": "RTX 5090 jooksutab botte 24/7. Päevane boonus genereerib 5% intressi sinu saldo pealt.",
|
||||||
|
"anticheat": "VAC, EAC, Faceit AC ja BattlEye korraga. Röövimine sinu vastu ebaõnnestub. **2 kasutust**, siis pead ostma uue.",
|
||||||
|
"reguleeritav_laud": "Võid nii seista kui istuda - alati võidad. /work teenib 25% rohkem (stackib mängurihiirega).",
|
||||||
|
"jellyfin": "Self-hosted meediaserver - oled suurfirmadest sõltumatu. Röövimise edu 45% → 60%. Grupiröövi õnnestumisele +5%.",
|
||||||
|
"mikrofon": "Parem helikvaliteet teeb sind usutavamaks. Teeni 30% rohkem eduka /crime puhul.",
|
||||||
|
"klaviatuur": "Klõbinad kostuvad üle kogu saali. /beg teenib 2x rohkem.",
|
||||||
|
"monitor": "240Hz ja 27 tolli. /work ooteaeg: 1h → 40min.",
|
||||||
|
"cat6": "Gigabitine internet = ideaalne piraatluseks. /crime edu tõenäosus tõuseb 60% → 75%.",
|
||||||
|
"monitor_360": "360Hz, 1ms. Mänguautomaadi jackpot 10x → 15x, kolmik 4x → 6x. Hasartmängude ooteaeg 30s → 25s.",
|
||||||
|
"karikas": "Ainult legendidele. Streak ei nulli, kui sa mõne päeva vahele jätad.",
|
||||||
|
"gaming_tool": "Nii mugav, et isegi admin ei saa sind üles. /crime ebaõnnestumine ei saada sind vanglasse.",
|
||||||
|
"ussipurk": "Lakkumatu toiduga ussipurk - kalad ei saa vastu. Kalapyygi ooteaeg 2min → 90s.",
|
||||||
|
"kalavork": "Suurem võrk = suuremad kalad. Kõigi kalade haruldus tõuseb ühe astme võrra.",
|
||||||
|
"echolood": "Täpne ehholood näitab kala täpset asukohta. Haukamise aken 2s → 3s.",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Consumables (repeatable, expiring boosts - a recurring coin sink)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
CONSUMABLE_DESCRIPTIONS: dict[str, str] = {
|
||||||
|
"energy_xl": "Topeltannus kofeiini. **1 tund**: /work, /beg ja /crime teenivad **2x** rohkem.",
|
||||||
|
"xp_potion": "Kahtlane roheline jook. **1 tund**: kõik EXP-allikad annavad **2x** rohkem.",
|
||||||
|
"kohv": "Kange kohv äratab su üles. Nullib kohe kõik ooteajad (work, beg, crime, rob, fish).",
|
||||||
|
}
|
||||||
|
|
||||||
|
CONSUMABLES_UI: dict[str, str] = {
|
||||||
|
"title": "☕ Turgutused",
|
||||||
|
"desc": "Ühekordsed, korduvostetavad boostid. Saldo: {bal} · Osta `/consumables <ese>`",
|
||||||
|
"active_header": "⏳ Aktiivsed boostid",
|
||||||
|
"active_none": "Ühtegi boosti pole aktiivne.",
|
||||||
|
"buff_line": "{name} - veel **{time}**",
|
||||||
|
"kind_earn": "⚡ 2x tulu",
|
||||||
|
"kind_exp": "✨ 2x EXP",
|
||||||
|
"bought_title": "{emoji} {name} ostetud!",
|
||||||
|
"bought_buff": "Boost on aktiivne **{time}**.\nUus saldo: {balance}",
|
||||||
|
"bought_extended": "Boosti pikendati - aktiivne veel **{time}**.\nUus saldo: {balance}",
|
||||||
|
"bought_instant": "☕ Kõik ooteajad nullitud!\nUus saldo: {balance}",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Vanity shop (cosmetic badges/titles - a pure status sink, no gameplay effect)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
VANITY_UI: dict[str, str] = {
|
||||||
|
"title": "👑 Staatusepood",
|
||||||
|
"desc": "Puhtalt uhkuse pärast - märgid ja tiitlid ilma igasuguse mänguefektita. Kantud märk paistab su `/profile`-l.\nSaldo: {bal}",
|
||||||
|
"line_owned": "✅ Olemas",
|
||||||
|
"line_active": "⭐ Kantud",
|
||||||
|
"entry_title": "„{title}“",
|
||||||
|
"footer": "Osta või kanna: /vanity <märk> · „Eemalda märk“ võtab tiitli maha",
|
||||||
|
"none_choice": "❌ Eemalda märk",
|
||||||
|
"bought": "{emoji} Ostsid tiitli **{title}** ja panid selle kohe kandma!\nUus saldo: {balance}",
|
||||||
|
"equipped": "{emoji} Kannad nüüd tiitlit **{title}**.",
|
||||||
|
"unequipped": "Märk eemaldatud - su profiil on jälle tavaline.",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lootbox (pay-to-open mystery box, a coin sink with a random reward)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
LOOTBOX_UI: dict[str, str] = {
|
||||||
|
"title": "🎁 Õnnekast",
|
||||||
|
"opening": "🎁 Avan õnnekasti...",
|
||||||
|
"coins_small": "🪙 Leidsid põhjast paar münti: **+{coins}**",
|
||||||
|
"coins_medium": "💰 Korralik saak: **+{coins}**",
|
||||||
|
"coins_big": "💎 Suur õnn: **+{coins}**",
|
||||||
|
"jackpot": "🎉 **JACKPOT!** **+{coins}**",
|
||||||
|
"buff_earn": "⚡ Boonus: teenimine **×2** järgmiseks **{min} minutiks**!",
|
||||||
|
"buff_exp": "✨ Boonus: EXP **×2** järgmiseks **{min} minutiks**!",
|
||||||
|
"foot_win": "Netovõit: +{net} · Saldo: {balance}",
|
||||||
|
"foot_loss": "Netokahjum: {net} · Saldo: {balance}",
|
||||||
|
"foot_buff": "Saldo: {balance}",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bank vault (rob-proof storage; liquid vs banked coins)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
BANK_UI: dict[str, str] = {
|
||||||
|
"title": "🏦 TipiPANK",
|
||||||
|
"desc": "Panka pandud mündid on **röövikindlad** (`/rob` ja `/heist` neid ei puuduta), aga neid ei saa kulutada ega panustada enne väljavõtmist ega teeni Botikoopa intressi.",
|
||||||
|
"f_liquid": "💵 Rahakotis (vaba)",
|
||||||
|
"f_bank": "🏦 Pangas (kaitstud)",
|
||||||
|
"deposited": "🏦 Panid **{amount}** panka.\n💵 Rahakotis: {balance} · 🏦 Pangas: {bank}",
|
||||||
|
"withdrawn": "💵 Võtsid **{amount}** pangast välja.\n💵 Rahakotis: {balance} · 🏦 Pangas: {bank}",
|
||||||
|
"nothing_liquid": "❌ Sul pole nii palju vaba raha rahakotis.",
|
||||||
|
"nothing_bank": "❌ Sul pole nii palju raha pangas.",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Achievements (one-time milestone badges over lifetime stats)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
ACHIEVEMENTS_UI: dict[str, str] = {
|
||||||
|
"title": "🏅 Saavutused",
|
||||||
|
"desc": "Iga lukust lahti saanud märk annab ühekordse müntipreemia.\nAvatud: **{earned}/{total}**",
|
||||||
|
"row_earned": "✅ {emoji} **{name}** · +{reward}",
|
||||||
|
"row_locked": "🔒 {emoji} {name} · {progress}/{goal} · +{reward}",
|
||||||
|
"unlocked_note": "🎉 **Uued saavutused avatud:** {names}\n💰 Preemia: +{reward}",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lottery (daily draw; one weighted winner takes the pot)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
LOTTERY_UI: dict[str, str] = {
|
||||||
|
"title": "🎟️ TipiLOTO",
|
||||||
|
"desc": "Osta pileteid ja võida kogu pott! Loosimine iga päev **{draw_time}**. Iga pilet = **{cost}**, rohkem pileteid = suurem võiduvõimalus.",
|
||||||
|
"f_pot": "💰 Praegune pott",
|
||||||
|
"f_players": "👥 Osalejaid",
|
||||||
|
"f_your": "🎟️ Sinu piletid",
|
||||||
|
"your_val": "{tickets} tk · võiduvõimalus **{chance}%**",
|
||||||
|
"your_none": "Sul pole veel pileteid. Osta käsuga `/lottery <kogus>`.",
|
||||||
|
"bought": "🎟️ Ostsid **{count}** piletit ({cost}).\nSul on nüüd **{tickets}** piletit selle päeva loosimises.\nSaldo: {balance}",
|
||||||
|
"max_tickets": "❌ Maksimaalne piletite arv ühes loosimises on {cap} (sul on {held}).",
|
||||||
|
"empty_pot": "🎟️ Pott on tühi - ole esimene, kes piletit ostab!",
|
||||||
|
# Draw announcement
|
||||||
|
"draw_title": "🎟️ TipiLOTO loosimine!",
|
||||||
|
"draw_win": "🎉 Võitja: {winner}\n💰 Võit: **{pot}**\n🎟️ {tickets}/{total} piletit ({chance}%)\n👥 {players} osalejat",
|
||||||
|
"draw_none": "🎟️ Täna keegi pileteid ei ostnud - loosimist ei toimunud.",
|
||||||
|
}
|
||||||
|
|
||||||
|
JAILED_UI: dict[str, str] = {
|
||||||
|
"title": "🔒 Praegu vanglas",
|
||||||
|
"empty": "Kõik on vabad! Vanglas pole kedagi.",
|
||||||
|
"entry": "• {mention} - vabaneb <t:{ts}:R>",
|
||||||
|
"footer": "{count} vang{plural}",
|
||||||
|
}
|
||||||
|
SHOP_BTN: dict[int, str] = {
|
||||||
|
1: "Tier 1",
|
||||||
|
2: "Tier 2",
|
||||||
|
3: "Tier 3",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /daily embed strings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
DAILY_UI: dict[str, str] = {
|
||||||
|
"earned": "✅ Said {earned}!",
|
||||||
|
"interest": E["TipiLAP"] + " Bot Farm tootis: +{interest}",
|
||||||
|
"vip": f"{E['TipiPILET']} LAN pileti boonus rakendus!",
|
||||||
|
"footer": "Streak: {streak_str} · Saldo: {balance}",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /stats embed strings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
STATS_UI: dict[str, str] = {
|
||||||
|
"economy_field": "💰 Majandus",
|
||||||
|
"economy_val": "Tipp-saldo: {peak}\nEluaegne tulu: {earned}\nEluaegne kaotus: {lost}",
|
||||||
|
"work_field": "🛠️ Töö & Kerja",
|
||||||
|
"work_val": "Töötanud: **{work}** korda\nKerjanud: **{beg}** korda",
|
||||||
|
"gamble_field": "🎲 Hasartmängud",
|
||||||
|
"gamble_val": "Panustatud kokku: {wagered}\nSuurim võit: {win}\nSuurim kaotus: {loss}\nSlotsi jackpotid: **{jackpots}** korda",
|
||||||
|
"crime_field": "🦹 Kuritegevus",
|
||||||
|
"crime_val": "Kuriteod: **{crimes}** ({succeeded} õnnestus)\nHeistid: **{heists}** ({heists_won} võideti)\nVangi sattunud: **{jailed}** korda\nKautsjon makstud: {bail}",
|
||||||
|
"social_field": "🤝 Sotsiaalne",
|
||||||
|
"social_val": "Kingitud: {given}\nSaadud: {received}",
|
||||||
|
"records_field": "🔥 Rekordid",
|
||||||
|
"records_val": "Pikim päevane streak: **{streak}** päeva\n🎁 Õnnekaste avatud: **{lootboxes}**\n🏅 Saavutusi: **{achievements}/{ach_total}**",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /profile tabbed view
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PROFILE_UI: dict[str, str] = {
|
||||||
|
"btn_profile": "💰 Profiil",
|
||||||
|
"btn_items": "🎒 Esemed",
|
||||||
|
"btn_stats": "📊 Statistika",
|
||||||
|
"btn_fish": "🎣 Kalakogu",
|
||||||
|
"main_title": "💰 {name}",
|
||||||
|
"items_title": "🎒 {name} - Esemed",
|
||||||
|
"stats_title": "📊 {name} - Statistika",
|
||||||
|
"fish_title": "🎣 {name} - Kalakogu",
|
||||||
|
"items_empty": "Sul pole ühtegi eset.",
|
||||||
|
"f_balance": "💰 Saldo",
|
||||||
|
"f_level": "📊 Tase",
|
||||||
|
"f_streak": "🔥 Streak",
|
||||||
|
"f_prestige": "⭐ Prestiiz",
|
||||||
|
"f_jail": "🚔 Vangis kuni",
|
||||||
|
"f_progress": "→ Tase {next}",
|
||||||
|
"progress_bar": "`{bar}` {done}/{needed} EXP",
|
||||||
|
"level_val": "Tase {level} - {role}",
|
||||||
|
"prestige_val":"⭐ P{level} · {pp} PP",
|
||||||
|
"footer_t1": "Tase 10 avab T2 poe · Tase 20 avab T3 poe",
|
||||||
|
"footer_t2": "T2 pood avatud · Tase 20 avab T3 poe",
|
||||||
|
"footer_t3": "T2 ja T3 pood avatud",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /balance embed strings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
BALANCE_UI: dict[str, str] = {
|
||||||
|
"saldo": "Saldo",
|
||||||
|
"streak": "Päeva streak",
|
||||||
|
"streak_val": "🔥 {streak}p",
|
||||||
|
"jailed_until": "🚔 Vangis kuni",
|
||||||
|
"items": "Esemed",
|
||||||
|
"uses_one": " *({uses} kasutus järel)*",
|
||||||
|
"uses_many": " *({uses} kasutust järel)*",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /cooldowns embed strings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
COOLDOWNS_UI: dict[str, str] = {
|
||||||
|
"ready": "✅ Valmis",
|
||||||
|
"daily_line": "📅 **/daily** {status}{note}",
|
||||||
|
"work_line": "💼 **/work** {status}{note}",
|
||||||
|
"beg_line": "🙏 **/beg** {status}{note}",
|
||||||
|
"crime_line": "🦹 **/crime** {status}",
|
||||||
|
"rob_line": "🔫 **/rob** {status}",
|
||||||
|
"fish_line": "🎣 **/fish** {status}{note}",
|
||||||
|
"note_korvak": " *(kõrvaklapid: 18t)*",
|
||||||
|
"note_monitor": " *(monitor: 40min)*",
|
||||||
|
"note_hiirematt": " *(hiirematt: 3min)*",
|
||||||
|
"note_ussipurk": " *(ussipurk: 90s)*",
|
||||||
|
"jailed": "\n🔒 **Vanglas** - vabaneb <t:{ts}:R>",
|
||||||
|
"jail_expired": "\n🔓 Vangla lõppes",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /rank embed strings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
RANK_UI: dict[str, str] = {
|
||||||
|
"title": "📊 {name} - Tase {level}",
|
||||||
|
"field_title": "Tiitel",
|
||||||
|
"field_exp": "EXP",
|
||||||
|
"field_progress": "Progress → Tase {next}",
|
||||||
|
"progress_val": "`{bar}` {progress}/{needed} EXP",
|
||||||
|
"footer_t1": "Tase 10 avab T2 poe · Tase 20 avab T3 poe",
|
||||||
|
"footer_t2": "T2 pood avatud · Tase 20 avab T3 poe",
|
||||||
|
"footer_t3": "T2 ja T3 pood avatud",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /work embed strings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
WORK_UI: dict[str, str] = {
|
||||||
|
"desc": "Sa {job} ja teenisid {earned}!",
|
||||||
|
"redbull": f"\n{E['TipiBULL']} Red Bull aktiveerus - 3x boonus!",
|
||||||
|
"hiir": f"\n{E['TipiHIIR']} Mängurihiir: +50% palk",
|
||||||
|
"laud": f"\n{E['TipiLAUD']} Reguleeritav laud: +25% palk",
|
||||||
|
"balance": "\nSaldo: {balance}",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /beg embed strings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
BEG_UI: dict[str, str] = {
|
||||||
|
"desc": "Sa {text} ja said {earned}.",
|
||||||
|
"klaviatuur": f"{E['TipiKLAVA']} Mehhaaniline klaviatuur: 2x tulu",
|
||||||
|
"balance": "Saldo: {balance}",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /crime embed strings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CRIME_UI: dict[str, str] = {
|
||||||
|
"win_desc": "Sa {text} ja teenisid {earned}!",
|
||||||
|
"fail_base": "Sa {text} ja said trahvi {fine}.",
|
||||||
|
"fail_jailed": "\n\ud83d\udd12 Oled vangis! P\u00e4\u00e4sed {ts}.",
|
||||||
|
"fail_shield": "\n\ud83d\udee1\ufe0f Gaming Tool hoidis sind vanglast!",
|
||||||
|
"mikrofon": f"\n{E['TipiMIC']} Mikrofon: +30% saak",
|
||||||
|
"cat6": f"\n{E['TipiCAT']} CAT6: 75% edu t\u00f5en\u00e4osus",
|
||||||
|
"balance": "\nSaldo: {balance}",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /rob embed strings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
ROB_UI: dict[str, str] = {
|
||||||
|
"jackpot_desc": "💥 **JACKPOT!** Murdsid TipiBOTi kassasse sisse ja varastasid {stolen}!\nSaldo: {balance}",
|
||||||
|
"win_desc": "Varastasid {stolen} kasutajalt **{name}**!\nSaldo: {balance}",
|
||||||
|
"anticheat_desc": "**{name}** kaitseb end Anticheati'ga - said trahvi {fine}.",
|
||||||
|
"anticheat_worn": "⚠️ Sinu **Anticheat** on kulunud! Osta uus `/buy` käsuga.",
|
||||||
|
"victim_dm": "💸 **{robber}** varastas sinult **{stolen}** münti!",
|
||||||
|
"fail_desc": "Jäid vahele! Trahv: {fine}.\nSaldo: {balance}",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /give embed strings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
GIVE_UI: dict[str, str] = {
|
||||||
|
"desc": "**{giver}** andis {amount} kasutajale **{receiver}**.",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /buy embed strings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
BUY_UI: dict[str, str] = {
|
||||||
|
"title": "{emoji} {name} ostetud!",
|
||||||
|
"desc": "{description}\nUus saldo: {balance}",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /leaderboard UI strings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
LEADERBOARD_UI: dict[str, str] = {
|
||||||
|
"house_entry": "🤖 {name} *(maja)* - {balance}",
|
||||||
|
"house_default_name": "TipiBOT",
|
||||||
|
"no_entries": "Keegi ei ole veel punkte teeninud.",
|
||||||
|
"footer": "Lehekülg {page}/{total} · {count} mängijat",
|
||||||
|
"btn_coins": "🪙 Mündid",
|
||||||
|
"btn_exp": "📊 EXP",
|
||||||
|
"btn_find_me": "📍 Mina",
|
||||||
|
"exp_entry": "{prefix} {name} - {exp} EXP *(Tase {level})*",
|
||||||
|
"unknown_user": "Kasutaja {uid}",
|
||||||
|
"btn_season": "🏆 Hooaeg",
|
||||||
|
"btn_prestige": "🔥 Prestiiž",
|
||||||
|
"btn_wagered": "🎲 Hasartmäng",
|
||||||
|
"btn_fish": "🎣 Kalapyyk",
|
||||||
|
"season_entry": "{prefix} {name} - {exp} EXP *(Prestiiž {prestige})*",
|
||||||
|
"prestige_entry": "{prefix} {name} - Prestiiž **{prestige}** · {pp} PP",
|
||||||
|
"wagered_entry": "{prefix} {name} - {wagered} panustatud",
|
||||||
|
"fish_entry": "{prefix} {name} - {caught} kala",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /request UI strings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
REQUEST_UI: dict[str, str] = {
|
||||||
|
"modal_title": "Rahasta taotlust",
|
||||||
|
"modal_label": "Kui palju soovid panustada?",
|
||||||
|
"btn_fund": "Rahasta 💸",
|
||||||
|
"btn_funded": "Rahastatud ✅",
|
||||||
|
"btn_fund_remaining": "Rahasta 💸 ({remaining}⬡ jäänud)",
|
||||||
|
"funded_line": "✅ **{name}** panustas {amount}",
|
||||||
|
"funded_full": "\n🎯 Taotlus täielikult täidetud!",
|
||||||
|
"funded_partial": "\n💰 Jäänud: {remaining}",
|
||||||
|
"audience_all": "Avatud kõigile rahastajatele 🌍",
|
||||||
|
"audience_targeted": "Suunatud: **{name}**",
|
||||||
|
"desc": "**{requester}** palub {amount}\n\n📋 **Põhjus:** {reason}\n\n{audience}",
|
||||||
|
"footer": "Taotlus aegub 5 minuti pärast",
|
||||||
|
}
|
||||||
86
strings/fishing.py
Normal file
86
strings/fishing.py
Normal 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
307
strings/games.py
Normal 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 **(20–30% 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}** (20–30% 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}",
|
||||||
|
}
|
||||||
96
strings/member.py
Normal file
96
strings/member.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"""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',
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /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.",
|
||||||
|
}
|
||||||
78
tests/test_achievements.py
Normal file
78
tests/test_achievements.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""Tests for the achievements system (one-time milestone rewards)."""
|
||||||
|
|
||||||
|
from core import economy
|
||||||
|
|
||||||
|
from conftest import run
|
||||||
|
|
||||||
|
UID = 4747
|
||||||
|
|
||||||
|
|
||||||
|
def _fund(fake_pb, **stats) -> None:
|
||||||
|
run(economy.get_user(UID))
|
||||||
|
rec = fake_pb.record_for(UID)
|
||||||
|
rec.update(stats)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNewlyEarned:
|
||||||
|
def test_threshold_met_is_newly_earned(self, fake_pb):
|
||||||
|
_fund(fake_pb, work_count=10)
|
||||||
|
user = run(economy.get_user(UID))
|
||||||
|
assert "work_10" in economy.newly_earned(user)
|
||||||
|
|
||||||
|
def test_below_threshold_not_earned(self, fake_pb):
|
||||||
|
_fund(fake_pb, work_count=9)
|
||||||
|
user = run(economy.get_user(UID))
|
||||||
|
assert "work_10" not in economy.newly_earned(user)
|
||||||
|
|
||||||
|
def test_already_claimed_not_repeated(self, fake_pb):
|
||||||
|
_fund(fake_pb, work_count=10, achievements_earned=["work_10"])
|
||||||
|
user = run(economy.get_user(UID))
|
||||||
|
assert "work_10" not in economy.newly_earned(user)
|
||||||
|
|
||||||
|
|
||||||
|
class TestClaim:
|
||||||
|
def test_claims_and_pays_reward_once(self, fake_pb):
|
||||||
|
_fund(fake_pb, work_count=10, balance=0)
|
||||||
|
reward = economy.ACHIEVEMENTS["work_10"]["reward"]
|
||||||
|
|
||||||
|
res = run(economy.do_check_achievements(UID))
|
||||||
|
assert res["ok"] and res["new"] == ["work_10"]
|
||||||
|
assert res["reward"] == reward
|
||||||
|
assert fake_pb.record_for(UID)["balance"] == reward
|
||||||
|
assert "work_10" in fake_pb.record_for(UID)["achievements_earned"]
|
||||||
|
|
||||||
|
# Second call: nothing new, no double pay.
|
||||||
|
res2 = run(economy.do_check_achievements(UID))
|
||||||
|
assert res2["new"] == [] and res2["reward"] == 0
|
||||||
|
assert fake_pb.record_for(UID)["balance"] == reward
|
||||||
|
|
||||||
|
def test_multiple_unlocked_at_once(self, fake_pb):
|
||||||
|
_fund(fake_pb, work_count=100, total_fish_caught=25, balance=0)
|
||||||
|
res = run(economy.do_check_achievements(UID))
|
||||||
|
# work_10, work_100 and fish_25 all cross at once
|
||||||
|
assert set(res["new"]) == {"work_10", "work_100", "fish_25"}
|
||||||
|
expected = sum(economy.ACHIEVEMENTS[a]["reward"] for a in res["new"])
|
||||||
|
assert res["reward"] == expected
|
||||||
|
assert fake_pb.record_for(UID)["balance"] == expected
|
||||||
|
|
||||||
|
def test_nothing_to_claim(self, fake_pb):
|
||||||
|
_fund(fake_pb, balance=500)
|
||||||
|
res = run(economy.do_check_achievements(UID))
|
||||||
|
assert res["ok"] and res["new"] == [] and res["reward"] == 0
|
||||||
|
assert fake_pb.record_for(UID)["balance"] == 500
|
||||||
|
|
||||||
|
|
||||||
|
class TestView:
|
||||||
|
def test_view_marks_earned_and_progress(self, fake_pb):
|
||||||
|
_fund(fake_pb, work_count=5, achievements_earned=["beg_50"])
|
||||||
|
rows = economy.achievements_view(run(economy.get_user(UID)))
|
||||||
|
by_id = {r["id"]: r for r in rows}
|
||||||
|
assert by_id["beg_50"]["earned"] is True
|
||||||
|
assert by_id["work_10"]["earned"] is False
|
||||||
|
assert by_id["work_10"]["progress"] == 5 # capped at goal
|
||||||
|
assert len(rows) == len(economy.ACHIEVEMENTS)
|
||||||
|
|
||||||
|
def test_progress_capped_at_goal(self, fake_pb):
|
||||||
|
_fund(fake_pb, work_count=9999)
|
||||||
|
rows = {r["id"]: r for r in economy.achievements_view(run(economy.get_user(UID)))}
|
||||||
|
assert rows["work_10"]["progress"] == rows["work_10"]["goal"]
|
||||||
86
tests/test_bank.py
Normal file
86
tests/test_bank.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"""Tests for the bank vault: rob-proof storage and net-worth accounting."""
|
||||||
|
|
||||||
|
from core import economy
|
||||||
|
|
||||||
|
from conftest import run
|
||||||
|
|
||||||
|
UID = 6161
|
||||||
|
ROBBER = 6162
|
||||||
|
|
||||||
|
|
||||||
|
def _fund(fake_pb, uid: int, balance: int, bank: int = 0) -> None:
|
||||||
|
run(economy.get_user(uid))
|
||||||
|
rec = fake_pb.record_for(uid)
|
||||||
|
rec["balance"] = balance
|
||||||
|
rec["bank_balance"] = bank
|
||||||
|
|
||||||
|
|
||||||
|
class TestDepositWithdraw:
|
||||||
|
def test_deposit_moves_liquid_to_bank(self, fake_pb):
|
||||||
|
_fund(fake_pb, UID, 1000)
|
||||||
|
res = run(economy.do_deposit(UID, 400))
|
||||||
|
assert res["ok"] and res["balance"] == 600 and res["bank"] == 400
|
||||||
|
rec = fake_pb.record_for(UID)
|
||||||
|
assert rec["balance"] == 600 and rec["bank_balance"] == 400
|
||||||
|
|
||||||
|
def test_withdraw_moves_bank_to_liquid(self, fake_pb):
|
||||||
|
_fund(fake_pb, UID, 100, bank=500)
|
||||||
|
res = run(economy.do_withdraw(UID, 300))
|
||||||
|
assert res["ok"] and res["balance"] == 400 and res["bank"] == 200
|
||||||
|
|
||||||
|
def test_deposit_more_than_liquid_rejected(self, fake_pb):
|
||||||
|
_fund(fake_pb, UID, 100)
|
||||||
|
res = run(economy.do_deposit(UID, 500))
|
||||||
|
assert not res["ok"] and res["reason"] == "insufficient"
|
||||||
|
assert fake_pb.record_for(UID)["balance"] == 100 # unchanged
|
||||||
|
|
||||||
|
def test_withdraw_more_than_bank_rejected(self, fake_pb):
|
||||||
|
_fund(fake_pb, UID, 0, bank=100)
|
||||||
|
res = run(economy.do_withdraw(UID, 500))
|
||||||
|
assert not res["ok"] and res["reason"] == "insufficient"
|
||||||
|
assert fake_pb.record_for(UID)["bank_balance"] == 100
|
||||||
|
|
||||||
|
def test_nonpositive_rejected(self, fake_pb):
|
||||||
|
_fund(fake_pb, UID, 1000, bank=1000)
|
||||||
|
assert run(economy.do_deposit(UID, 0))["reason"] == "invalid"
|
||||||
|
assert run(economy.do_withdraw(UID, -5))["reason"] == "invalid"
|
||||||
|
|
||||||
|
def test_banned_rejected(self, fake_pb):
|
||||||
|
_fund(fake_pb, UID, 1000)
|
||||||
|
fake_pb.record_for(UID)["eco_banned"] = True
|
||||||
|
assert run(economy.do_deposit(UID, 100))["reason"] == "banned"
|
||||||
|
|
||||||
|
def test_round_trip_conserves_coins(self, fake_pb):
|
||||||
|
_fund(fake_pb, UID, 1000)
|
||||||
|
run(economy.do_deposit(UID, 700))
|
||||||
|
run(economy.do_withdraw(UID, 700))
|
||||||
|
rec = fake_pb.record_for(UID)
|
||||||
|
assert rec["balance"] == 1000 and rec["bank_balance"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestRobProof:
|
||||||
|
def test_rob_cannot_touch_banked_coins(self, fake_pb, monkeypatch):
|
||||||
|
# Target keeps everything banked, only a little liquid (< rob threshold).
|
||||||
|
_fund(fake_pb, UID, 50, bank=100_000)
|
||||||
|
_fund(fake_pb, ROBBER, 1000)
|
||||||
|
# Target has < 100 liquid, so a rob is rejected as "broke" - the vault is
|
||||||
|
# invisible to /rob (which only reads balance).
|
||||||
|
res = run(economy.do_rob(ROBBER, UID))
|
||||||
|
assert not res["ok"] and res["reason"] == "broke"
|
||||||
|
assert fake_pb.record_for(UID)["bank_balance"] == 100_000 # untouched
|
||||||
|
|
||||||
|
|
||||||
|
class TestNetWorth:
|
||||||
|
def test_leaderboard_counts_bank(self, fake_pb):
|
||||||
|
_fund(fake_pb, UID, 100, bank=900) # net worth 1000
|
||||||
|
_fund(fake_pb, ROBBER, 500, bank=0) # net worth 500
|
||||||
|
board = dict((uid, worth) for uid, worth in run(economy.get_leaderboard(top_n=None)))
|
||||||
|
assert board[str(UID)] == 1000
|
||||||
|
assert board[str(ROBBER)] == 500
|
||||||
|
|
||||||
|
def test_economy_stats_counts_bank(self, fake_pb, monkeypatch):
|
||||||
|
monkeypatch.setattr(economy.house, "HOUSE_ID", None)
|
||||||
|
_fund(fake_pb, UID, 100, bank=900)
|
||||||
|
stats = run(economy.get_economy_stats())
|
||||||
|
assert stats["total_coins"] == 1000
|
||||||
|
assert stats["player_coins"] == 1000
|
||||||
109
tests/test_consumables.py
Normal file
109
tests/test_consumables.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"""Tests for the consumables shop (repeatable, expiring coin sink)."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from core import economy
|
||||||
|
|
||||||
|
from conftest import run
|
||||||
|
|
||||||
|
UID = 4242
|
||||||
|
|
||||||
|
|
||||||
|
def _fixed_now(monkeypatch, dt: datetime):
|
||||||
|
# _clock is the time seam shared by every economy submodule
|
||||||
|
monkeypatch.setattr(economy.store, "_clock", lambda: dt)
|
||||||
|
return dt
|
||||||
|
|
||||||
|
|
||||||
|
def _fund(fake_pb, amount: int) -> None:
|
||||||
|
run(economy.get_user(UID))
|
||||||
|
fake_pb.record_for(UID)["balance"] = amount
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuy:
|
||||||
|
def test_buy_earn_buff_deducts_and_activates(self, fake_pb):
|
||||||
|
_fund(fake_pb, 1000)
|
||||||
|
res = run(economy.do_buy_consumable(UID, "energy_xl"))
|
||||||
|
assert res["ok"] and not res["instant"]
|
||||||
|
assert res["balance"] == 500 # 1000 - 500 cost
|
||||||
|
user = run(economy.get_user(UID))
|
||||||
|
assert economy.earn_mult(user) == 2.0
|
||||||
|
assert economy.exp_buff_mult(user) == 1.0
|
||||||
|
|
||||||
|
def test_insufficient_funds_rejected(self, fake_pb):
|
||||||
|
_fund(fake_pb, 100)
|
||||||
|
res = run(economy.do_buy_consumable(UID, "energy_xl"))
|
||||||
|
assert not res["ok"] and res["reason"] == "insufficient"
|
||||||
|
assert res["need"] == 400
|
||||||
|
assert run(economy.get_user(UID))["balance"] == 100 # unchanged
|
||||||
|
|
||||||
|
def test_banned_rejected(self, fake_pb):
|
||||||
|
_fund(fake_pb, 1000)
|
||||||
|
fake_pb.record_for(UID)["eco_banned"] = True
|
||||||
|
res = run(economy.do_buy_consumable(UID, "energy_xl"))
|
||||||
|
assert not res["ok"] and res["reason"] == "banned"
|
||||||
|
|
||||||
|
def test_unknown_consumable(self, fake_pb):
|
||||||
|
_fund(fake_pb, 1000)
|
||||||
|
res = run(economy.do_buy_consumable(UID, "nope"))
|
||||||
|
assert not res["ok"] and res["reason"] == "not_found"
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuffLifecycle:
|
||||||
|
def test_buff_expires(self, fake_pb, monkeypatch):
|
||||||
|
t0 = _fixed_now(monkeypatch, datetime(2026, 8, 19, 12, tzinfo=timezone.utc))
|
||||||
|
_fund(fake_pb, 1000)
|
||||||
|
run(economy.do_buy_consumable(UID, "energy_xl"))
|
||||||
|
assert economy.earn_mult(run(economy.get_user(UID))) == 2.0
|
||||||
|
# 61 minutes later the 60-minute buff is gone
|
||||||
|
_fixed_now(monkeypatch, t0 + timedelta(minutes=61))
|
||||||
|
assert economy.earn_mult(run(economy.get_user(UID))) == 1.0
|
||||||
|
|
||||||
|
def test_rebuy_extends_duration(self, fake_pb, monkeypatch):
|
||||||
|
t0 = _fixed_now(monkeypatch, datetime(2026, 8, 19, 12, tzinfo=timezone.utc))
|
||||||
|
_fund(fake_pb, 2000)
|
||||||
|
run(economy.do_buy_consumable(UID, "energy_xl")) # expiry = t0 + 60m
|
||||||
|
_fixed_now(monkeypatch, t0 + timedelta(minutes=30))
|
||||||
|
res = run(economy.do_buy_consumable(UID, "energy_xl")) # extends, not resets
|
||||||
|
assert res["extended"] is True
|
||||||
|
# remaining should be ~90m (30m left + 60m added), not 60m
|
||||||
|
assert res["remaining"] > timedelta(minutes=85)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEffects:
|
||||||
|
def test_earn_buff_doubles_work(self, fake_pb, monkeypatch):
|
||||||
|
import random
|
||||||
|
_fixed_now(monkeypatch, datetime(2026, 8, 19, 12, tzinfo=timezone.utc))
|
||||||
|
_fund(fake_pb, 1000)
|
||||||
|
monkeypatch.setattr(random, "randint", lambda a, b: 50)
|
||||||
|
monkeypatch.setattr(random, "choice", lambda seq: seq[0])
|
||||||
|
monkeypatch.setattr(random, "random", lambda: 0.99) # no energiajook luck
|
||||||
|
base = run(economy.do_work(UID))["earned"]
|
||||||
|
fake_pb.record_for(UID)["last_work"] = None # clear cooldown
|
||||||
|
run(economy.do_buy_consumable(UID, "energy_xl"))
|
||||||
|
boosted = run(economy.do_work(UID))["earned"]
|
||||||
|
assert boosted == base * 2
|
||||||
|
|
||||||
|
def test_exp_buff_doubles_award(self, fake_pb):
|
||||||
|
_fund(fake_pb, 1000)
|
||||||
|
run(economy.do_buy_consumable(UID, "xp_potion"))
|
||||||
|
res = run(economy.award_exp(UID, 10))
|
||||||
|
assert res["gained"] == 20 # 10 * 2x exp buff
|
||||||
|
|
||||||
|
def test_kohv_clears_cooldowns(self, fake_pb, monkeypatch):
|
||||||
|
t0 = _fixed_now(monkeypatch, datetime(2026, 8, 19, 12, tzinfo=timezone.utc))
|
||||||
|
_fund(fake_pb, 1000)
|
||||||
|
rec = fake_pb.record_for(UID)
|
||||||
|
rec["last_work"] = t0.isoformat() # on cooldown
|
||||||
|
res = run(economy.do_buy_consumable(UID, "kohv"))
|
||||||
|
assert res["ok"] and res["instant"]
|
||||||
|
user = run(economy.get_user(UID))
|
||||||
|
assert user["last_work"] is None # cooldown wiped -> /work is ready
|
||||||
|
assert economy.store._cooldown_remaining(user, "work") is None
|
||||||
|
|
||||||
|
def test_instant_reset_commands_match_cleared_cooldowns(self):
|
||||||
|
# The Discord layer cancels reminder DMs for exactly these commands after a
|
||||||
|
# kohv, so the list must stay in lockstep with the cooldown fields it wipes.
|
||||||
|
expected = tuple(f.removeprefix("last_") for f in economy.consumables._COOLDOWN_FIELDS)
|
||||||
|
assert economy.INSTANT_RESET_COMMANDS == expected
|
||||||
|
assert economy.INSTANT_RESET_COMMANDS == ("work", "beg", "crime", "rob", "fish")
|
||||||
@@ -35,6 +35,29 @@ class TestLevels:
|
|||||||
assert economy.level_role_name(99) == "TipiLEGEND"
|
assert economy.level_role_name(99) == "TipiLEGEND"
|
||||||
|
|
||||||
|
|
||||||
|
class TestEffectiveCooldown:
|
||||||
|
def test_default_when_no_item(self):
|
||||||
|
assert economy.effective_cooldown("work", []) == economy.COOLDOWNS["work"]
|
||||||
|
assert economy.effective_cooldown("daily", []) == economy.COOLDOWNS["daily"]
|
||||||
|
|
||||||
|
def test_item_reduces_cooldown(self):
|
||||||
|
assert economy.effective_cooldown("work", ["monitor"]) == timedelta(minutes=40)
|
||||||
|
assert economy.effective_cooldown("beg", ["hiirematt"]) == timedelta(minutes=3)
|
||||||
|
assert economy.effective_cooldown("daily", ["korvaklapid"]) == timedelta(hours=18)
|
||||||
|
assert economy.effective_cooldown("fish", ["ussipurk"]) == timedelta(seconds=90)
|
||||||
|
|
||||||
|
def test_unrelated_item_does_not_reduce(self):
|
||||||
|
assert economy.effective_cooldown("work", ["hiirematt"]) == economy.COOLDOWNS["work"]
|
||||||
|
|
||||||
|
def test_command_without_cooldown_returns_none(self):
|
||||||
|
assert economy.effective_cooldown("unknown", []) is None
|
||||||
|
|
||||||
|
def test_every_item_cooldown_beats_its_base(self):
|
||||||
|
# An item-reduced cooldown must always be shorter than the base.
|
||||||
|
for cmd, (item, reduced) in economy.store.ITEM_COOLDOWNS.items():
|
||||||
|
assert reduced < economy.COOLDOWNS[cmd]
|
||||||
|
|
||||||
|
|
||||||
class TestGambleExp:
|
class TestGambleExp:
|
||||||
def test_tiers(self):
|
def test_tiers(self):
|
||||||
assert economy.gamble_exp(0) == 0
|
assert economy.gamble_exp(0) == 0
|
||||||
|
|||||||
77
tests/test_leaderboards.py
Normal file
77
tests/test_leaderboards.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"""Tests for leaderboard queries, incl. the single-scan combined builder."""
|
||||||
|
|
||||||
|
from core import economy, pb_client
|
||||||
|
|
||||||
|
from conftest import run
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(fake_pb, n: int) -> None:
|
||||||
|
for i in range(1, n + 1):
|
||||||
|
run(economy.get_user(1000 + i))
|
||||||
|
rec = fake_pb.record_for(1000 + i)
|
||||||
|
rec["balance"] = i * 100
|
||||||
|
rec["exp"] = i * 50
|
||||||
|
rec["season_total_exp"] = i * 10
|
||||||
|
rec["prestige_level"] = i % 3
|
||||||
|
rec["prestige_points"] = i
|
||||||
|
rec["total_wagered"] = i * 7
|
||||||
|
rec["total_fish_caught"] = n - i # inverse order, to catch sort mistakes
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetAllLeaderboards:
|
||||||
|
def test_matches_individual_queries(self, fake_pb):
|
||||||
|
_seed(fake_pb, 5)
|
||||||
|
combined = run(economy.get_all_leaderboards())
|
||||||
|
assert combined["coins"] == run(economy.get_leaderboard(top_n=None))
|
||||||
|
assert combined["exp"] == run(economy.get_leaderboard_exp(top_n=None))
|
||||||
|
assert combined["season"] == run(economy.get_leaderboard_season_exp(top_n=None))
|
||||||
|
assert combined["prestige"] == run(economy.get_leaderboard_prestige(top_n=None))
|
||||||
|
assert combined["wagered"] == run(economy.get_leaderboard_wagered(top_n=None))
|
||||||
|
assert combined["fish"] == run(economy.get_leaderboard_fish(top_n=None))
|
||||||
|
|
||||||
|
def test_scans_collection_once(self, fake_pb, monkeypatch):
|
||||||
|
_seed(fake_pb, 3)
|
||||||
|
calls = {"n": 0}
|
||||||
|
real = pb_client.list_all_records
|
||||||
|
|
||||||
|
async def counting():
|
||||||
|
calls["n"] += 1
|
||||||
|
return await real()
|
||||||
|
|
||||||
|
monkeypatch.setattr(pb_client, "list_all_records", counting)
|
||||||
|
run(economy.get_all_leaderboards())
|
||||||
|
assert calls["n"] == 1 # not six
|
||||||
|
|
||||||
|
def test_fish_sorted_descending(self, fake_pb):
|
||||||
|
_seed(fake_pb, 4)
|
||||||
|
fish = run(economy.get_all_leaderboards())["fish"]
|
||||||
|
counts = [c for _, c in fish]
|
||||||
|
assert counts == sorted(counts, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEconomyStats:
|
||||||
|
def test_totals_split_house_from_players(self, fake_pb, monkeypatch):
|
||||||
|
monkeypatch.setattr(economy.house, "HOUSE_ID", 999)
|
||||||
|
monkeypatch.setattr(economy.house, "_house_pb_id", None)
|
||||||
|
# three players + the house
|
||||||
|
for uid, bal in [(1, 100), (2, 250), (3, 0)]:
|
||||||
|
run(economy.get_user(uid))
|
||||||
|
fake_pb.record_for(uid)["balance"] = bal
|
||||||
|
run(economy.get_user(999))
|
||||||
|
fake_pb.record_for(999)["balance"] = 5000
|
||||||
|
|
||||||
|
stats = run(economy.get_economy_stats())
|
||||||
|
assert stats["total_coins"] == 100 + 250 + 0 + 5000
|
||||||
|
assert stats["house_balance"] == 5000
|
||||||
|
assert stats["player_coins"] == 350
|
||||||
|
assert stats["player_count"] == 3 # house excluded
|
||||||
|
|
||||||
|
def test_no_house_configured(self, fake_pb, monkeypatch):
|
||||||
|
monkeypatch.setattr(economy.house, "HOUSE_ID", None)
|
||||||
|
run(economy.get_user(1))
|
||||||
|
fake_pb.record_for(1)["balance"] = 42
|
||||||
|
stats = run(economy.get_economy_stats())
|
||||||
|
assert stats["total_coins"] == 42
|
||||||
|
assert stats["house_balance"] == 0
|
||||||
|
assert stats["player_coins"] == 42
|
||||||
|
assert stats["player_count"] == 1
|
||||||
78
tests/test_lootbox.py
Normal file
78
tests/test_lootbox.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""Tests for the /lootbox mystery box (pay-to-open coin sink)."""
|
||||||
|
|
||||||
|
import random
|
||||||
|
|
||||||
|
from core import economy
|
||||||
|
|
||||||
|
from conftest import run
|
||||||
|
|
||||||
|
UID = 8080
|
||||||
|
|
||||||
|
|
||||||
|
def _fund(fake_pb, amount: int) -> None:
|
||||||
|
run(economy.get_user(UID))
|
||||||
|
fake_pb.record_for(UID)["balance"] = amount
|
||||||
|
|
||||||
|
|
||||||
|
def _open_until(fake_pb, predicate):
|
||||||
|
"""Open boxes with varied seeds until `predicate(res)` holds; returns that res."""
|
||||||
|
for seed in range(1000):
|
||||||
|
random.seed(seed)
|
||||||
|
_fund(fake_pb, 100_000)
|
||||||
|
res = run(economy.do_open_lootbox(UID))
|
||||||
|
if predicate(res):
|
||||||
|
return res
|
||||||
|
raise AssertionError("outcome never occurred")
|
||||||
|
|
||||||
|
|
||||||
|
class TestOpen:
|
||||||
|
def test_costs_the_fee_and_charges_up_front(self, fake_pb):
|
||||||
|
_fund(fake_pb, 1_000)
|
||||||
|
random.seed(1)
|
||||||
|
res = run(economy.do_open_lootbox(UID))
|
||||||
|
assert res["ok"]
|
||||||
|
# balance == 1000 - cost + reward_coins
|
||||||
|
assert res["balance"] == 1_000 - economy.LOOTBOX_COST + res["reward_coins"]
|
||||||
|
|
||||||
|
def test_insufficient_rejected(self, fake_pb):
|
||||||
|
_fund(fake_pb, economy.LOOTBOX_COST - 1)
|
||||||
|
res = run(economy.do_open_lootbox(UID))
|
||||||
|
assert not res["ok"] and res["reason"] == "insufficient"
|
||||||
|
assert res["need"] == 1
|
||||||
|
assert fake_pb.record_for(UID)["balance"] == economy.LOOTBOX_COST - 1 # not charged
|
||||||
|
|
||||||
|
def test_banned_rejected(self, fake_pb):
|
||||||
|
_fund(fake_pb, 5_000)
|
||||||
|
fake_pb.record_for(UID)["eco_banned"] = True
|
||||||
|
res = run(economy.do_open_lootbox(UID))
|
||||||
|
assert not res["ok"] and res["reason"] == "banned"
|
||||||
|
|
||||||
|
def test_increments_counter(self, fake_pb):
|
||||||
|
_fund(fake_pb, 5_000)
|
||||||
|
random.seed(3)
|
||||||
|
run(economy.do_open_lootbox(UID))
|
||||||
|
run(economy.do_open_lootbox(UID))
|
||||||
|
assert run(economy.get_user(UID))["lootboxes_opened"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestOutcomes:
|
||||||
|
def test_coin_outcome_credits_and_net_matches(self, fake_pb):
|
||||||
|
res = _open_until(fake_pb, lambda r: r["reward_coins"] > 0)
|
||||||
|
assert res["net"] == res["reward_coins"] - economy.LOOTBOX_COST
|
||||||
|
assert res["buff_kind"] is None
|
||||||
|
|
||||||
|
def test_buff_outcome_grants_active_buff(self, fake_pb):
|
||||||
|
res = _open_until(fake_pb, lambda r: r["buff_kind"] is not None)
|
||||||
|
assert res["reward_coins"] == 0
|
||||||
|
assert res["net"] == -economy.LOOTBOX_COST
|
||||||
|
user = run(economy.get_user(UID))
|
||||||
|
# the granted buff is live
|
||||||
|
assert res["buff_kind"] in economy.active_buffs(user)
|
||||||
|
|
||||||
|
def test_never_negative_balance(self, fake_pb):
|
||||||
|
# Open exactly at the cost floor repeatedly; balance must stay >= 0.
|
||||||
|
for seed in range(30):
|
||||||
|
random.seed(seed)
|
||||||
|
_fund(fake_pb, economy.LOOTBOX_COST)
|
||||||
|
res = run(economy.do_open_lootbox(UID))
|
||||||
|
assert res["balance"] >= 0
|
||||||
131
tests/test_lottery.py
Normal file
131
tests/test_lottery.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
"""Tests for the daily lottery (buy tickets, weighted winner takes the pot)."""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import random
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from core import economy
|
||||||
|
|
||||||
|
from conftest import run
|
||||||
|
|
||||||
|
TZ = ZoneInfo("Europe/Tallinn")
|
||||||
|
P = "2026-09-04" # a fixed draw period
|
||||||
|
UID = 3131
|
||||||
|
UID2 = 3132
|
||||||
|
UID3 = 3133
|
||||||
|
|
||||||
|
|
||||||
|
def _fund(fake_pb, uid: int, balance: int) -> None:
|
||||||
|
run(economy.get_user(uid))
|
||||||
|
fake_pb.record_for(uid)["balance"] = balance
|
||||||
|
|
||||||
|
|
||||||
|
class TestPeriod:
|
||||||
|
def test_before_draw_hour_is_today(self):
|
||||||
|
dt = datetime.datetime(2026, 9, 4, 20, 0, tzinfo=TZ)
|
||||||
|
assert economy.period_for(dt) == "2026-09-04"
|
||||||
|
|
||||||
|
def test_at_or_after_draw_hour_is_tomorrow(self):
|
||||||
|
dt = datetime.datetime(2026, 9, 4, economy.DRAW_HOUR, 0, tzinfo=TZ)
|
||||||
|
assert economy.period_for(dt) == "2026-09-05"
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuy:
|
||||||
|
def test_buy_deducts_and_records_tickets(self, fake_pb):
|
||||||
|
_fund(fake_pb, UID, 10_000)
|
||||||
|
res = run(economy.do_buy_ticket(UID, 3, P))
|
||||||
|
assert res["ok"] and res["tickets"] == 3
|
||||||
|
assert res["cost"] == 3 * economy.TICKET_COST
|
||||||
|
assert res["balance"] == 10_000 - 3 * economy.TICKET_COST
|
||||||
|
rec = fake_pb.record_for(UID)
|
||||||
|
assert rec["lottery_tickets"] == 3 and rec["lottery_period"] == P
|
||||||
|
|
||||||
|
def test_buy_accumulates_same_period(self, fake_pb):
|
||||||
|
_fund(fake_pb, UID, 10_000)
|
||||||
|
run(economy.do_buy_ticket(UID, 2, P))
|
||||||
|
res = run(economy.do_buy_ticket(UID, 3, P))
|
||||||
|
assert res["tickets"] == 5
|
||||||
|
|
||||||
|
def test_new_period_resets_tickets(self, fake_pb):
|
||||||
|
_fund(fake_pb, UID, 10_000)
|
||||||
|
run(economy.do_buy_ticket(UID, 5, P))
|
||||||
|
res = run(economy.do_buy_ticket(UID, 1, "2026-09-05"))
|
||||||
|
assert res["tickets"] == 1 # old period's tickets dropped
|
||||||
|
|
||||||
|
def test_insufficient_rejected(self, fake_pb):
|
||||||
|
_fund(fake_pb, UID, 100)
|
||||||
|
res = run(economy.do_buy_ticket(UID, 1, P))
|
||||||
|
assert not res["ok"] and res["reason"] == "insufficient"
|
||||||
|
assert fake_pb.record_for(UID)["balance"] == 100
|
||||||
|
|
||||||
|
def test_max_tickets_enforced(self, fake_pb):
|
||||||
|
_fund(fake_pb, UID, 10_000_000)
|
||||||
|
res = run(economy.do_buy_ticket(UID, economy.MAX_TICKETS_PER_DRAW + 1, P))
|
||||||
|
assert not res["ok"] and res["reason"] == "max_tickets"
|
||||||
|
|
||||||
|
def test_nonpositive_rejected(self, fake_pb):
|
||||||
|
_fund(fake_pb, UID, 10_000)
|
||||||
|
assert run(economy.do_buy_ticket(UID, 0, P))["reason"] == "invalid"
|
||||||
|
|
||||||
|
def test_banned_rejected(self, fake_pb):
|
||||||
|
_fund(fake_pb, UID, 10_000)
|
||||||
|
fake_pb.record_for(UID)["eco_banned"] = True
|
||||||
|
assert run(economy.do_buy_ticket(UID, 1, P))["reason"] == "banned"
|
||||||
|
|
||||||
|
|
||||||
|
class TestState:
|
||||||
|
def test_pot_and_your_tickets(self, fake_pb):
|
||||||
|
_fund(fake_pb, UID, 10_000)
|
||||||
|
_fund(fake_pb, UID2, 10_000)
|
||||||
|
run(economy.do_buy_ticket(UID, 3, P))
|
||||||
|
run(economy.do_buy_ticket(UID2, 2, P))
|
||||||
|
state = run(economy.get_lottery_state(P, UID))
|
||||||
|
assert state["total_tickets"] == 5
|
||||||
|
assert state["pot"] == 5 * economy.TICKET_COST
|
||||||
|
assert state["participants"] == 2
|
||||||
|
assert state["your_tickets"] == 3
|
||||||
|
|
||||||
|
def test_other_period_not_counted(self, fake_pb):
|
||||||
|
_fund(fake_pb, UID, 10_000)
|
||||||
|
run(economy.do_buy_ticket(UID, 3, "2026-01-01"))
|
||||||
|
state = run(economy.get_lottery_state(P))
|
||||||
|
assert state["total_tickets"] == 0 and state["pot"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestDraw:
|
||||||
|
def test_no_participants_returns_none(self, fake_pb):
|
||||||
|
assert run(economy.do_lottery_draw(P)) is None
|
||||||
|
|
||||||
|
def test_winner_gets_whole_pot_and_coins_conserved(self, fake_pb):
|
||||||
|
_fund(fake_pb, UID, 10_000)
|
||||||
|
_fund(fake_pb, UID2, 10_000)
|
||||||
|
run(economy.do_buy_ticket(UID, 3, P)) # -600
|
||||||
|
run(economy.do_buy_ticket(UID2, 2, P)) # -400
|
||||||
|
pot = 5 * economy.TICKET_COST
|
||||||
|
total_before = sum(fake_pb.record_for(u)["balance"] for u in (UID, UID2))
|
||||||
|
|
||||||
|
random.seed(1)
|
||||||
|
res = run(economy.do_lottery_draw(P))
|
||||||
|
assert res["ok"] and res["pot"] == pot
|
||||||
|
winner, loser = (UID, UID2) if res["winner_id"] == UID else (UID2, UID)
|
||||||
|
assert fake_pb.record_for(winner)["balance"] == (
|
||||||
|
(10_000 - (3 if winner == UID else 2) * economy.TICKET_COST) + pot
|
||||||
|
)
|
||||||
|
# Coins conserved: the pot minted to the winner equals total ticket spend.
|
||||||
|
total_after = sum(fake_pb.record_for(u)["balance"] for u in (UID, UID2))
|
||||||
|
assert total_after == total_before + pot
|
||||||
|
assert fake_pb.record_for(res["winner_id"])["lottery_tickets"] == 0 # consumed
|
||||||
|
|
||||||
|
def test_more_tickets_wins_more_often(self, fake_pb):
|
||||||
|
_fund(fake_pb, UID, 10_000_000)
|
||||||
|
_fund(fake_pb, UID2, 10_000_000)
|
||||||
|
wins = {UID: 0, UID2: 0}
|
||||||
|
for seed in range(200):
|
||||||
|
# reset tickets each round to the same split
|
||||||
|
fake_pb.record_for(UID).update(lottery_tickets=9, lottery_period=P)
|
||||||
|
fake_pb.record_for(UID2).update(lottery_tickets=1, lottery_period=P)
|
||||||
|
random.seed(seed)
|
||||||
|
res = run(economy.do_lottery_draw(P))
|
||||||
|
wins[res["winner_id"]] += 1
|
||||||
|
# UID holds 90% of tickets -> should win far more often.
|
||||||
|
assert wins[UID] > wins[UID2] * 3
|
||||||
115
tests/test_money_safety_fixes.py
Normal file
115
tests/test_money_safety_fixes.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""Regression tests for the money-safety audit fixes.
|
||||||
|
|
||||||
|
Covers the two pure-logic fixes:
|
||||||
|
- heist payout conserves coins (no minting when the house is poor) and the win
|
||||||
|
path no longer crashes on the shadowed `house` local.
|
||||||
|
- do_bail is idempotent: a jailed user can only be charged once per sentence, so
|
||||||
|
a double-click or a stale BailView cannot destroy coins with a second fine.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from core import economy
|
||||||
|
from core.pb_client import DatabaseError
|
||||||
|
|
||||||
|
from conftest import run
|
||||||
|
|
||||||
|
UID = 111
|
||||||
|
OTHER = 222
|
||||||
|
HOUSE = 999
|
||||||
|
|
||||||
|
|
||||||
|
def _fixed_now(monkeypatch, dt: datetime):
|
||||||
|
monkeypatch.setattr(economy.store, "_clock", lambda: dt)
|
||||||
|
return dt
|
||||||
|
|
||||||
|
|
||||||
|
class TestHeistConservation:
|
||||||
|
def _setup_house(self, fake_pb, monkeypatch, balance: int):
|
||||||
|
monkeypatch.setattr(economy.house, "HOUSE_ID", HOUSE)
|
||||||
|
monkeypatch.setattr(economy.house, "_house_pb_id", None)
|
||||||
|
run(economy.get_user(HOUSE))
|
||||||
|
fake_pb.record_for(HOUSE)["balance"] = balance
|
||||||
|
|
||||||
|
def test_poor_house_win_does_not_mint(self, fake_pb, monkeypatch):
|
||||||
|
# Poor house is the case the pre-fix code minted from: the desired pot
|
||||||
|
# (floored at 300) exceeded the balance, so payouts outran the debit.
|
||||||
|
self._setup_house(fake_pb, monkeypatch, balance=100)
|
||||||
|
run(economy.get_user(UID))
|
||||||
|
run(economy.get_user(OTHER))
|
||||||
|
|
||||||
|
house_before = fake_pb.record_for(HOUSE)["balance"]
|
||||||
|
res = run(economy.do_heist_resolve([UID, OTHER], True)) # must not raise
|
||||||
|
assert res["ok"] and res["success"]
|
||||||
|
|
||||||
|
house_after = fake_pb.record_for(HOUSE)["balance"]
|
||||||
|
gains = fake_pb.record_for(UID)["balance"] + fake_pb.record_for(OTHER)["balance"]
|
||||||
|
# Coin conservation: the house loses exactly what the players gain.
|
||||||
|
assert house_before - house_after == gains
|
||||||
|
assert house_after >= 0
|
||||||
|
assert res["payout_each"] * 2 == gains
|
||||||
|
|
||||||
|
def test_rich_house_win_pays_out_and_conserves(self, fake_pb, monkeypatch):
|
||||||
|
self._setup_house(fake_pb, monkeypatch, balance=100_000)
|
||||||
|
run(economy.get_user(UID))
|
||||||
|
run(economy.get_user(OTHER))
|
||||||
|
|
||||||
|
house_before = fake_pb.record_for(HOUSE)["balance"]
|
||||||
|
res = run(economy.do_heist_resolve([UID, OTHER], True))
|
||||||
|
assert res["ok"] and res["payout_each"] > 0
|
||||||
|
|
||||||
|
house_after = fake_pb.record_for(HOUSE)["balance"]
|
||||||
|
gains = fake_pb.record_for(UID)["balance"] + fake_pb.record_for(OTHER)["balance"]
|
||||||
|
assert house_before - house_after == gains
|
||||||
|
|
||||||
|
|
||||||
|
class TestBailIdempotency:
|
||||||
|
def _jail(self, fake_pb, now, balance: int):
|
||||||
|
run(economy.get_user(UID))
|
||||||
|
rec = fake_pb.record_for(UID)
|
||||||
|
rec["balance"] = balance
|
||||||
|
rec["jailed_until"] = (now + timedelta(minutes=30)).isoformat()
|
||||||
|
|
||||||
|
def test_second_bail_when_free_is_noop(self, fake_pb, monkeypatch):
|
||||||
|
now = _fixed_now(monkeypatch, datetime(2026, 7, 25, 12, tzinfo=timezone.utc))
|
||||||
|
self._jail(fake_pb, now, balance=1000)
|
||||||
|
|
||||||
|
first = run(economy.do_bail(UID))
|
||||||
|
assert first["ok"]
|
||||||
|
bal_after_first = fake_pb.record_for(UID)["balance"]
|
||||||
|
assert bal_after_first < 1000 # a fine was charged
|
||||||
|
assert fake_pb.record_for(UID)["jailed_until"] is None # freed
|
||||||
|
|
||||||
|
# A double-click / stale view fires do_bail again while already free.
|
||||||
|
second = run(economy.do_bail(UID))
|
||||||
|
assert not second["ok"] and second["reason"] == "not_jailed"
|
||||||
|
assert fake_pb.record_for(UID)["balance"] == bal_after_first # no second charge
|
||||||
|
|
||||||
|
def test_bail_charges_once_for_a_real_sentence(self, fake_pb, monkeypatch):
|
||||||
|
now = _fixed_now(monkeypatch, datetime(2026, 7, 25, 12, tzinfo=timezone.utc))
|
||||||
|
self._jail(fake_pb, now, balance=1000)
|
||||||
|
res = run(economy.do_bail(UID))
|
||||||
|
assert res["ok"] and res["fine"] >= economy.MIN_BAIL
|
||||||
|
|
||||||
|
|
||||||
|
class TestBlackjackPayoutSafety:
|
||||||
|
"""do_blackjack_payout must not raise on a DB failure - the stake was already
|
||||||
|
deducted in do_blackjack_bet, so a raised exception would blow up the
|
||||||
|
interaction handler and swallow the outcome. It reports db_error instead."""
|
||||||
|
|
||||||
|
async def _boom(self, *args, **kwargs):
|
||||||
|
raise DatabaseError("simulated PocketBase outage")
|
||||||
|
|
||||||
|
def test_payout_returns_db_error_when_read_fails(self, fake_pb, monkeypatch):
|
||||||
|
monkeypatch.setattr(economy.gambling, "get_user", self._boom)
|
||||||
|
res = run(economy.do_blackjack_payout(UID, payout=200, total_invested=100))
|
||||||
|
assert res == {"ok": False, "reason": "db_error"}
|
||||||
|
|
||||||
|
def test_payout_returns_db_error_when_commit_fails(self, fake_pb, monkeypatch):
|
||||||
|
run(economy.get_user(UID))
|
||||||
|
fake_pb.record_for(UID)["balance"] = 500
|
||||||
|
monkeypatch.setattr(economy.gambling, "_commit", self._boom)
|
||||||
|
res = run(economy.do_blackjack_payout(UID, payout=200, total_invested=100))
|
||||||
|
assert res == {"ok": False, "reason": "db_error"}
|
||||||
|
# The failed credit did not persist; balance is untouched (no partial win).
|
||||||
|
assert fake_pb.record_for(UID)["balance"] == 500
|
||||||
102
tests/test_pending_wager.py
Normal file
102
tests/test_pending_wager.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""Tests for pending-wager escrow persistence.
|
||||||
|
|
||||||
|
Interactive games (blackjack, RPS PvP) deduct a stake up front and hold it in an
|
||||||
|
in-memory View. These tests verify the stake is recorded on the user record in
|
||||||
|
the same commit, cleared on settlement, and refunded by reconcile on restart.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from core import economy
|
||||||
|
|
||||||
|
from conftest import run
|
||||||
|
|
||||||
|
UID = 555
|
||||||
|
OPP = 556
|
||||||
|
|
||||||
|
|
||||||
|
def _fund(fake_pb, uid: int, amount: int) -> None:
|
||||||
|
run(economy.get_user(uid))
|
||||||
|
fake_pb.record_for(uid)["balance"] = amount
|
||||||
|
|
||||||
|
|
||||||
|
def _pending(fake_pb, uid: int) -> dict:
|
||||||
|
return fake_pb.record_for(uid).get("pending_wager") or {}
|
||||||
|
|
||||||
|
|
||||||
|
class TestBlackjackEscrow:
|
||||||
|
def test_bet_records_pending_and_payout_clears(self, fake_pb):
|
||||||
|
_fund(fake_pb, UID, 1000)
|
||||||
|
run(economy.do_blackjack_bet(UID, 100))
|
||||||
|
pw = _pending(fake_pb, UID)
|
||||||
|
assert pw["kind"] == "blackjack" and pw["amount"] == 100
|
||||||
|
|
||||||
|
run(economy.do_blackjack_payout(UID, payout=200, total_invested=100))
|
||||||
|
assert _pending(fake_pb, UID) == {} # settled
|
||||||
|
assert fake_pb.record_for(UID)["balance"] == 1000 - 100 + 200
|
||||||
|
|
||||||
|
def test_double_split_accumulates_escrow(self, fake_pb):
|
||||||
|
_fund(fake_pb, UID, 1000)
|
||||||
|
run(economy.do_blackjack_bet(UID, 100)) # initial
|
||||||
|
run(economy.do_blackjack_bet(UID, 100)) # double / split adds a hand
|
||||||
|
assert _pending(fake_pb, UID)["amount"] == 200
|
||||||
|
|
||||||
|
def test_losing_hand_clears_escrow(self, fake_pb):
|
||||||
|
_fund(fake_pb, UID, 1000)
|
||||||
|
run(economy.do_blackjack_bet(UID, 100))
|
||||||
|
run(economy.do_blackjack_payout(UID, payout=0, total_invested=100)) # bust/loss
|
||||||
|
assert _pending(fake_pb, UID) == {}
|
||||||
|
|
||||||
|
|
||||||
|
class TestRpsEscrow:
|
||||||
|
def test_deposit_records_and_payout_forfeit_clear(self, fake_pb):
|
||||||
|
_fund(fake_pb, UID, 500)
|
||||||
|
_fund(fake_pb, OPP, 500)
|
||||||
|
run(economy.do_rps_pvp_deposit(UID, 100))
|
||||||
|
run(economy.do_rps_pvp_deposit(OPP, 100))
|
||||||
|
assert _pending(fake_pb, UID)["kind"] == "rps"
|
||||||
|
assert _pending(fake_pb, OPP)["amount"] == 100
|
||||||
|
|
||||||
|
run(economy.do_rps_pvp_payout(UID, 100)) # UID wins
|
||||||
|
run(economy.do_rps_pvp_forfeit(OPP)) # OPP loses
|
||||||
|
assert _pending(fake_pb, UID) == {}
|
||||||
|
assert _pending(fake_pb, OPP) == {}
|
||||||
|
assert fake_pb.record_for(UID)["balance"] == 500 - 100 + 200
|
||||||
|
assert fake_pb.record_for(OPP)["balance"] == 500 - 100
|
||||||
|
|
||||||
|
def test_refund_clears_escrow(self, fake_pb):
|
||||||
|
_fund(fake_pb, UID, 500)
|
||||||
|
run(economy.do_rps_pvp_deposit(UID, 100))
|
||||||
|
run(economy.do_rps_pvp_refund(UID, 100))
|
||||||
|
assert _pending(fake_pb, UID) == {}
|
||||||
|
assert fake_pb.record_for(UID)["balance"] == 500
|
||||||
|
|
||||||
|
|
||||||
|
class TestReconcile:
|
||||||
|
def test_refunds_interrupted_stakes(self, fake_pb):
|
||||||
|
# Simulate a restart: two players left mid-game with escrowed stakes.
|
||||||
|
_fund(fake_pb, UID, 400)
|
||||||
|
run(economy.do_blackjack_bet(UID, 100)) # 300 left, 100 escrowed
|
||||||
|
_fund(fake_pb, OPP, 500)
|
||||||
|
fake_pb.record_for(OPP)["balance"] = 500
|
||||||
|
run(economy.do_rps_pvp_deposit(OPP, 250)) # 250 left, 250 escrowed
|
||||||
|
|
||||||
|
refunded = run(economy.reconcile_pending_wagers())
|
||||||
|
by_uid = {uid: (amt, kind) for uid, amt, kind in refunded}
|
||||||
|
assert by_uid[UID] == (100, "blackjack")
|
||||||
|
assert by_uid[OPP] == (250, "rps")
|
||||||
|
assert fake_pb.record_for(UID)["balance"] == 400 # stake restored
|
||||||
|
assert fake_pb.record_for(OPP)["balance"] == 500
|
||||||
|
assert _pending(fake_pb, UID) == {}
|
||||||
|
assert _pending(fake_pb, OPP) == {}
|
||||||
|
|
||||||
|
def test_noop_when_nothing_pending(self, fake_pb):
|
||||||
|
_fund(fake_pb, UID, 100)
|
||||||
|
assert run(economy.reconcile_pending_wagers()) == []
|
||||||
|
assert fake_pb.record_for(UID)["balance"] == 100
|
||||||
|
|
||||||
|
def test_reconcile_is_idempotent(self, fake_pb):
|
||||||
|
_fund(fake_pb, UID, 400)
|
||||||
|
run(economy.do_blackjack_bet(UID, 100))
|
||||||
|
run(economy.reconcile_pending_wagers())
|
||||||
|
# A second run (e.g. another restart) must not double-refund.
|
||||||
|
assert run(economy.reconcile_pending_wagers()) == []
|
||||||
|
assert fake_pb.record_for(UID)["balance"] == 400
|
||||||
77
tests/test_strings.py
Normal file
77
tests/test_strings.py
Normal 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__)
|
||||||
78
tests/test_vanity.py
Normal file
78
tests/test_vanity.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""Tests for the vanity shop (cosmetic status sink, no gameplay effect)."""
|
||||||
|
|
||||||
|
from core import economy
|
||||||
|
|
||||||
|
from conftest import run
|
||||||
|
|
||||||
|
UID = 7777
|
||||||
|
|
||||||
|
|
||||||
|
def _fund(fake_pb, amount: int) -> None:
|
||||||
|
run(economy.get_user(UID))
|
||||||
|
fake_pb.record_for(UID)["balance"] = amount
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuy:
|
||||||
|
def test_buy_burns_coins_owns_and_equips(self, fake_pb):
|
||||||
|
_fund(fake_pb, 10_000)
|
||||||
|
cost = economy.VANITY["couch"]["cost"]
|
||||||
|
res = run(economy.do_vanity_select(UID, "couch"))
|
||||||
|
assert res["ok"] and res["action"] == "bought"
|
||||||
|
assert res["balance"] == 10_000 - cost
|
||||||
|
user = run(economy.get_user(UID))
|
||||||
|
assert "couch" in user["vanity_owned"]
|
||||||
|
assert user["vanity_active"] == "couch"
|
||||||
|
assert economy.vanity_badge(user) == ("🎮", "Sohvasõdur")
|
||||||
|
|
||||||
|
def test_coins_are_destroyed_not_sent_to_house(self, fake_pb, monkeypatch):
|
||||||
|
_fund(fake_pb, 10_000)
|
||||||
|
credited = []
|
||||||
|
monkeypatch.setattr(economy.house, "_credit_house", lambda amt: credited.append(amt))
|
||||||
|
run(economy.do_vanity_select(UID, "couch"))
|
||||||
|
assert credited == [] # nothing recirculated to the house
|
||||||
|
|
||||||
|
def test_insufficient_funds_rejected(self, fake_pb):
|
||||||
|
_fund(fake_pb, 100)
|
||||||
|
res = run(economy.do_vanity_select(UID, "legend"))
|
||||||
|
assert not res["ok"] and res["reason"] == "insufficient"
|
||||||
|
assert res["need"] == economy.VANITY["legend"]["cost"] - 100
|
||||||
|
assert run(economy.get_user(UID))["balance"] == 100 # unchanged
|
||||||
|
|
||||||
|
def test_banned_rejected(self, fake_pb):
|
||||||
|
_fund(fake_pb, 10_000)
|
||||||
|
fake_pb.record_for(UID)["eco_banned"] = True
|
||||||
|
res = run(economy.do_vanity_select(UID, "couch"))
|
||||||
|
assert not res["ok"] and res["reason"] == "banned"
|
||||||
|
|
||||||
|
def test_unknown_badge(self, fake_pb):
|
||||||
|
_fund(fake_pb, 10_000)
|
||||||
|
res = run(economy.do_vanity_select(UID, "nope"))
|
||||||
|
assert not res["ok"] and res["reason"] == "not_found"
|
||||||
|
|
||||||
|
|
||||||
|
class TestEquip:
|
||||||
|
def test_equip_owned_is_free(self, fake_pb):
|
||||||
|
_fund(fake_pb, 20_000)
|
||||||
|
run(economy.do_vanity_select(UID, "couch")) # buy + equip couch
|
||||||
|
run(economy.do_vanity_select(UID, "cables")) # buy + equip cables
|
||||||
|
bal_before = run(economy.get_user(UID))["balance"]
|
||||||
|
res = run(economy.do_vanity_select(UID, "couch")) # re-equip owned couch
|
||||||
|
assert res["ok"] and res["action"] == "equipped"
|
||||||
|
user = run(economy.get_user(UID))
|
||||||
|
assert user["vanity_active"] == "couch"
|
||||||
|
assert user["balance"] == bal_before # no re-charge
|
||||||
|
|
||||||
|
def test_unequip_clears_badge(self, fake_pb):
|
||||||
|
_fund(fake_pb, 10_000)
|
||||||
|
run(economy.do_vanity_select(UID, "couch"))
|
||||||
|
res = run(economy.do_vanity_select(UID, economy.vanity.NONE_ID))
|
||||||
|
assert res["ok"] and res["action"] == "unequipped"
|
||||||
|
user = run(economy.get_user(UID))
|
||||||
|
assert user["vanity_active"] is None
|
||||||
|
assert economy.vanity_badge(user) is None
|
||||||
|
assert "couch" in user["vanity_owned"] # still owned, just not worn
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_badge_by_default(fake_pb):
|
||||||
|
user = run(economy.get_user(UID))
|
||||||
|
assert economy.vanity_badge(user) is None
|
||||||
Reference in New Issue
Block a user