forked from sass/tipibot
Compare commits
6 Commits
fix/econom
...
ce1ed28904
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce1ed28904 | ||
|
|
52002c37fc | ||
| 567a82b9f2 | |||
| f3c69738ef | |||
|
|
cec5ac01a0 | ||
| 5e303fe36f |
11
.env.example
11
.env.example
@@ -9,6 +9,11 @@ DISCORD_TOKEN=
|
||||
# Google Sheets spreadsheet ID (the long string in the sheet URL)
|
||||
SHEET_ID=your-google-sheet-id-here
|
||||
|
||||
# Separate spreadsheet holding tournament team registrations (Team Name + lineup
|
||||
# of Discord usernames). Optional; drives /teamsync + hourly team-role sync on
|
||||
# the economy/community bot. Leave unset to disable team-role sync entirely.
|
||||
TEAM_SHEET_ID=
|
||||
|
||||
# Path to Google service account credentials JSON
|
||||
GOOGLE_CREDS_PATH=credentials.json
|
||||
|
||||
@@ -31,12 +36,6 @@ BIRTHDAY_CHANNEL_ID=
|
||||
# How many days before a birthday the on-join check counts as "coming up"
|
||||
BIRTHDAY_WINDOW_DAYS=7
|
||||
|
||||
# Channel ID where the daily lottery draw result is announced (optional - the
|
||||
# draw still runs and pays the winner if unset; per-profile like BIRTHDAY_CHANNEL)
|
||||
LOTTERY_CHANNEL_ID_DEV=
|
||||
LOTTERY_CHANNEL_ID_ECONOMY=
|
||||
LOTTERY_CHANNEL_ID=
|
||||
|
||||
# PocketBase backend (https://pocketbase.io)
|
||||
PB_URL=http://127.0.0.1:8090
|
||||
PB_ADMIN_EMAIL=admin@example.com
|
||||
|
||||
66
CLAUDE.md
66
CLAUDE.md
@@ -1,66 +0,0 @@
|
||||
# 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.
|
||||
14
README.md
14
README.md
@@ -245,7 +245,7 @@ The house is listed at **#0** on the leaderboard. Players can attempt to rob it
|
||||
|
||||
| Command | Cooldown | Base payout | Notes |
|
||||
|---|---|---|---|
|
||||
| `/daily` | 20h | 150 ⬡ | Streak multiplier applied (see below). Kõrvaklapid reduces cooldown to 18h 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. |
|
||||
| `/daily` | 20h | 150 ⬡ | Streak multiplier applied (see below). Kõrvaklapid reduces cooldown to 18h. LAN pilet doubles the reward. Bot Farm adds 5% interest on your balance (capped at 500 ⬡/day). Prestige daily_plus adds +20% base per upgrade level. |
|
||||
| `/work` | 1h | 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. |
|
||||
| `/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,19 +336,11 @@ The **ECONOMY** role is granted on your first EXP award (i.e. first successful e
|
||||
| `/rank [@user]` | EXP total, current level, progress bar to next level, leaderboard rank. |
|
||||
| `/stats [@user]` | Lifetime statistics: economy totals, work/beg counts, gambling records, crime/heist history, social totals, best streak. |
|
||||
| `/cooldowns` | All cooldowns at a glance with live Discord timestamps. Shows jail timer if jailed. |
|
||||
| `/leaderboard` | Paginated leaderboard with 6 tabs: 🪙 Coins (net worth = wallet + bank), 📊 EXP, 🏆 Season EXP, 🔥 Prestige, 🎲 Wagered, 🎣 Fish caught. House pinned at #0 on coins tab. |
|
||||
| `/leaderboard` | Paginated leaderboard with 6 tabs: 🪙 Coins, 📊 EXP, 🏆 Season EXP, 🔥 Prestige, 🎲 Wagered, 🎣 Fish caught. House pinned at #0 on coins tab. |
|
||||
| `/shop` | Browse all items by tier. Shows owned status, Anticheat charges remaining, and level lock for T2/T3. |
|
||||
| `/buy <item>` | Purchase an item by name (partial match accepted). |
|
||||
| `/bank` | View your vault. Banked coins are **rob-proof** (`/rob` and `/heist` only touch liquid balance) but earn no Bot Farm interest and can't be spent until withdrawn. |
|
||||
| `/deposit <amount>` | Move coins from your wallet into the bank. `all` deposits everything liquid. |
|
||||
| `/withdraw <amount>` | Move coins from the bank back to your wallet. `all` withdraws everything banked. |
|
||||
| `/consumables` | Buy repeatable, expiring boosts (earn ×2, EXP ×2, or an instant cooldown wipe). A recurring coin sink. |
|
||||
| `/lootbox` | Open a mystery box for **1 000 ⬡**. Weighted random reward: coin tiers (usually a small net loss), a 30-min earn/EXP ×2 buff, or a rare jackpot. |
|
||||
| `/vanity` | Status shop — buy and equip cosmetic badges/titles (shown on `/profile`). Coins are **burned**, not recirculated. No gameplay effect. |
|
||||
| `/reminders` | Toggle per-command DM notifications. Bot DMs you the moment each cooldown expires. |
|
||||
| `/quests` | Personal daily (3) and weekly (2) quests with progress bars and a claim button. |
|
||||
| `/achievements` | Milestone badges over your lifetime stats (work/wealth/gambling/crime/fishing/streaks/prestige). Each unlocks once and pays a one-time coin reward; opening the command claims any newly earned. |
|
||||
| `/lottery [amount]` | View the pot or buy tickets (200 ⬡ each, max 100/draw). One winner is drawn daily at 21:00 Tallinn time and takes the whole pot — more tickets = higher weighted chance. Coins are conserved (the pot equals total ticket spend). Announced in `LOTTERY_CHANNEL_ID` if set. |
|
||||
| `/fish` | Interactive fishing minigame. Cast, wait for bite, pull, then keep or sell. 2min cooldown (90s with Ussipurk). |
|
||||
| `/fishbook` | View your fish collection - all caught species, rarity, count, and current inventory amounts. |
|
||||
| `/fishsell` | Sell all fish currently in your inventory at once. |
|
||||
@@ -432,7 +424,7 @@ All items are **permanent** once purchased **except Anticheat**, which expires a
|
||||
| Hiirematt | 600 ⬡ | `/beg` cooldown 5min → 3min |
|
||||
| Red Bull | 800 ⬡ | `/work` has 30% chance to earn ×3 |
|
||||
| Anticheat | 1 000 ⬡ | Rob attempts against you fail and fine the robber. **2 uses**, then repurchase. |
|
||||
| Kõrvaklapid | 1 200 ⬡ | `/daily` cooldown 20h → 18h and +25 ⬡ bonus |
|
||||
| Kõrvaklapid | 1 200 ⬡ | `/daily` cooldown 20h → 18h |
|
||||
| LAN pilet | 1 200 ⬡ | `/daily` reward ×2 |
|
||||
| Bot Farm | 1 500 ⬡ | `/daily` adds 5% interest on balance (capped at 500 ⬡/day) |
|
||||
|
||||
|
||||
138
bot.py
138
bot.py
@@ -23,7 +23,7 @@ import config
|
||||
import strings as S
|
||||
from core import economy, pb_client, sheets
|
||||
from core.admin import is_bot_admin
|
||||
from core.member_sync import SyncResult
|
||||
from core.member_sync import SyncResult, sync_all_team_roles
|
||||
from commands.dev_member_commands import register_dev_member_commands
|
||||
from commands.dev_member_runtime import handle_member_join, run_birthday_daily
|
||||
from commands.economy_admin_commands import register_economy_admin_commands
|
||||
@@ -35,6 +35,7 @@ from commands.economy_prestige_commands import register_prestige_commands
|
||||
from commands.economy_quests_commands import register_economy_quests_commands
|
||||
from commands.economy_profile_commands import register_economy_profile_commands
|
||||
from commands.economy_support_commands import register_economy_support_commands
|
||||
from commands.economy_team_commands import register_economy_team_commands
|
||||
from commands.ops_channel_commands import register_ops_channel_commands
|
||||
from commands.ops_admin_commands import register_ops_admin_commands
|
||||
from commands.info_commands import register_info_commands
|
||||
@@ -336,69 +337,37 @@ async def before_birthday_daily():
|
||||
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(hours=1)
|
||||
async def team_sync_hourly():
|
||||
"""Reload the tournament registration sheet and re-apply team roles.
|
||||
|
||||
|
||||
@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()
|
||||
Economy profile only (the tournament players live in the community guild).
|
||||
Runs the first iteration immediately on start, so this also covers the
|
||||
initial load at boot. No-op when TEAM_SHEET_ID is unset.
|
||||
"""
|
||||
if IS_DEV_PROFILE or not config.TEAM_SHEET_ID:
|
||||
return
|
||||
try:
|
||||
result = await economy.do_lottery_draw(period)
|
||||
except Exception:
|
||||
log.exception("Lottery draw failed for %s", period)
|
||||
rosters = await sheets.refresh_teams()
|
||||
except Exception as e:
|
||||
log.error("team_sync_hourly: failed to load team sheet: %s", e)
|
||||
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
|
||||
if not rosters:
|
||||
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"))
|
||||
guild = bot.get_guild(config.GUILD_ID)
|
||||
if guild is None:
|
||||
log.warning("team_sync_hourly: guild %s not found", config.GUILD_ID)
|
||||
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,
|
||||
summary = await sync_all_team_roles(guild, log)
|
||||
if summary.assigned or summary.removed or summary.created:
|
||||
log.info(
|
||||
"team_sync_hourly: assigned=%d, removed=%d, created=%d, errors=%d",
|
||||
summary.assigned, summary.removed, len(summary.created), len(summary.errors),
|
||||
)
|
||||
try:
|
||||
await channel.send(content=mention, embed=embed)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
|
||||
@lottery_draw_daily.before_loop
|
||||
async def before_lottery_draw_daily():
|
||||
@team_sync_hourly.before_loop
|
||||
async def before_team_sync_hourly():
|
||||
await bot.wait_until_ready()
|
||||
|
||||
|
||||
@@ -499,10 +468,10 @@ async def on_ready():
|
||||
birthday_daily.start()
|
||||
log.info("Birthday daily task started (fires 09:00 Tallinn time)")
|
||||
|
||||
# Start daily lottery draw (runs in every profile; each has its own collection)
|
||||
if not lottery_draw_daily.is_running():
|
||||
lottery_draw_daily.start()
|
||||
log.info("Lottery draw task started (fires %02d:00 Tallinn time)", economy.lottery.DRAW_HOUR)
|
||||
# Start hourly tournament team-role sync (economy/community guild)
|
||||
if not IS_DEV_PROFILE and config.TEAM_SHEET_ID and not team_sync_hourly.is_running():
|
||||
team_sync_hourly.start()
|
||||
log.info("Team-role sync task started (hourly, from the registration sheet)")
|
||||
|
||||
# Start rotating rich presence
|
||||
if not _rotate_presence.is_running():
|
||||
@@ -512,16 +481,6 @@ async def on_ready():
|
||||
# Re-schedule any reminder tasks lost on restart
|
||||
await _restore_reminders()
|
||||
|
||||
# Refund stakes escrowed by interactive games (blackjack/RPS PvP) that a
|
||||
# restart interrupted, so a mid-hand crash never eats a player's coins.
|
||||
try:
|
||||
refunded = await economy.reconcile_pending_wagers()
|
||||
if refunded:
|
||||
total = sum(amt for _, amt, _ in refunded)
|
||||
log.info("Reconciled %d interrupted wager(s), refunded %d coins", len(refunded), total)
|
||||
except Exception:
|
||||
log.exception("Pending-wager reconciliation failed")
|
||||
|
||||
# Notify the channel where /restart was triggered
|
||||
if _RESTART_FILE.exists():
|
||||
try:
|
||||
@@ -571,6 +530,10 @@ if IS_DEV_PROFILE:
|
||||
has_announced_today=_has_announced_today,
|
||||
mark_announced_today=_mark_announced_today,
|
||||
)
|
||||
else:
|
||||
# Tournament team-role sync lives on the economy/community bot, where the
|
||||
# registered players actually are (see commands/economy_team_commands.py).
|
||||
register_economy_team_commands(tree, bot, log)
|
||||
|
||||
register_ops_admin_commands(
|
||||
tree,
|
||||
@@ -769,10 +732,8 @@ register_prestige_commands(
|
||||
|
||||
def _parse_amount(value: str, balance: int) -> tuple[int | None, str | None]:
|
||||
"""Parse an amount string; 'all' resolves to the user's full balance.
|
||||
Accepts plain non-negative integers and valid thousand-separated numbers
|
||||
(1,000 / 1.000 / 1 000). Rejects decimals, ambiguous inputs like 1,1 or 1.5,
|
||||
and negative amounts (a negative bet/give would mint coins on loss/transfer
|
||||
paths that trust the caller's sign).
|
||||
Accepts plain integers and valid thousand-separated numbers (1,000 / 1.000 / 1 000).
|
||||
Rejects decimals and ambiguous inputs like 1,1 or 1.5.
|
||||
Returns (amount, None) on success or (None, error_msg) on failure."""
|
||||
v = value.strip()
|
||||
if v.lower() == "all":
|
||||
@@ -781,12 +742,9 @@ def _parse_amount(value: str, balance: int) -> tuple[int | None, str | None]:
|
||||
if re.fullmatch(r'\d{1,3}([,. ]\d{3})*', v):
|
||||
v = re.sub(r'[,. ]', '', v)
|
||||
try:
|
||||
amount = int(v)
|
||||
return int(v), None
|
||||
except ValueError:
|
||||
return None, S.ERR["invalid_amount"]
|
||||
if amount < 0:
|
||||
return None, S.ERR["invalid_amount"]
|
||||
return amount, None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -853,7 +811,17 @@ async def _restore_reminders() -> None:
|
||||
last_str = user.get(last_key)
|
||||
if not last_str:
|
||||
continue
|
||||
cooldown = economy.effective_cooldown(cmd, user.get("items", []))
|
||||
items = user.get("items", [])
|
||||
if cmd == "work" and "monitor" in items:
|
||||
cooldown = datetime.timedelta(minutes=40)
|
||||
elif cmd == "beg" and "hiirematt" in items:
|
||||
cooldown = datetime.timedelta(minutes=3)
|
||||
elif cmd == "daily" and "korvaklapid" in items:
|
||||
cooldown = datetime.timedelta(hours=18)
|
||||
elif cmd == "fish" and "ussipurk" in items:
|
||||
cooldown = datetime.timedelta(seconds=90)
|
||||
else:
|
||||
cooldown = economy.COOLDOWNS.get(cmd)
|
||||
if not cooldown:
|
||||
continue
|
||||
last_dt = datetime.datetime.fromisoformat(last_str)
|
||||
@@ -872,7 +840,17 @@ async def _maybe_remind(user_id: int, cmd: str) -> None:
|
||||
user_data = await economy.get_user(user_id)
|
||||
if cmd not in user_data.get("reminders", []):
|
||||
return
|
||||
delay = economy.effective_cooldown(cmd, user_data.get("items", [])) or datetime.timedelta(hours=1)
|
||||
items = set(user_data.get("items", []))
|
||||
if cmd == "work" and "monitor" in items:
|
||||
delay = datetime.timedelta(minutes=40)
|
||||
elif cmd == "beg" and "hiirematt" in items:
|
||||
delay = datetime.timedelta(minutes=3)
|
||||
elif cmd == "daily" and "korvaklapid" in items:
|
||||
delay = datetime.timedelta(hours=18)
|
||||
elif cmd == "fish" and "ussipurk" in items:
|
||||
delay = datetime.timedelta(seconds=90)
|
||||
else:
|
||||
delay = economy.COOLDOWNS.get(cmd, datetime.timedelta(hours=1))
|
||||
_schedule_reminder(user_id, cmd, delay)
|
||||
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
"""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,29 +211,17 @@ def register_economy_admin_commands(
|
||||
prestige_pp = data.get("prestige_points", 0)
|
||||
total_fish = data.get("total_fish_caught", 0)
|
||||
inv_fish = len(data.get("fish_inventory") or [])
|
||||
pw = data.get("pending_wager") or {}
|
||||
wager_str = f"{pw.get('amount', 0):,} ({pw.get('kind')})" if pw.get("amount") else "-"
|
||||
embed = discord.Embed(
|
||||
title=S.ADMINVIEW_UI["title"].format(name=kasutaja.display_name),
|
||||
color=0x5865F2,
|
||||
)
|
||||
embed.add_field(name=S.ADMINVIEW_UI["f_balance"], value=f"{data.get('balance', 0):,} {economy.COIN}", inline=True)
|
||||
embed.add_field(name=S.ADMINVIEW_UI["f_bank"], value=f"{data.get('bank_balance', 0):,} {economy.COIN}", inline=True)
|
||||
embed.add_field(name=S.ADMINVIEW_UI["f_exp"], value=S.ADMINVIEW_UI["exp_val"].format(exp=f"{exp:,}", level=level), inline=True)
|
||||
embed.add_field(name=S.ADMINVIEW_UI["f_streak"], value=str(data.get("daily_streak", 0)), inline=True)
|
||||
embed.add_field(name=S.ADMINVIEW_UI["f_banned"], value=banned, inline=True)
|
||||
embed.add_field(name=S.ADMINVIEW_UI["f_jailed"], value=jailed, inline=True)
|
||||
embed.add_field(name=S.ADMINVIEW_UI["f_prestige"], value=S.ADMINVIEW_UI["prestige_val"].format(level=prestige_lvl, pp=prestige_pp), inline=True)
|
||||
embed.add_field(name=S.ADMINVIEW_UI["f_fish"], value=S.ADMINVIEW_UI["fish_val"].format(caught=total_fish, inv=inv_fish), inline=True)
|
||||
embed.add_field(
|
||||
name=S.ADMINVIEW_UI["f_extras"],
|
||||
value=S.ADMINVIEW_UI["extras_val"].format(
|
||||
ach=len(data.get("achievements_earned") or []),
|
||||
lootboxes=data.get("lootboxes_opened", 0),
|
||||
wager=wager_str,
|
||||
),
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(name=S.ADMINVIEW_UI["f_items"], value=items_str, inline=False)
|
||||
embed.add_field(name=S.ADMINVIEW_UI["f_uses"], value=uses_str, inline=False)
|
||||
embed.add_field(name=S.ADMINVIEW_UI["f_last_daily"], value=data.get("last_daily") or "-", inline=True)
|
||||
|
||||
@@ -5,7 +5,6 @@ import datetime
|
||||
import random
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable, MutableSet
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
@@ -14,10 +13,6 @@ from core import economy
|
||||
from core.emoji import EMOJI as E
|
||||
import strings as S
|
||||
|
||||
from ._replies import reply_db_error
|
||||
|
||||
_TALLINN = ZoneInfo("Europe/Tallinn")
|
||||
|
||||
|
||||
def register_economy_extra_commands(
|
||||
tree: app_commands.CommandTree,
|
||||
@@ -125,9 +120,6 @@ def register_economy_extra_commands(
|
||||
return
|
||||
res = await economy.do_heist_check(interaction.user.id)
|
||||
if not res["ok"]:
|
||||
if res["reason"] == "db_error":
|
||||
await reply_db_error(interaction)
|
||||
return
|
||||
if res["reason"] == "banned":
|
||||
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||
elif res["reason"] == "jailed":
|
||||
@@ -278,9 +270,6 @@ def register_economy_extra_commands(
|
||||
return
|
||||
res = await economy.do_heist_check(interaction.user.id)
|
||||
if not res["ok"]:
|
||||
if res["reason"] == "db_error":
|
||||
await reply_db_error(interaction)
|
||||
return
|
||||
if res["reason"] == "banned":
|
||||
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||
elif res["reason"] == "jailed":
|
||||
@@ -529,9 +518,6 @@ def register_economy_extra_commands(
|
||||
|
||||
res = await economy.do_give(interaction.user.id, kasutaja.id, summa_int)
|
||||
if not res["ok"]:
|
||||
if res["reason"] == "db_error":
|
||||
await reply_db_error(interaction)
|
||||
return
|
||||
if res["reason"] == "banned":
|
||||
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||
elif res["reason"] == "jailed":
|
||||
@@ -557,64 +543,6 @@ def register_economy_extra_commands(
|
||||
)
|
||||
await interaction.response.send_message(embed=embed)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# /lottery - daily draw, one weighted winner takes the pot
|
||||
# -----------------------------------------------------------------------
|
||||
@tree.command(name="lottery", description=S.CMD["lottery"])
|
||||
@app_commands.describe(kogus=S.OPT["lottery_kogus"])
|
||||
async def cmd_lottery(interaction: discord.Interaction, kogus: int | None = None):
|
||||
period = economy.period_for(datetime.datetime.now(_TALLINN))
|
||||
|
||||
if kogus is None:
|
||||
state = await economy.get_lottery_state(period, interaction.user.id)
|
||||
embed = discord.Embed(
|
||||
title=S.LOTTERY_UI["title"],
|
||||
description=S.LOTTERY_UI["desc"].format(
|
||||
draw_time=f"{economy.DRAW_HOUR}:00", cost=coin(state["ticket_cost"])
|
||||
),
|
||||
color=0xF4C430,
|
||||
)
|
||||
embed.add_field(name=S.LOTTERY_UI["f_pot"], value=coin(state["pot"]), inline=True)
|
||||
embed.add_field(name=S.LOTTERY_UI["f_players"], value=str(state["participants"]), inline=True)
|
||||
if state["your_tickets"]:
|
||||
chance = round(state["your_tickets"] / state["total_tickets"] * 100, 1)
|
||||
embed.add_field(
|
||||
name=S.LOTTERY_UI["f_your"],
|
||||
value=S.LOTTERY_UI["your_val"].format(tickets=state["your_tickets"], chance=chance),
|
||||
inline=False,
|
||||
)
|
||||
else:
|
||||
embed.add_field(name=S.LOTTERY_UI["f_your"], value=S.LOTTERY_UI["your_none"], inline=False)
|
||||
await interaction.response.send_message(embed=embed)
|
||||
return
|
||||
|
||||
if kogus <= 0:
|
||||
await interaction.response.send_message(S.ERR["positive_amount"], ephemeral=True)
|
||||
return
|
||||
res = await economy.do_buy_ticket(interaction.user.id, kogus, period)
|
||||
if not res["ok"]:
|
||||
if res["reason"] == "db_error":
|
||||
await reply_db_error(interaction)
|
||||
elif res["reason"] == "banned":
|
||||
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||
elif res["reason"] == "max_tickets":
|
||||
await interaction.response.send_message(
|
||||
S.LOTTERY_UI["max_tickets"].format(cap=res["cap"], held=res["held"]), ephemeral=True
|
||||
)
|
||||
elif res["reason"] == "insufficient":
|
||||
await interaction.response.send_message(
|
||||
S.ERR["broke_need"].format(need=coin(res["need"])), ephemeral=True
|
||||
)
|
||||
else:
|
||||
await interaction.response.send_message(S.ERR["invalid_amount"], ephemeral=True)
|
||||
return
|
||||
await interaction.response.send_message(
|
||||
S.LOTTERY_UI["bought"].format(
|
||||
count=res["bought"], cost=coin(res["cost"]),
|
||||
tickets=res["tickets"], balance=coin(res["balance"]),
|
||||
)
|
||||
)
|
||||
|
||||
class LeaderboardView(discord.ui.View):
|
||||
PER_PAGE = 10
|
||||
|
||||
@@ -823,13 +751,14 @@ def register_economy_extra_commands(
|
||||
@tree.command(name="leaderboard", description=S.CMD["leaderboard"])
|
||||
async def cmd_leaderboard(interaction: discord.Interaction):
|
||||
await interaction.response.defer()
|
||||
lbs = await economy.get_all_leaderboards() # single collection scan for all six tabs
|
||||
coins_raw = lbs["coins"]
|
||||
exp_raw = lbs["exp"]
|
||||
season_raw = lbs["season"]
|
||||
prestige_raw = lbs["prestige"]
|
||||
wagered_raw = lbs["wagered"]
|
||||
fish_raw = lbs["fish"]
|
||||
coins_raw, exp_raw, season_raw, prestige_raw, wagered_raw, fish_raw = await asyncio.gather(
|
||||
economy.get_leaderboard(top_n=None),
|
||||
economy.get_leaderboard_exp(top_n=None),
|
||||
economy.get_leaderboard_season_exp(top_n=None),
|
||||
economy.get_leaderboard_prestige(top_n=None),
|
||||
economy.get_leaderboard_wagered(top_n=None),
|
||||
economy.get_leaderboard_fish(top_n=None),
|
||||
)
|
||||
|
||||
house_entry = None
|
||||
regular = []
|
||||
@@ -936,9 +865,6 @@ def register_economy_extra_commands(
|
||||
async def cmd_buy(interaction: discord.Interaction, ese: app_commands.Choice[str]):
|
||||
res = await economy.do_buy(interaction.user.id, ese.value)
|
||||
if not res["ok"]:
|
||||
if res["reason"] == "db_error":
|
||||
await reply_db_error(interaction)
|
||||
return
|
||||
if res["reason"] == "banned":
|
||||
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||
elif res["reason"] == "owned":
|
||||
|
||||
@@ -10,8 +10,6 @@ from discord import app_commands
|
||||
from core import economy
|
||||
import strings as S
|
||||
|
||||
from ._replies import reply_db_error
|
||||
|
||||
|
||||
def register_economy_fish_commands(
|
||||
tree: app_commands.CommandTree,
|
||||
@@ -220,9 +218,6 @@ def register_economy_fish_commands(
|
||||
|
||||
res = await economy.do_fish_start(interaction.user.id)
|
||||
if not res["ok"]:
|
||||
if res["reason"] == "db_error":
|
||||
await reply_db_error(interaction)
|
||||
return
|
||||
if res["reason"] == "banned":
|
||||
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||
elif res["reason"] == "cooldown":
|
||||
|
||||
@@ -12,8 +12,6 @@ from core import economy
|
||||
from core.emoji import EMOJI as E
|
||||
import strings as S
|
||||
|
||||
from ._replies import reply_db_error
|
||||
|
||||
|
||||
def register_economy_games_commands(
|
||||
tree: app_commands.CommandTree,
|
||||
@@ -118,9 +116,6 @@ def register_economy_games_commands(
|
||||
res = await economy.do_roulette(interaction.user.id, panus_int, värv.value)
|
||||
if not res["ok"]:
|
||||
active_games.discard(interaction.user.id)
|
||||
if res["reason"] == "db_error":
|
||||
await reply_db_error(interaction)
|
||||
return
|
||||
if res["reason"] == "banned":
|
||||
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||
elif res["reason"] == "jailed":
|
||||
@@ -207,14 +202,7 @@ def register_economy_games_commands(
|
||||
bet_line = ""
|
||||
if self.bet > 0:
|
||||
res = await economy.do_game_bet(interaction.user.id, self.bet, outcome)
|
||||
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":
|
||||
if outcome == "win":
|
||||
bet_line = S.RPS_UI["bet_win"].format(amount=coin(self.bet), balance=coin(res["balance"]))
|
||||
elif outcome == "lose":
|
||||
bet_line = S.RPS_UI["bet_lose"].format(amount=coin(self.bet), balance=coin(res["balance"]))
|
||||
@@ -290,12 +278,10 @@ def register_economy_games_commands(
|
||||
if self.bet > 0:
|
||||
if winner == "a":
|
||||
await economy.do_rps_pvp_payout(self.player_a.id, self.bet)
|
||||
await economy.do_rps_pvp_forfeit(self.player_b.id)
|
||||
bet_line_a = f"\n+{coin(self.bet)}"
|
||||
bet_line_b = f"\n-{coin(self.bet)}"
|
||||
elif winner == "b":
|
||||
await economy.do_rps_pvp_payout(self.player_b.id, self.bet)
|
||||
await economy.do_rps_pvp_forfeit(self.player_a.id)
|
||||
bet_line_a = f"\n-{coin(self.bet)}"
|
||||
bet_line_b = f"\n+{coin(self.bet)}"
|
||||
else:
|
||||
@@ -666,9 +652,6 @@ def register_economy_games_commands(
|
||||
res = await economy.do_slots(interaction.user.id, panus_int)
|
||||
if not res["ok"]:
|
||||
active_games.discard(interaction.user.id)
|
||||
if res["reason"] == "db_error":
|
||||
await reply_db_error(interaction)
|
||||
return
|
||||
if res["reason"] == "banned":
|
||||
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||
return
|
||||
@@ -943,17 +926,13 @@ def register_economy_games_commands(
|
||||
embed = self._cur_embed(game_over=True, hand_results=hand_results)
|
||||
embed.title = S.TITLE[title_key]
|
||||
embed.color = color
|
||||
if res.get("ok"):
|
||||
result_line = result_str + S.BJ_UI["balance_line"].format(balance=coin(res["balance"]))
|
||||
else:
|
||||
result_line = result_str + "\n" + S.ERR["payout_failed"]
|
||||
embed.add_field(
|
||||
name=S.BJ["result_field"],
|
||||
value=result_line,
|
||||
value=result_str + S.BJ_UI["balance_line"].format(balance=coin(res["balance"])),
|
||||
inline=False,
|
||||
)
|
||||
await self.message.edit(embed=embed, view=self)
|
||||
if res.get("ok") and total_payout > total_invested:
|
||||
if total_payout > total_invested:
|
||||
asyncio.create_task(award_exp(interaction, economy.gamble_exp(total_invested)))
|
||||
|
||||
async def _do_dealer_reveal(self, interaction: discord.Interaction) -> None:
|
||||
@@ -1034,9 +1013,6 @@ def register_economy_games_commands(
|
||||
try:
|
||||
res = await economy.do_blackjack_bet(self.user_id, self.bet)
|
||||
if not res["ok"]:
|
||||
if res["reason"] == "db_error":
|
||||
await reply_db_error(interaction)
|
||||
return
|
||||
await interaction.response.send_message(
|
||||
S.ERR["broke"].format(bal=coin(res.get("balance", 0))), ephemeral=True
|
||||
)
|
||||
@@ -1062,9 +1038,6 @@ def register_economy_games_commands(
|
||||
try:
|
||||
res = await economy.do_blackjack_bet(self.user_id, self.bet)
|
||||
if not res["ok"]:
|
||||
if res["reason"] == "db_error":
|
||||
await reply_db_error(interaction)
|
||||
return
|
||||
await interaction.response.send_message(
|
||||
S.ERR["broke"].format(bal=coin(res.get("balance", 0))), ephemeral=True
|
||||
)
|
||||
@@ -1124,9 +1097,6 @@ def register_economy_games_commands(
|
||||
|
||||
res = await economy.do_blackjack_bet(interaction.user.id, bet)
|
||||
if not res["ok"]:
|
||||
if res["reason"] == "db_error":
|
||||
await reply_db_error(interaction)
|
||||
return
|
||||
if res["reason"] == "banned":
|
||||
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||
elif res["reason"] == "jailed":
|
||||
@@ -1180,37 +1150,32 @@ def register_economy_games_commands(
|
||||
await asyncio.sleep(_BJ_DEAL_DELAY)
|
||||
if _bj_is_blackjack(dealer_hand):
|
||||
push_res = await economy.do_blackjack_payout(interaction.user.id, bet, bet)
|
||||
push_line = (
|
||||
S.BJ["push_result"] + S.BJ_UI["balance_line"].format(balance=coin(push_res["balance"]))
|
||||
if push_res.get("ok")
|
||||
else S.BJ["push_result"] + "\n" + S.ERR["payout_failed"]
|
||||
)
|
||||
embed = _bj_embed(
|
||||
player_hand,
|
||||
dealer_hand,
|
||||
S.TITLE["blackjack_push"],
|
||||
0x99AAB5,
|
||||
hide_dealer=False,
|
||||
result_field=(S.BJ["result_field"], push_line),
|
||||
result_field=(
|
||||
S.BJ["result_field"],
|
||||
S.BJ["push_result"] + S.BJ_UI["balance_line"].format(balance=coin(push_res["balance"])),
|
||||
),
|
||||
)
|
||||
else:
|
||||
payout = bet + int(bet * 1.5)
|
||||
bj_res = await economy.do_blackjack_payout(interaction.user.id, payout, bet)
|
||||
bj_line = (
|
||||
f"+{coin(payout)}" + S.BJ_UI["balance_line"].format(balance=coin(bj_res["balance"]))
|
||||
if bj_res.get("ok")
|
||||
else f"+{coin(payout)}" + "\n" + S.ERR["payout_failed"]
|
||||
)
|
||||
embed = _bj_embed(
|
||||
player_hand,
|
||||
dealer_hand,
|
||||
S.TITLE["blackjack_bj"],
|
||||
0xF4C430,
|
||||
hide_dealer=False,
|
||||
result_field=(S.BJ["result_field"], bj_line),
|
||||
result_field=(
|
||||
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)
|
||||
await msg.edit(embed=embed)
|
||||
return
|
||||
|
||||
@@ -10,8 +10,6 @@ from discord import app_commands
|
||||
from core import economy
|
||||
import strings as S
|
||||
|
||||
from ._replies import reply_db_error
|
||||
|
||||
|
||||
def register_economy_income_commands(
|
||||
tree: app_commands.CommandTree,
|
||||
@@ -27,9 +25,6 @@ def register_economy_income_commands(
|
||||
await interaction.response.defer()
|
||||
res = await economy.do_daily(interaction.user.id)
|
||||
if not res["ok"]:
|
||||
if res["reason"] == "db_error":
|
||||
await reply_db_error(interaction)
|
||||
return
|
||||
if res["reason"] == "banned":
|
||||
await interaction.followup.send(S.MSG_BANNED, ephemeral=True)
|
||||
elif res["reason"] == "cooldown":
|
||||
@@ -68,9 +63,6 @@ def register_economy_income_commands(
|
||||
await interaction.response.defer()
|
||||
res = await economy.do_work(interaction.user.id)
|
||||
if not res["ok"]:
|
||||
if res["reason"] == "db_error":
|
||||
await reply_db_error(interaction)
|
||||
return
|
||||
if res["reason"] == "banned":
|
||||
await interaction.followup.send(S.MSG_BANNED, ephemeral=True)
|
||||
elif res["reason"] == "cooldown":
|
||||
@@ -105,9 +97,6 @@ def register_economy_income_commands(
|
||||
await interaction.response.defer()
|
||||
res = await economy.do_beg(interaction.user.id)
|
||||
if not res["ok"]:
|
||||
if res["reason"] == "db_error":
|
||||
await reply_db_error(interaction)
|
||||
return
|
||||
if res["reason"] == "banned":
|
||||
await interaction.followup.send(S.MSG_BANNED, ephemeral=True)
|
||||
elif res["reason"] == "cooldown":
|
||||
@@ -139,9 +128,6 @@ def register_economy_income_commands(
|
||||
await interaction.response.defer()
|
||||
res = await economy.do_crime(interaction.user.id)
|
||||
if not res["ok"]:
|
||||
if res["reason"] == "db_error":
|
||||
await reply_db_error(interaction)
|
||||
return
|
||||
if res["reason"] == "banned":
|
||||
await interaction.followup.send(S.MSG_BANNED, ephemeral=True)
|
||||
elif res["reason"] == "cooldown":
|
||||
@@ -201,9 +187,6 @@ def register_economy_income_commands(
|
||||
await interaction.response.defer()
|
||||
res = await economy.do_rob(interaction.user.id, sihtmärk.id)
|
||||
if not res["ok"]:
|
||||
if res["reason"] == "db_error":
|
||||
await reply_db_error(interaction)
|
||||
return
|
||||
if res["reason"] == "banned":
|
||||
await interaction.followup.send(S.MSG_BANNED, ephemeral=True)
|
||||
elif res["reason"] == "cooldown":
|
||||
|
||||
@@ -9,8 +9,6 @@ from discord import app_commands
|
||||
from core import economy
|
||||
import strings as S
|
||||
|
||||
from ._replies import reply_db_error
|
||||
|
||||
|
||||
def register_prestige_commands(
|
||||
tree: app_commands.CommandTree,
|
||||
@@ -129,9 +127,6 @@ def register_prestige_commands(
|
||||
return
|
||||
await interaction.response.defer()
|
||||
res = await economy.do_prestige(self.user_id)
|
||||
if not res["ok"] and res.get("reason") == "db_error":
|
||||
await reply_db_error(interaction)
|
||||
return
|
||||
self.clear_items()
|
||||
if not res["ok"]:
|
||||
embed = discord.Embed(
|
||||
@@ -171,9 +166,6 @@ def register_prestige_commands(
|
||||
await interaction.response.defer()
|
||||
res = await economy.do_prestige_buy(self.user_id, upgrade_id)
|
||||
if not res["ok"]:
|
||||
if res["reason"] == "db_error":
|
||||
await reply_db_error(interaction)
|
||||
return
|
||||
if res["reason"] == "insufficient_pp":
|
||||
err = S.PRESTIGE_UI["buy_no_pp"].format(have=res["have"], need=res["need"])
|
||||
elif res["reason"] == "maxed":
|
||||
@@ -229,9 +221,6 @@ def register_prestige_commands(
|
||||
return
|
||||
res = await economy.do_prestige_buy(interaction.user.id, upgrade.strip().lower())
|
||||
if not res["ok"]:
|
||||
if res["reason"] == "db_error":
|
||||
await reply_db_error(interaction)
|
||||
return
|
||||
if res["reason"] == "banned":
|
||||
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||
elif res["reason"] == "not_found":
|
||||
|
||||
@@ -10,8 +10,6 @@ from discord import app_commands
|
||||
from core import economy
|
||||
import strings as S
|
||||
|
||||
from ._replies import reply_db_error
|
||||
|
||||
|
||||
def register_economy_profile_commands(
|
||||
tree: app_commands.CommandTree,
|
||||
@@ -31,15 +29,10 @@ def register_economy_profile_commands(
|
||||
pct = progress / needed if needed > 0 else 1.0
|
||||
filled = int(pct * 12)
|
||||
bar = "█" * filled + "░" * (12 - filled)
|
||||
badge = economy.vanity_badge(data)
|
||||
title = (
|
||||
f"{badge[0]} {target.display_name}"
|
||||
if badge
|
||||
else S.PROFILE_UI["main_title"].format(name=target.display_name)
|
||||
embed = discord.Embed(
|
||||
title=S.PROFILE_UI["main_title"].format(name=target.display_name),
|
||||
color=0xF4C430,
|
||||
)
|
||||
embed = discord.Embed(title=title, color=0xF4C430)
|
||||
if badge:
|
||||
embed.set_author(name=badge[1])
|
||||
embed.add_field(name=S.PROFILE_UI["f_balance"], value=coin(data.get("balance", 0)), inline=True)
|
||||
embed.add_field(
|
||||
name=S.PROFILE_UI["f_level"],
|
||||
@@ -155,12 +148,7 @@ def register_economy_profile_commands(
|
||||
)
|
||||
embed.add_field(
|
||||
name=S.STATS_UI["records_field"],
|
||||
value=S.STATS_UI["records_val"].format(
|
||||
streak=_s("best_daily_streak"),
|
||||
lootboxes=_s("lootboxes_opened"),
|
||||
achievements=len(data.get("achievements_earned") or []),
|
||||
ach_total=len(economy.ACHIEVEMENTS),
|
||||
),
|
||||
value=S.STATS_UI["records_val"].format(streak=_s("best_daily_streak")),
|
||||
inline=True,
|
||||
)
|
||||
return embed
|
||||
@@ -299,9 +287,6 @@ def register_economy_profile_commands(
|
||||
color=0xF4C430,
|
||||
)
|
||||
embed.add_field(name=S.BALANCE_UI["saldo"], value=coin(data["balance"]), inline=True)
|
||||
bank_balance = data.get("bank_balance", 0)
|
||||
if bank_balance:
|
||||
embed.add_field(name=S.BANK_UI["f_bank"], value=coin(bank_balance), inline=True)
|
||||
streak = data.get("daily_streak", 0)
|
||||
if streak:
|
||||
embed.add_field(
|
||||
@@ -349,53 +334,6 @@ def register_economy_profile_commands(
|
||||
data = await economy.get_user(target.id)
|
||||
await interaction.response.send_message(embed=_balance_embed(target, data))
|
||||
|
||||
@tree.command(name="achievements", description=S.CMD["achievements"])
|
||||
async def cmd_achievements(
|
||||
interaction: discord.Interaction, kasutaja: discord.Member | None = None
|
||||
):
|
||||
target = kasutaja or interaction.user
|
||||
note = ""
|
||||
# Only the invoker's own view claims newly-earned achievements.
|
||||
if target.id == interaction.user.id:
|
||||
res = await economy.do_check_achievements(interaction.user.id)
|
||||
if not res["ok"]:
|
||||
await reply_db_error(interaction)
|
||||
return
|
||||
if res["new"]:
|
||||
names = ", ".join(
|
||||
f"{economy.ACHIEVEMENTS[a]['emoji']} {economy.ACHIEVEMENTS[a]['name']}"
|
||||
for a in res["new"]
|
||||
)
|
||||
note = S.ACHIEVEMENTS_UI["unlocked_note"].format(
|
||||
names=names, reward=coin(res["reward"])
|
||||
) + "\n\n"
|
||||
|
||||
data = await economy.get_user(target.id)
|
||||
rows = economy.achievements_view(data)
|
||||
earned_count = sum(1 for r in rows if r["earned"])
|
||||
lines = [
|
||||
S.ACHIEVEMENTS_UI["row_earned"].format(
|
||||
emoji=r["emoji"], name=r["name"], reward=coin(r["reward"])
|
||||
)
|
||||
if r["earned"]
|
||||
else S.ACHIEVEMENTS_UI["row_locked"].format(
|
||||
emoji=r["emoji"], name=r["name"], progress=r["progress"],
|
||||
goal=r["goal"], reward=coin(r["reward"]),
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
title = S.ACHIEVEMENTS_UI["title"]
|
||||
if target.id != interaction.user.id:
|
||||
title += f" · {target.display_name}"
|
||||
embed = discord.Embed(
|
||||
title=title,
|
||||
description=note
|
||||
+ S.ACHIEVEMENTS_UI["desc"].format(earned=earned_count, total=len(rows))
|
||||
+ "\n\n" + "\n".join(lines),
|
||||
color=0xF4C430,
|
||||
)
|
||||
await interaction.response.send_message(embed=embed)
|
||||
|
||||
@tree.command(name="cooldowns", description=S.CMD["cooldowns"])
|
||||
async def cmd_cooldowns(interaction: discord.Interaction):
|
||||
data = await economy.get_user(interaction.user.id)
|
||||
@@ -601,12 +539,7 @@ def register_economy_profile_commands(
|
||||
)
|
||||
embed.add_field(
|
||||
name=S.STATS_UI["records_field"],
|
||||
value=S.STATS_UI["records_val"].format(
|
||||
streak=_s("best_daily_streak"),
|
||||
lootboxes=_s("lootboxes_opened"),
|
||||
achievements=len(data.get("achievements_earned") or []),
|
||||
ach_total=len(economy.ACHIEVEMENTS),
|
||||
),
|
||||
value=S.STATS_UI["records_val"].format(streak=_s("best_daily_streak")),
|
||||
inline=True,
|
||||
)
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
|
||||
import discord
|
||||
@@ -9,8 +8,6 @@ from discord import app_commands
|
||||
from core import economy
|
||||
import strings as S
|
||||
|
||||
from ._replies import reply_db_error
|
||||
|
||||
|
||||
def register_economy_support_commands(
|
||||
tree: app_commands.CommandTree,
|
||||
@@ -50,9 +47,6 @@ def register_economy_support_commands(
|
||||
res = await economy.do_give(interaction.user.id, self._view.requester.id, amount)
|
||||
if not res["ok"]:
|
||||
self._view.remaining += amount # roll back the reservation
|
||||
if res["reason"] == "db_error":
|
||||
await reply_db_error(interaction)
|
||||
return
|
||||
data = await economy.get_user(interaction.user.id)
|
||||
await interaction.response.send_message(
|
||||
S.ERR["broke"].format(bal=coin(data["balance"])), ephemeral=True
|
||||
@@ -218,9 +212,6 @@ def register_economy_support_commands(
|
||||
|
||||
res = await economy.do_buy_consumable(interaction.user.id, ese.value)
|
||||
if not res["ok"]:
|
||||
if res["reason"] == "db_error":
|
||||
await reply_db_error(interaction)
|
||||
return
|
||||
if res["reason"] == "banned":
|
||||
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||
elif res["reason"] == "insufficient":
|
||||
@@ -233,10 +224,6 @@ def register_economy_support_commands(
|
||||
|
||||
cons = res["consumable"]
|
||||
if res["instant"]:
|
||||
# Kohv wiped these cooldowns, so any reminder DM already scheduled for
|
||||
# them is now stale - cancel it (the next command run reschedules).
|
||||
for cmd in economy.INSTANT_RESET_COMMANDS:
|
||||
cancel_reminder_task(interaction.user.id, cmd)
|
||||
desc = S.CONSUMABLES_UI["bought_instant"].format(balance=coin(res["balance"]))
|
||||
else:
|
||||
key = "bought_extended" if res["extended"] else "bought_buff"
|
||||
@@ -251,181 +238,6 @@ def register_economy_support_commands(
|
||||
)
|
||||
await interaction.response.send_message(embed=embed)
|
||||
|
||||
# -- /vanity ------------------------------------------------------------
|
||||
def _vanity_embed(user_data: dict) -> discord.Embed:
|
||||
owned = set(user_data.get("vanity_owned") or [])
|
||||
active = user_data.get("vanity_active")
|
||||
embed = discord.Embed(
|
||||
title=S.VANITY_UI["title"],
|
||||
description=S.VANITY_UI["desc"].format(bal=coin(user_data.get("balance", 0))),
|
||||
color=0xF4C430,
|
||||
)
|
||||
for vid, v in economy.VANITY.items():
|
||||
if vid == active:
|
||||
status = S.VANITY_UI["line_active"]
|
||||
elif vid in owned:
|
||||
status = S.VANITY_UI["line_owned"]
|
||||
else:
|
||||
status = f"{v['cost']} {economy.COIN}"
|
||||
embed.add_field(
|
||||
name=f"{v['emoji']} {v['name']} · {status}",
|
||||
value=S.VANITY_UI["entry_title"].format(title=v["title"]),
|
||||
inline=False,
|
||||
)
|
||||
embed.set_footer(text=S.VANITY_UI["footer"])
|
||||
return embed
|
||||
|
||||
@tree.command(name="vanity", description=S.CMD["vanity"])
|
||||
@app_commands.describe(ese=S.OPT["vanity_ese"])
|
||||
@app_commands.choices(
|
||||
ese=[
|
||||
app_commands.Choice(name=f"{v['emoji']} {v['title']} ({v['cost']} TipiCOINi)", value=vid)
|
||||
for vid, v in economy.VANITY.items()
|
||||
]
|
||||
+ [app_commands.Choice(name=S.VANITY_UI["none_choice"], value=economy.vanity.NONE_ID)]
|
||||
)
|
||||
async def cmd_vanity(
|
||||
interaction: discord.Interaction,
|
||||
ese: app_commands.Choice[str] | None = None,
|
||||
):
|
||||
if ese is None:
|
||||
data = await economy.get_user(interaction.user.id)
|
||||
await interaction.response.send_message(
|
||||
embed=_vanity_embed(data), ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
res = await economy.do_vanity_select(interaction.user.id, ese.value)
|
||||
if not res["ok"]:
|
||||
if res["reason"] == "db_error":
|
||||
await reply_db_error(interaction)
|
||||
return
|
||||
if res["reason"] == "banned":
|
||||
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||
elif res["reason"] == "insufficient":
|
||||
await interaction.response.send_message(
|
||||
S.ERR["broke_need"].format(need=coin(res["need"])), ephemeral=True
|
||||
)
|
||||
else:
|
||||
await interaction.response.send_message(S.ERR["item_not_found"], ephemeral=True)
|
||||
return
|
||||
|
||||
action = res["action"]
|
||||
if action == "unequipped":
|
||||
await interaction.response.send_message(S.VANITY_UI["unequipped"], ephemeral=True)
|
||||
return
|
||||
v = res["vanity"]
|
||||
if action == "bought":
|
||||
msg = S.VANITY_UI["bought"].format(
|
||||
emoji=v["emoji"], title=v["title"], balance=coin(res["balance"])
|
||||
)
|
||||
else:
|
||||
msg = S.VANITY_UI["equipped"].format(emoji=v["emoji"], title=v["title"])
|
||||
await interaction.response.send_message(msg)
|
||||
|
||||
# -- /lootbox -----------------------------------------------------------
|
||||
@tree.command(name="lootbox", description=S.CMD["lootbox"])
|
||||
async def cmd_lootbox(interaction: discord.Interaction):
|
||||
res = await economy.do_open_lootbox(interaction.user.id)
|
||||
if not res["ok"]:
|
||||
if res["reason"] == "db_error":
|
||||
await reply_db_error(interaction)
|
||||
elif res["reason"] == "banned":
|
||||
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||
else:
|
||||
await interaction.response.send_message(
|
||||
S.ERR["broke_need"].format(need=coin(res["need"])), ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
# Suspense: show the box opening, then reveal the reward.
|
||||
await interaction.response.send_message(
|
||||
embed=discord.Embed(
|
||||
title=S.LOOTBOX_UI["title"], description=S.LOOTBOX_UI["opening"], color=0xF4C430
|
||||
)
|
||||
)
|
||||
msg = await interaction.original_response()
|
||||
await asyncio.sleep(1.2)
|
||||
|
||||
if res["buff_kind"]:
|
||||
line = S.LOOTBOX_UI["buff_" + res["buff_kind"]].format(min=res["buff_min"])
|
||||
foot = S.LOOTBOX_UI["foot_buff"].format(balance=coin(res["balance"]))
|
||||
color = 0x5865F2
|
||||
else:
|
||||
line = S.LOOTBOX_UI[res["outcome"]].format(coins=coin(res["reward_coins"]))
|
||||
if res["net"] >= 0:
|
||||
foot = S.LOOTBOX_UI["foot_win"].format(net=coin(res["net"]), balance=coin(res["balance"]))
|
||||
color = 0x57F287
|
||||
else:
|
||||
foot = S.LOOTBOX_UI["foot_loss"].format(net=coin(abs(res["net"])), balance=coin(res["balance"]))
|
||||
color = 0xF4C430 if res["outcome"] != "coins_small" else 0xED4245
|
||||
embed = discord.Embed(title=S.LOOTBOX_UI["title"], description=line, color=color)
|
||||
embed.set_footer(text=foot)
|
||||
await msg.edit(embed=embed)
|
||||
|
||||
# -- /bank, /deposit, /withdraw -----------------------------------------
|
||||
@tree.command(name="bank", description=S.CMD["bank"])
|
||||
async def cmd_bank(interaction: discord.Interaction):
|
||||
data = await economy.get_user(interaction.user.id)
|
||||
embed = discord.Embed(
|
||||
title=S.BANK_UI["title"], description=S.BANK_UI["desc"], color=0xF4C430
|
||||
)
|
||||
embed.add_field(name=S.BANK_UI["f_liquid"], value=coin(data.get("balance", 0)), inline=True)
|
||||
embed.add_field(name=S.BANK_UI["f_bank"], value=coin(data.get("bank_balance", 0)), inline=True)
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
|
||||
@tree.command(name="deposit", description=S.CMD["deposit"])
|
||||
@app_commands.describe(summa=S.OPT["deposit_summa"])
|
||||
async def cmd_deposit(interaction: discord.Interaction, summa: str):
|
||||
data = await economy.get_user(interaction.user.id)
|
||||
amount, err = parse_amount(summa, data.get("balance", 0))
|
||||
if err or amount is None:
|
||||
await interaction.response.send_message(err or S.ERR["invalid_amount"], ephemeral=True)
|
||||
return
|
||||
if amount <= 0:
|
||||
await interaction.response.send_message(S.ERR["positive_amount"], ephemeral=True)
|
||||
return
|
||||
res = await economy.do_deposit(interaction.user.id, amount)
|
||||
if not res["ok"]:
|
||||
if res["reason"] == "db_error":
|
||||
await reply_db_error(interaction)
|
||||
elif res["reason"] == "banned":
|
||||
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||
else:
|
||||
await interaction.response.send_message(S.BANK_UI["nothing_liquid"], ephemeral=True)
|
||||
return
|
||||
await interaction.response.send_message(
|
||||
S.BANK_UI["deposited"].format(
|
||||
amount=coin(res["amount"]), balance=coin(res["balance"]), bank=coin(res["bank"])
|
||||
)
|
||||
)
|
||||
|
||||
@tree.command(name="withdraw", description=S.CMD["withdraw"])
|
||||
@app_commands.describe(summa=S.OPT["withdraw_summa"])
|
||||
async def cmd_withdraw(interaction: discord.Interaction, summa: str):
|
||||
data = await economy.get_user(interaction.user.id)
|
||||
amount, err = parse_amount(summa, data.get("bank_balance", 0))
|
||||
if err or amount is None:
|
||||
await interaction.response.send_message(err or S.ERR["invalid_amount"], ephemeral=True)
|
||||
return
|
||||
if amount <= 0:
|
||||
await interaction.response.send_message(S.ERR["positive_amount"], ephemeral=True)
|
||||
return
|
||||
res = await economy.do_withdraw(interaction.user.id, amount)
|
||||
if not res["ok"]:
|
||||
if res["reason"] == "db_error":
|
||||
await reply_db_error(interaction)
|
||||
elif res["reason"] == "banned":
|
||||
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||
else:
|
||||
await interaction.response.send_message(S.BANK_UI["nothing_bank"], ephemeral=True)
|
||||
return
|
||||
await interaction.response.send_message(
|
||||
S.BANK_UI["withdrawn"].format(
|
||||
amount=coin(res["amount"]), balance=coin(res["balance"]), bank=coin(res["bank"])
|
||||
)
|
||||
)
|
||||
|
||||
class RemindersSelect(discord.ui.Select):
|
||||
def __init__(self, user_id: int, current: list[str]):
|
||||
self.user_id = user_id
|
||||
|
||||
87
commands/economy_team_commands.py
Normal file
87
commands/economy_team_commands.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Tournament team-role sync for the economy/community guild.
|
||||
|
||||
The tournament participants live in the *economy* (community) guild, not the
|
||||
internal dev guild, so team-role assignment runs here rather than as part of the
|
||||
member-roster sync in :mod:`commands.dev_member_commands`. This is deliberately
|
||||
roster-INDEPENDENT: it matches Discord usernames straight against the separate
|
||||
registration spreadsheet (``TEAM_SHEET_ID``) and never touches the member sheet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
|
||||
from core import sheets
|
||||
from core.admin import bot_admin_check
|
||||
from core.member_sync import sync_all_team_roles
|
||||
import strings as S
|
||||
|
||||
|
||||
def register_economy_team_commands(
|
||||
tree: app_commands.CommandTree,
|
||||
bot: discord.Client,
|
||||
log: logging.Logger,
|
||||
) -> None:
|
||||
@tree.command(name="teamsync", description=S.CMD["teamsync"])
|
||||
@app_commands.guild_only()
|
||||
@bot_admin_check()
|
||||
async def cmd_teamsync(interaction: discord.Interaction):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
guild = interaction.guild
|
||||
if guild is None:
|
||||
await interaction.followup.send(S.ERR["guild_only"], ephemeral=True)
|
||||
return
|
||||
|
||||
try:
|
||||
rosters = await sheets.refresh_teams()
|
||||
except Exception as e:
|
||||
await interaction.followup.send(
|
||||
S.TEAMSYNC_UI["refresh_error"].format(error=e), ephemeral=True
|
||||
)
|
||||
return
|
||||
if not rosters:
|
||||
await interaction.followup.send(S.TEAMSYNC_UI["disabled"], ephemeral=True)
|
||||
return
|
||||
|
||||
summary = await sync_all_team_roles(guild, log)
|
||||
await interaction.followup.send(_format_summary(summary), ephemeral=True)
|
||||
log.info(
|
||||
"/teamsync - scanned=%d, assigned=%d, removed=%d, created=%d, errors=%d",
|
||||
summary.scanned,
|
||||
summary.assigned,
|
||||
summary.removed,
|
||||
len(summary.created),
|
||||
len(summary.errors),
|
||||
)
|
||||
|
||||
|
||||
def _format_summary(summary) -> str:
|
||||
lines = [
|
||||
S.TEAMSYNC_UI["done"],
|
||||
S.TEAMSYNC_UI["scanned"].format(count=summary.scanned),
|
||||
S.TEAMSYNC_UI["assigned"].format(count=summary.assigned),
|
||||
S.TEAMSYNC_UI["removed"].format(count=summary.removed),
|
||||
]
|
||||
if summary.created:
|
||||
# A team can be created only once, but the same role could surface for
|
||||
# several members in one run - de-dupe for the report.
|
||||
unique = list(dict.fromkeys(summary.created))
|
||||
lines.append(S.TEAMSYNC_UI["created"].format(roles=", ".join(unique)))
|
||||
if summary.errors:
|
||||
lines.append(S.TEAMSYNC_UI["errors"].format(count=len(summary.errors)))
|
||||
|
||||
text = "\n".join(lines)
|
||||
|
||||
if summary.changes:
|
||||
shown = summary.changes[:20]
|
||||
text += "\n\n" + S.TEAMSYNC_UI["changes_header"] + "\n" + "\n".join(shown)
|
||||
if len(summary.changes) > 20:
|
||||
text += "\n" + S.TEAMSYNC_UI["changes_more"].format(count=len(summary.changes) - 20)
|
||||
else:
|
||||
text += "\n\n" + S.TEAMSYNC_UI["no_changes"]
|
||||
|
||||
return text
|
||||
@@ -13,9 +13,9 @@ from pathlib import Path
|
||||
import discord
|
||||
from discord import app_commands
|
||||
|
||||
from core import economy
|
||||
from core.admin import bot_admin_check
|
||||
import strings as S
|
||||
from core.admin import bot_admin_check
|
||||
|
||||
|
||||
def register_ops_admin_commands(
|
||||
@@ -45,7 +45,6 @@ def register_ops_admin_commands(
|
||||
latency_ms = round(bot.latency * 1000, 1)
|
||||
cache_size = get_member_cache_size()
|
||||
user_count = await count_economy_users()
|
||||
eco_stats = await economy.get_economy_stats()
|
||||
|
||||
embed = discord.Embed(title=S.STATUS_UI["title"], color=0x57F287)
|
||||
embed.add_field(
|
||||
@@ -83,18 +82,6 @@ def register_ops_admin_commands(
|
||||
value=str(cache_size),
|
||||
inline=True,
|
||||
)
|
||||
total = eco_stats["total_coins"]
|
||||
house_pct = round(eco_stats["house_balance"] / total * 100) if total else 0
|
||||
embed.add_field(
|
||||
name=S.STATUS_UI["supply_field"],
|
||||
value=S.STATUS_UI["supply_val"].format(
|
||||
total=f"{total:,}".replace(",", " "),
|
||||
players=f"{eco_stats['player_coins']:,}".replace(",", " "),
|
||||
house=f"{eco_stats['house_balance']:,}".replace(",", " "),
|
||||
house_pct=house_pct,
|
||||
),
|
||||
inline=False,
|
||||
)
|
||||
|
||||
log_lines = [
|
||||
S.STATUS_UI["log_line"].format(name=p.name, size_kb=f"{p.stat().st_size / 1024:.1f}")
|
||||
|
||||
12
config.py
12
config.py
@@ -23,6 +23,9 @@ DISCORD_TOKEN = (
|
||||
) or _LEGACY_DISCORD_TOKEN
|
||||
|
||||
SHEET_ID = os.getenv("SHEET_ID")
|
||||
# Separate spreadsheet holding the tournament team registrations (Team Name +
|
||||
# lineup of Discord usernames). Optional: when unset, team-role sync is a no-op.
|
||||
TEAM_SHEET_ID = os.getenv("TEAM_SHEET_ID")
|
||||
GOOGLE_CREDS_PATH = os.getenv("GOOGLE_CREDS_PATH", "credentials.json")
|
||||
|
||||
_LEGACY_GUILD_ID = _env_int("GUILD_ID", 0)
|
||||
@@ -42,15 +45,6 @@ BIRTHDAY_CHANNEL_ID = (
|
||||
BIRTHDAY_WINDOW_DAYS = int(os.getenv("BIRTHDAY_WINDOW_DAYS", "7"))
|
||||
BASE_ROLE_IDS: list[int] = [1478304631930228779, 1478302278862766190]
|
||||
|
||||
# Channel where the daily lottery draw result is announced. Optional - if unset,
|
||||
# the draw still runs and pays the winner, it just isn't announced.
|
||||
_LEGACY_LOTTERY_CHANNEL_ID = _env_int("LOTTERY_CHANNEL_ID", 0)
|
||||
LOTTERY_CHANNEL_ID_DEV = _env_int("LOTTERY_CHANNEL_ID_DEV", _LEGACY_LOTTERY_CHANNEL_ID)
|
||||
LOTTERY_CHANNEL_ID_ECONOMY = _env_int("LOTTERY_CHANNEL_ID_ECONOMY", 0)
|
||||
LOTTERY_CHANNEL_ID = (
|
||||
LOTTERY_CHANNEL_ID_ECONOMY if BOT_PROFILE == "economy" else LOTTERY_CHANNEL_ID_DEV
|
||||
)
|
||||
|
||||
|
||||
def _parse_admin_roles(raw: str) -> dict[int, set[int]]:
|
||||
"""Parse DISCORD_ADMIN_ROLES env var as "guild_id:role_id[:role_id...],guild_id:role_id...".
|
||||
|
||||
@@ -16,12 +16,9 @@ from .store import (
|
||||
)
|
||||
from .house import *
|
||||
from .house import _credit_house, _house_record_id
|
||||
from .bank import *
|
||||
from .levels import *
|
||||
from .shop import *
|
||||
from .consumables import *
|
||||
from .vanity import *
|
||||
from .lootbox import *
|
||||
from .fishing import *
|
||||
from .quests import *
|
||||
from .quests import _ensure_quests, _pick_quests, _quest_view
|
||||
@@ -31,12 +28,9 @@ from .gambling import *
|
||||
from .prestige import *
|
||||
from .leaderboards import *
|
||||
from .heist import *
|
||||
from .achievements import *
|
||||
from .lottery import *
|
||||
from .admin import *
|
||||
|
||||
from . import ( # noqa: E402 (submodules addressable as economy.store etc.)
|
||||
achievements, admin, bank, consumables, fishing, gambling, heist, house,
|
||||
income, jail, leaderboards, levels, lootbox, lottery, prestige, quests,
|
||||
shop, store, vanity,
|
||||
admin, consumables, fishing, gambling, heist, house, income, jail,
|
||||
leaderboards, levels, prestige, quests, shop, store,
|
||||
)
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
"""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,7 +22,6 @@ async def do_season_reset(top_n: int = 10) -> list[tuple[str, int, int]]:
|
||||
reset_fields = {
|
||||
"exp": 0,
|
||||
"balance": 0,
|
||||
"bank_balance": 0,
|
||||
"items": [],
|
||||
"item_uses": {},
|
||||
"last_daily": None,
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
"""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"]}
|
||||
@@ -23,18 +23,6 @@ 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
|
||||
@@ -78,11 +66,6 @@ _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)
|
||||
@@ -117,19 +100,6 @@ def exp_buff_mult(user) -> float:
|
||||
return _BUFF_MULT["exp"] if "exp" in active_buffs(user) else 1.0
|
||||
|
||||
|
||||
def grant_buff(user, kind: str, duration_min: int) -> bool:
|
||||
"""Add/extend a timed buff of `kind` on `user` in place. Stacks: extends from
|
||||
the current expiry if still active, else starts now. Returns True if it
|
||||
extended an existing buff. Pure - safe under a user lock (caller commits)."""
|
||||
buffs = dict(user.get("active_buffs") or {})
|
||||
current = _parse_dt(buffs.get(kind))
|
||||
extended = current is not None and current > _now()
|
||||
start = current if extended else _now()
|
||||
buffs[kind] = (start + timedelta(minutes=duration_min)).isoformat()
|
||||
user["active_buffs"] = buffs
|
||||
return extended
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /consumables purchase
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -155,7 +125,13 @@ async def do_buy_consumable(user_id: int, cons_id: str) -> dict:
|
||||
for field in _COOLDOWN_FIELDS:
|
||||
user[field] = None
|
||||
else:
|
||||
extended = grant_buff(user, cons["kind"], cons["duration_min"])
|
||||
buffs = dict(user.get("active_buffs") or {})
|
||||
current = _parse_dt(buffs.get(cons["kind"]))
|
||||
# Stack: extend from the current expiry if still active, else from now.
|
||||
extended = current is not None and current > _now()
|
||||
start = current if extended else _now()
|
||||
buffs[cons["kind"]] = (start + timedelta(minutes=cons["duration_min"])).isoformat()
|
||||
user["active_buffs"] = buffs
|
||||
|
||||
await _commit(user_id, user)
|
||||
_txn("BUY_CONSUMABLE", user=user_id, item=cons_id, cost=f"-{cons['cost']}", bal=user["balance"])
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from datetime import timedelta
|
||||
|
||||
from ..pb_client import DatabaseError
|
||||
from .store import (
|
||||
_cooldown_remaining, _commit, _is_jailed, _locked_by, _now,
|
||||
_prestige_mult, _txn, effective_cooldown, get_user,
|
||||
COOLDOWNS, _cooldown_remaining, _commit, _is_jailed, _locked_by, _now,
|
||||
_prestige_mult, _txn, get_user,
|
||||
)
|
||||
|
||||
|
||||
@@ -80,7 +81,7 @@ async def do_fish_start(user_id: int) -> dict:
|
||||
if jail := _is_jailed(user):
|
||||
return {"ok": False, "reason": "jailed", "remaining": jail}
|
||||
|
||||
fish_cd = effective_cooldown("fish", user["items"])
|
||||
fish_cd = timedelta(seconds=90) if "ussipurk" in user["items"] else COOLDOWNS["fish"]
|
||||
if cd := _cooldown_remaining(user, "fish", override_cd=fish_cd):
|
||||
return {"ok": False, "reason": "cooldown", "remaining": cd}
|
||||
|
||||
|
||||
@@ -6,10 +6,7 @@ import random
|
||||
|
||||
from ..pb_client import DatabaseError
|
||||
from ..emoji import EMOJI as E
|
||||
from .store import (
|
||||
_commit, _is_jailed, _locked_by, _log, _txn, add_pending_wager,
|
||||
clear_pending_wager, get_user,
|
||||
)
|
||||
from .store import _commit, _is_jailed, _locked_by, _txn, get_user
|
||||
from .house import _credit_house
|
||||
|
||||
|
||||
@@ -108,7 +105,6 @@ async def do_rps_pvp_deposit(user_id: int, bet: int) -> dict:
|
||||
return {"ok": False, "reason": "insufficient"}
|
||||
user["balance"] -= bet
|
||||
user["total_wagered"] = user.get("total_wagered", 0) + bet
|
||||
add_pending_wager(user, "rps", bet) # escrow survives a restart
|
||||
try:
|
||||
await _commit(user_id, user)
|
||||
except DatabaseError:
|
||||
@@ -129,7 +125,6 @@ async def do_rps_pvp_payout(winner_id: int, bet: int) -> dict:
|
||||
user["lifetime_earned"] = user.get("lifetime_earned", 0) + bet
|
||||
user["biggest_win"] = max(user.get("biggest_win", 0), bet)
|
||||
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
|
||||
clear_pending_wager(user) # winner's escrow settled
|
||||
try:
|
||||
await _commit(winner_id, user)
|
||||
except DatabaseError:
|
||||
@@ -138,23 +133,6 @@ async def do_rps_pvp_payout(winner_id: int, bet: int) -> dict:
|
||||
return {"ok": True, "balance": user["balance"]}
|
||||
|
||||
|
||||
@_locked_by(0)
|
||||
async def do_rps_pvp_forfeit(loser_id: int) -> dict:
|
||||
"""Release the loser's escrow marker without refunding - their stake was paid
|
||||
to the winner as part of the 2*bet payout. Without this the loser's
|
||||
pending_wager would linger and be wrongly refunded on the next restart."""
|
||||
try:
|
||||
user = await get_user(loser_id)
|
||||
except DatabaseError:
|
||||
return {"ok": False, "reason": "db_error"}
|
||||
clear_pending_wager(user)
|
||||
try:
|
||||
await _commit(loser_id, user)
|
||||
except DatabaseError:
|
||||
return {"ok": False, "reason": "db_error"}
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@_locked_by(0)
|
||||
async def do_rps_pvp_refund(user_id: int, bet: int) -> dict:
|
||||
"""Refund a previously escrowed bet (tie / timeout / cancel)."""
|
||||
@@ -164,7 +142,6 @@ async def do_rps_pvp_refund(user_id: int, bet: int) -> dict:
|
||||
return {"ok": False, "reason": "db_error"}
|
||||
user["balance"] = user.get("balance", 0) + bet
|
||||
user["total_wagered"] = max(0, user.get("total_wagered", 0) - bet)
|
||||
clear_pending_wager(user) # escrow returned
|
||||
try:
|
||||
await _commit(user_id, user)
|
||||
except DatabaseError:
|
||||
@@ -271,29 +248,14 @@ async def do_blackjack_bet(user_id: int, bet: int) -> dict:
|
||||
if user["balance"] < bet:
|
||||
return {"ok": False, "reason": "insufficient", "balance": user["balance"]}
|
||||
user["balance"] -= bet
|
||||
# Escrow the stake in the same commit (accumulates across double/split), so a
|
||||
# restart mid-hand refunds it via reconcile_pending_wagers instead of eating it.
|
||||
add_pending_wager(user, "blackjack", bet)
|
||||
try:
|
||||
await _commit(user_id, user)
|
||||
except DatabaseError:
|
||||
# Deduction never persisted, so the player was not charged - report it
|
||||
# instead of raising through the interaction handler.
|
||||
return {"ok": False, "reason": "db_error"}
|
||||
await _commit(user_id, user)
|
||||
return {"ok": True, "balance": user["balance"]}
|
||||
|
||||
|
||||
@_locked_by(0)
|
||||
async def do_blackjack_payout(user_id: int, payout: int, total_invested: int = 0) -> dict:
|
||||
"""Credit the net payout. House receives the difference when payout < total_invested.
|
||||
|
||||
The stake was already deducted in do_blackjack_bet, so a DB failure here must
|
||||
not raise through the interaction handler and swallow the player's winnings:
|
||||
report db_error like every other mutation so the caller can surface it."""
|
||||
try:
|
||||
user = await get_user(user_id)
|
||||
except DatabaseError:
|
||||
return {"ok": False, "reason": "db_error"}
|
||||
"""Credit the net payout. House receives the difference when payout < total_invested."""
|
||||
user = await get_user(user_id)
|
||||
user["balance"] += payout
|
||||
user["balance"] = max(0, user["balance"])
|
||||
user["total_wagered"] = user.get("total_wagered", 0) + total_invested
|
||||
@@ -305,18 +267,7 @@ async def do_blackjack_payout(user_id: int, payout: int, total_invested: int = 0
|
||||
elif net < 0:
|
||||
user["lifetime_lost"] = user.get("lifetime_lost", 0) + abs(net)
|
||||
user["biggest_loss"] = max(user.get("biggest_loss", 0), abs(net))
|
||||
clear_pending_wager(user) # hand settled - release the escrow marker
|
||||
try:
|
||||
await _commit(user_id, user)
|
||||
except DatabaseError:
|
||||
# The stake is already gone (deducted in do_blackjack_bet) and this credit
|
||||
# did not persist. Nothing to roll back locally - log the owed amount so an
|
||||
# admin can reconcile with /admincoins.
|
||||
_log.critical(
|
||||
"blackjack payout commit failed for %s: owed payout=%s (invested=%s)",
|
||||
user_id, payout, total_invested,
|
||||
)
|
||||
return {"ok": False, "reason": "db_error"}
|
||||
await _commit(user_id, user)
|
||||
house_gain = total_invested - payout
|
||||
if house_gain > 0:
|
||||
await _credit_house(house_gain)
|
||||
|
||||
@@ -8,7 +8,7 @@ from .. import pb_client
|
||||
from ..pb_client import DatabaseError
|
||||
from . import house
|
||||
from .store import HEIST_JAIL, _commit, _is_jailed, _now, _txn, _user_lock, get_user
|
||||
from .house import _credit_house, _refund_house_safe, _debit_house_safe
|
||||
from .house import _credit_house, _refund_house_safe, _refund_user_safe
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -98,6 +98,6 @@ async def do_heist_resolve(user_ids: list[int], success: bool) -> dict:
|
||||
if success and payout_each > 0:
|
||||
await _refund_house_safe(payout_each, "heist_win_compensate", uid)
|
||||
elif not success and fine_credited:
|
||||
await _debit_house_safe(fine if 'fine' in locals() else 0, "heist_fail_compensate", uid)
|
||||
await _refund_user_safe(house.HOUSE_ID, fine if 'fine' in locals() else 0, "heist_fail_compensate", uid)
|
||||
|
||||
return {"ok": True, "payout_each": payout_each, "success": success, "failed_users": failed_users}
|
||||
|
||||
@@ -75,10 +75,9 @@ async def _refund_house_safe(amount: int, context: str, related_uid: int) -> Non
|
||||
)
|
||||
|
||||
|
||||
async def _debit_house_safe(amount: int, context: str, uid: int) -> None:
|
||||
"""Best-effort atomic debit of `amount` from the house. Compensates a fine
|
||||
that was credited to the house but whose matching user debit failed to persist
|
||||
(the coins must be pulled back out of the house). Logs critical if it fails."""
|
||||
async def _refund_user_safe(_unused_house_id, amount: int, context: str, uid: int) -> None:
|
||||
"""Best-effort atomic debit of `amount` from the house (compensates a failed
|
||||
user fine). Logs critical if it fails."""
|
||||
if HOUSE_ID is None or amount <= 0:
|
||||
return
|
||||
try:
|
||||
|
||||
@@ -3,16 +3,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from datetime import date
|
||||
from datetime import date, timedelta
|
||||
|
||||
import strings
|
||||
|
||||
from ..pb_client import DatabaseError
|
||||
from . import house
|
||||
from .store import (
|
||||
JAIL_DURATION, PRESTIGE_SHOP, _commit, _cooldown_remaining,
|
||||
_is_jailed, _locked_by, _log, _now, _prestige_mult, _txn, effective_cooldown,
|
||||
get_user,
|
||||
COOLDOWNS, JAIL_DURATION, PRESTIGE_SHOP, _commit, _cooldown_remaining,
|
||||
_is_jailed, _locked_by, _log, _now, _prestige_mult, _txn, get_user,
|
||||
)
|
||||
from .house import _credit_house
|
||||
from .consumables import earn_mult
|
||||
@@ -30,7 +29,7 @@ async def do_daily(user_id: int) -> dict:
|
||||
if user.get("eco_banned"):
|
||||
return {"ok": False, "reason": "banned"}
|
||||
|
||||
daily_cd = effective_cooldown("daily", user["items"])
|
||||
daily_cd = timedelta(hours=18) if "korvaklapid" in user["items"] else COOLDOWNS["daily"]
|
||||
if cd := _cooldown_remaining(user, "daily", override_cd=daily_cd):
|
||||
return {"ok": False, "reason": "cooldown", "remaining": cd}
|
||||
|
||||
@@ -110,7 +109,7 @@ async def do_work(user_id: int) -> dict:
|
||||
if user.get("eco_banned"):
|
||||
return {"ok": False, "reason": "banned"}
|
||||
|
||||
work_cd = effective_cooldown("work", user["items"])
|
||||
work_cd = timedelta(minutes=40) if "monitor" in user["items"] else COOLDOWNS["work"]
|
||||
if cd := _cooldown_remaining(user, "work", override_cd=work_cd):
|
||||
return {"ok": False, "reason": "cooldown", "remaining": cd}
|
||||
if jail := _is_jailed(user):
|
||||
@@ -164,7 +163,7 @@ async def do_beg(user_id: int) -> dict:
|
||||
if user.get("eco_banned"):
|
||||
return {"ok": False, "reason": "banned"}
|
||||
|
||||
beg_cd = effective_cooldown("beg", user["items"])
|
||||
beg_cd = timedelta(minutes=3) if "hiirematt" in user["items"] else COOLDOWNS["beg"]
|
||||
if cd := _cooldown_remaining(user, "beg", override_cd=beg_cd):
|
||||
return {"ok": False, "reason": "cooldown", "remaining": cd}
|
||||
|
||||
|
||||
@@ -3,21 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import pb_client
|
||||
from . import house
|
||||
from .levels import get_level
|
||||
|
||||
|
||||
def _net_worth(r: dict) -> int:
|
||||
"""Coins that count toward wealth: liquid balance + banked vault."""
|
||||
return (r.get("balance", 0) or 0) + (r.get("bank_balance", 0) or 0)
|
||||
|
||||
|
||||
async def get_leaderboard(top_n: int | None = 10) -> list[tuple[str, int]]:
|
||||
"""Return top_n (user_id_str, net_worth) pairs sorted descending.
|
||||
Net worth = balance + bank_balance, so banking coins does not hide them."""
|
||||
"""Return top_n (user_id_str, balance) pairs sorted descending."""
|
||||
records = await pb_client.list_all_records()
|
||||
result = sorted(
|
||||
((r["user_id"], _net_worth(r)) for r in records if r.get("user_id")),
|
||||
((r["user_id"], r.get("balance", 0)) for r in records if r.get("user_id")),
|
||||
key=lambda x: x[1],
|
||||
reverse=True,
|
||||
)
|
||||
@@ -87,60 +80,3 @@ async def get_leaderboard_fish(top_n: int | None = 10) -> list[tuple[str, int]]:
|
||||
reverse=True,
|
||||
)
|
||||
return result if top_n is None else result[:top_n]
|
||||
|
||||
|
||||
async def get_all_leaderboards() -> dict[str, list[tuple]]:
|
||||
"""Build every leaderboard view from a SINGLE collection scan.
|
||||
|
||||
/leaderboard shows six tabs; calling each get_leaderboard_* separately would
|
||||
read the whole collection six times. This reads once and sorts in memory,
|
||||
returning the same tuple shapes the individual functions produce (unbounded -
|
||||
the command paginates)."""
|
||||
records = await pb_client.list_all_records()
|
||||
users = [r for r in records if r.get("user_id")]
|
||||
|
||||
def desc(keyfn) -> list[dict]:
|
||||
return sorted(users, key=keyfn, reverse=True)
|
||||
|
||||
return {
|
||||
"coins": [(r["user_id"], _net_worth(r))
|
||||
for r in desc(_net_worth)],
|
||||
"exp": [(r["user_id"], r.get("exp", 0), get_level(r.get("exp", 0)))
|
||||
for r in desc(lambda r: r.get("exp", 0))],
|
||||
"season": [(r["user_id"], r.get("season_total_exp", 0), r.get("prestige_level", 0))
|
||||
for r in desc(lambda r: r.get("season_total_exp", 0))],
|
||||
"prestige": [(r["user_id"], r.get("prestige_level", 0), r.get("prestige_points", 0))
|
||||
for r in desc(lambda r: (r.get("prestige_level", 0), r.get("prestige_points", 0)))],
|
||||
"wagered": [(r["user_id"], r.get("total_wagered", 0))
|
||||
for r in desc(lambda r: r.get("total_wagered", 0))],
|
||||
"fish": [(r["user_id"], r.get("total_fish_caught", 0))
|
||||
for r in desc(lambda r: r.get("total_fish_caught", 0))],
|
||||
}
|
||||
|
||||
|
||||
async def get_economy_stats() -> dict[str, int]:
|
||||
"""Money-supply snapshot from a single scan: total coins in circulation, how
|
||||
much players hold vs. the house. Lets /status show whether the sinks (house,
|
||||
vanity burn, bail, fines) are keeping pace with minted income."""
|
||||
records = await pb_client.list_all_records()
|
||||
house_id = str(house.HOUSE_ID) if house.HOUSE_ID is not None else None
|
||||
total = 0
|
||||
house_balance = 0
|
||||
player_count = 0
|
||||
for r in records:
|
||||
uid = r.get("user_id")
|
||||
if not uid:
|
||||
continue
|
||||
# Banked coins are still part of the money supply.
|
||||
worth = (r.get("balance", 0) or 0) + (r.get("bank_balance", 0) or 0)
|
||||
total += worth
|
||||
if uid == house_id:
|
||||
house_balance = worth
|
||||
else:
|
||||
player_count += 1
|
||||
return {
|
||||
"total_coins": total,
|
||||
"house_balance": house_balance,
|
||||
"player_coins": total - house_balance,
|
||||
"player_count": player_count,
|
||||
}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
"""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"],
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
"""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,
|
||||
}
|
||||
@@ -32,7 +32,7 @@ QUESTS_DAILY: dict[str, QuestDef] = {
|
||||
"fish2": {"stat": "total_fish_caught", "goal": 2, "coins": 150, "exp": 20},
|
||||
"crime1": {"stat": "crimes_succeeded", "goal": 1, "coins": 200, "exp": 25},
|
||||
"earn1000": {"stat": "lifetime_earned", "goal": 1000, "coins": 150, "exp": 20},
|
||||
"give200": {"stat": "total_given", "goal": 200, "coins": 100, "exp": 15},
|
||||
"give200": {"stat": "total_given", "goal": 200, "coins": 250, "exp": 20},
|
||||
}
|
||||
|
||||
QUESTS_WEEKLY: dict[str, QuestDef] = {
|
||||
|
||||
@@ -87,26 +87,6 @@ COOLDOWNS: dict[str, timedelta] = {
|
||||
"fish": timedelta(minutes=2),
|
||||
}
|
||||
|
||||
# Items that shorten a command's cooldown: command -> (item_id, reduced cooldown).
|
||||
# Single source of truth so the cooldown check (do_*), the reminder scheduler
|
||||
# (_maybe_remind) and the restart restore (_restore_reminders) never drift apart.
|
||||
ITEM_COOLDOWNS: dict[str, tuple[str, timedelta]] = {
|
||||
"work": ("monitor", timedelta(minutes=40)),
|
||||
"beg": ("hiirematt", timedelta(minutes=3)),
|
||||
"daily": ("korvaklapid", timedelta(hours=18)),
|
||||
"fish": ("ussipurk", timedelta(seconds=90)),
|
||||
}
|
||||
|
||||
|
||||
def effective_cooldown(cmd: str, items) -> timedelta | None:
|
||||
"""The cooldown for `cmd` given the user's owned `items`, applying any
|
||||
item-based reduction. Returns None for commands with no cooldown."""
|
||||
override = ITEM_COOLDOWNS.get(cmd)
|
||||
if override is not None and override[0] in items:
|
||||
return override[1]
|
||||
return COOLDOWNS.get(cmd)
|
||||
|
||||
|
||||
JAIL_DURATION = timedelta(minutes=30)
|
||||
HEIST_JAIL = timedelta(hours=1, minutes=30)
|
||||
|
||||
@@ -114,8 +94,7 @@ HEIST_JAIL = timedelta(hours=1, minutes=30)
|
||||
# User schema
|
||||
# ---------------------------------------------------------------------------
|
||||
class UserData(TypedDict, total=False):
|
||||
balance: int # liquid coins - spendable, gamblable, robbable
|
||||
bank_balance: int # vaulted coins - safe from /rob and /heist, not spendable until withdrawn
|
||||
balance: int
|
||||
exp: int # lifetime EXP (resets each season)
|
||||
last_daily: str | None
|
||||
last_work: str | None
|
||||
@@ -128,8 +107,6 @@ class UserData(TypedDict, total=False):
|
||||
items: list[str]
|
||||
item_uses: dict # {item_id: remaining_uses} for consumables
|
||||
active_buffs: dict # {buff_kind: expiry_iso} for timed consumable boosts
|
||||
vanity_owned: list[str] # cosmetic badge ids the user has purchased
|
||||
vanity_active: str | None # currently-equipped vanity badge id (shown on /profile)
|
||||
jailed_until: str | None # ISO datetime or None
|
||||
jailbreak_used: bool
|
||||
reminders: list[str] # command names user wants DM reminders for
|
||||
@@ -153,10 +130,6 @@ class UserData(TypedDict, total=False):
|
||||
total_given: int
|
||||
total_received: int
|
||||
best_daily_streak: int
|
||||
lootboxes_opened: int
|
||||
achievements_earned: list # ids of achievements already claimed
|
||||
lottery_tickets: int # tickets held for the current lottery period
|
||||
lottery_period: str | None # draw-date the held tickets count for (ISO date)
|
||||
heist_global_cd_until: float
|
||||
# Prestige system
|
||||
prestige_level: int
|
||||
@@ -171,17 +144,11 @@ class UserData(TypedDict, total=False):
|
||||
# Quest system
|
||||
quest_daily: dict # {"date": "YYYY-MM-DD", "quests": {qid: {snap, claimed}}}
|
||||
quest_weekly: dict # {"week": "YYYY-Www", "quests": {qid: {snap, claimed}}}
|
||||
# Coins a running interactive game (blackjack/RPS PvP) has deducted but not
|
||||
# yet settled. {"kind": ..., "amount": int, "ts": iso} while escrowed, {}
|
||||
# otherwise. Reconciled (refunded) on startup so a restart mid-game never
|
||||
# eats the stake. See reconcile_pending_wagers.
|
||||
pending_wager: dict
|
||||
|
||||
|
||||
def _default_user() -> UserData:
|
||||
return {
|
||||
"balance": 0,
|
||||
"bank_balance": 0,
|
||||
"exp": 0,
|
||||
"last_daily": None,
|
||||
"last_work": None,
|
||||
@@ -194,8 +161,6 @@ def _default_user() -> UserData:
|
||||
"items": [],
|
||||
"item_uses": {},
|
||||
"active_buffs": {},
|
||||
"vanity_owned": [],
|
||||
"vanity_active": None,
|
||||
"jailed_until": None,
|
||||
"jailbreak_used": False,
|
||||
"reminders": ["daily", "work", "beg", "crime", "rob"],
|
||||
@@ -219,10 +184,6 @@ def _default_user() -> UserData:
|
||||
"total_given": 0,
|
||||
"total_received": 0,
|
||||
"best_daily_streak": 0,
|
||||
"lootboxes_opened": 0,
|
||||
"achievements_earned": [],
|
||||
"lottery_tickets": 0,
|
||||
"lottery_period": None,
|
||||
"heist_global_cd_until": 0.0,
|
||||
# ── Prestige ─────────────────────────────────────────────────────────
|
||||
"prestige_level": 0,
|
||||
@@ -237,8 +198,6 @@ def _default_user() -> UserData:
|
||||
# ── Quests ───────────────────────────────────────────────────────────
|
||||
"quest_daily": {},
|
||||
"quest_weekly": {},
|
||||
# ── Interactive-game escrow (blackjack / RPS PvP) ────────────────────
|
||||
"pending_wager": {},
|
||||
}
|
||||
|
||||
|
||||
@@ -416,59 +375,6 @@ async def _commit(user_id: int, user: UserData) -> dict | None:
|
||||
raise DatabaseError(f"Failed to persist user {user_id}: {exc}") from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pending-wager escrow (interactive games survive a restart)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interactive games (blackjack, RPS PvP) deduct a stake up front and hold it in
|
||||
# an in-memory View until the hand resolves. A restart would drop the View and
|
||||
# lose the coins. To prevent that, the deduction commit also records the escrowed
|
||||
# amount on the user (add_pending_wager), the settlement commit clears it
|
||||
# (clear_pending_wager), and reconcile_pending_wagers refunds anything still
|
||||
# outstanding at startup. All three mutate the user dict in place so the escrow
|
||||
# state rides along in the SAME commit as the balance change (atomic).
|
||||
def add_pending_wager(user: UserData, kind: str, amount: int) -> None:
|
||||
"""Record/accumulate `amount` coins as escrowed by a `kind` game."""
|
||||
pw = dict(user.get("pending_wager") or {})
|
||||
pw = {
|
||||
"kind": kind,
|
||||
"amount": int(pw.get("amount", 0) or 0) + amount,
|
||||
"ts": _now().isoformat(),
|
||||
}
|
||||
user["pending_wager"] = pw
|
||||
|
||||
|
||||
def clear_pending_wager(user: UserData) -> None:
|
||||
"""Mark the user's escrow settled (call in the settlement commit)."""
|
||||
user["pending_wager"] = {}
|
||||
|
||||
|
||||
async def reconcile_pending_wagers() -> list[tuple[int, int, str]]:
|
||||
"""Refund every stake left escrowed by a game that a restart interrupted.
|
||||
|
||||
Runs once at startup (before commands are served). Returns the list of
|
||||
(user_id, refunded_amount, kind) so the caller can log a summary."""
|
||||
refunded: list[tuple[int, int, str]] = []
|
||||
for uid_str, snapshot in (await get_all_users_raw()).items():
|
||||
pw = snapshot.get("pending_wager") or {}
|
||||
if int(pw.get("amount", 0) or 0) <= 0:
|
||||
continue
|
||||
uid = int(uid_str)
|
||||
async with _user_lock(uid):
|
||||
user = await get_user(uid)
|
||||
pw = user.get("pending_wager") or {}
|
||||
amount = int(pw.get("amount", 0) or 0)
|
||||
if amount <= 0:
|
||||
continue
|
||||
kind = str(pw.get("kind", "?"))
|
||||
user["balance"] += amount
|
||||
clear_pending_wager(user)
|
||||
await _commit(uid, user)
|
||||
_txn("WAGER_RECONCILE", user=uid, refund=f"+{amount}", kind=kind, bal=user["balance"])
|
||||
_log.info("Refunded interrupted %s wager: %s coins to user %s", kind, amount, uid)
|
||||
refunded.append((uid, amount, kind))
|
||||
return refunded
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /reminders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
"""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"]}
|
||||
@@ -48,7 +48,6 @@ class SyncResult:
|
||||
"""Tracks what happened during a sync operation."""
|
||||
nickname_changed: bool = False
|
||||
roles_added: list[str] = field(default_factory=list)
|
||||
roles_removed: list[str] = field(default_factory=list)
|
||||
birthday_soon: bool = False
|
||||
birthday_today: bool = False
|
||||
not_found: bool = False
|
||||
@@ -57,7 +56,31 @@ class SyncResult:
|
||||
|
||||
@property
|
||||
def changed(self) -> bool:
|
||||
return self.nickname_changed or self.roles_added or self.roles_removed
|
||||
return self.nickname_changed or self.roles_added
|
||||
|
||||
|
||||
@dataclass
|
||||
class TeamSyncResult:
|
||||
"""What happened when syncing one member's tournament team role."""
|
||||
added: str | None = None # team role name granted, if any
|
||||
removed: list[str] = field(default_factory=list) # stale team roles taken away
|
||||
created: str | None = None # team role name auto-created in the guild, if any
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def changed(self) -> bool:
|
||||
return bool(self.added or self.removed)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TeamSyncSummary:
|
||||
"""Aggregate outcome of a whole-guild team-role sync."""
|
||||
scanned: int = 0
|
||||
assigned: int = 0
|
||||
removed: int = 0
|
||||
created: list[str] = field(default_factory=list)
|
||||
changes: list[str] = field(default_factory=list) # human-readable per-member lines
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _format_nickname(full_name: str) -> str:
|
||||
@@ -224,6 +247,101 @@ async def sync_member(
|
||||
return result
|
||||
|
||||
|
||||
async def sync_team_role(
|
||||
member: discord.Member,
|
||||
guild: discord.Guild,
|
||||
) -> TeamSyncResult:
|
||||
"""Give one member their tournament team role from the registration sheet.
|
||||
|
||||
Roster-INDEPENDENT: unlike :func:`sync_member` this does not touch the
|
||||
internal member sheet at all. It matches the member's Discord username
|
||||
against the team sheet caches (populated by ``sheets.refresh_teams``) and:
|
||||
|
||||
* grants the role for the team they're registered on (auto-creating that
|
||||
role in the guild when it does not exist yet);
|
||||
* removes any *other* team role they still carry (left / switched teams).
|
||||
|
||||
Only role NAMES present in the team sheet are ever added or removed, so no
|
||||
unrelated role is ever at risk. When ``TEAM_SHEET_ID`` is unset the caches
|
||||
are empty and this is a no-op returning an unchanged result.
|
||||
"""
|
||||
result = TeamSyncResult()
|
||||
|
||||
team_name = sheets.get_team_for_username(member.name)
|
||||
all_teams = sheets.all_team_names()
|
||||
if not all_teams:
|
||||
return result # feature switched off (no team sheet loaded)
|
||||
|
||||
desired: discord.Role | None = None
|
||||
if team_name:
|
||||
desired = discord.utils.get(guild.roles, name=team_name)
|
||||
if desired is None:
|
||||
try:
|
||||
desired = await guild.create_role(name=team_name, reason="Team sync: uus tiim")
|
||||
result.created = team_name
|
||||
log.info("Created team role %r for %s", team_name, member)
|
||||
except discord.Forbidden:
|
||||
result.errors.append(f"Tiimirolli '{team_name}' loomiseks puudub õigus")
|
||||
except discord.HTTPException as e:
|
||||
result.errors.append(f"Tiimirolli '{team_name}' loomine ebaõnnestus: {e}")
|
||||
|
||||
# Team roles held but no longer registered for (switched teams / dropped out).
|
||||
to_remove = [r for r in member.roles if r.name in all_teams and r.name != team_name]
|
||||
|
||||
if desired is not None and desired not in member.roles:
|
||||
try:
|
||||
await member.add_roles(desired, reason="Team sync")
|
||||
result.added = desired.name
|
||||
except discord.Forbidden:
|
||||
log.debug("No permission to add team role for %s, skipping", member)
|
||||
except discord.HTTPException as e:
|
||||
result.errors.append(f"Tiimirolli viga kasutajale {member}: {e}")
|
||||
|
||||
if to_remove:
|
||||
try:
|
||||
await member.remove_roles(*to_remove, reason="Team sync: tiim vahetus")
|
||||
result.removed = [r.name for r in to_remove]
|
||||
except discord.Forbidden:
|
||||
log.debug("No permission to remove team roles for %s, skipping", member)
|
||||
except discord.HTTPException as e:
|
||||
result.errors.append(f"Tiimirolli eemaldamise viga kasutajale {member}: {e}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def sync_all_team_roles(
|
||||
guild: discord.Guild,
|
||||
log: logging.Logger = log,
|
||||
) -> TeamSyncSummary:
|
||||
"""Run :func:`sync_team_role` for every human member of ``guild``.
|
||||
|
||||
Assumes the team caches are already fresh (caller runs ``refresh_teams``
|
||||
first). Returns an aggregate summary for reporting.
|
||||
"""
|
||||
summary = TeamSyncSummary()
|
||||
for member in guild.members:
|
||||
if member.bot:
|
||||
continue
|
||||
summary.scanned += 1
|
||||
res = await sync_team_role(member, guild)
|
||||
if res.created:
|
||||
summary.created.append(res.created)
|
||||
if res.errors:
|
||||
summary.errors.extend(res.errors)
|
||||
if res.added:
|
||||
summary.assigned += 1
|
||||
if res.removed:
|
||||
summary.removed += len(res.removed)
|
||||
if res.changed:
|
||||
bits: list[str] = []
|
||||
if res.added:
|
||||
bits.append(f"+{res.added}")
|
||||
if res.removed:
|
||||
bits.append("-" + ", -".join(res.removed))
|
||||
summary.changes.append(f"{member.display_name}: {', '.join(bits)}")
|
||||
return summary
|
||||
|
||||
|
||||
async def announce_birthday(
|
||||
member: discord.Member,
|
||||
bot: discord.Client,
|
||||
|
||||
161
core/sheets.py
161
core/sheets.py
@@ -8,6 +8,7 @@ Pure-cache helpers (get_cache, find_*) remain sync.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
|
||||
import gspread
|
||||
from google.oauth2.service_account import Credentials
|
||||
@@ -250,3 +251,163 @@ def _add_new_member_row_sync(username: str, discord_id: int) -> None:
|
||||
async def add_new_member_row(username: str, discord_id: int) -> None:
|
||||
"""Append a new row pre-filled with Discord username and User ID (non-blocking)."""
|
||||
await asyncio.to_thread(_add_new_member_row_sync, username, discord_id)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Team registration sheet (a SEPARATE spreadsheet, config.TEAM_SHEET_ID)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unlike the member roster this sheet is NOT a single clean table: it stacks
|
||||
# several game sections (CS2, LoL, ...) - each with merged title/description
|
||||
# rows, its own header row, and a block of team rows - across one or more tabs.
|
||||
# So we read raw cell values (get_all_values) and scan for header rows rather
|
||||
# than relying on get_all_records, which requires one rectangular table.
|
||||
#
|
||||
# Each team's players live in one "Lineup" cell, comma-joined, every nickname
|
||||
# suffixed with a citizenship marker like "(EST)". Those nicknames ARE the
|
||||
# players' Discord usernames; the citizenship is used only as a delimiter
|
||||
# (a nickname may itself contain commas) and then discarded.
|
||||
# ===========================================================================
|
||||
|
||||
# Matches a citizenship marker such as "(EST)" / "(LAT)". Used to split a
|
||||
# lineup cell into individual players, then thrown away.
|
||||
_CITIZENSHIP_RE = re.compile(r"\(\s*[A-Za-z]{2,4}\s*\)")
|
||||
|
||||
_TEAM_NAME_HEADER = "team name"
|
||||
_LINEUP_HEADER_PREFIX = "lineup"
|
||||
|
||||
# Team-sheet caches (mirrors the member-roster cache above)
|
||||
_team_roster: dict[str, list[str]] = {} # team name -> [Discord username, ...]
|
||||
_team_by_username: dict[str, str] = {} # normalized username -> team name
|
||||
_team_names: set[str] = set() # universe of all team names
|
||||
|
||||
|
||||
def parse_lineup(cell: str) -> list[str]:
|
||||
"""Split one 'Lineup' cell into player nicknames (Discord usernames).
|
||||
|
||||
Players are delimited by their trailing citizenship marker, e.g.
|
||||
'TFT (EST), nqmm (EST), sn1rk (EST)' -> ['TFT', 'nqmm', 'sn1rk']
|
||||
Splitting on the marker (not on commas) keeps nicknames that themselves
|
||||
contain commas or semicolons intact. Falls back to comma-splitting when a
|
||||
cell carries no citizenship markers at all.
|
||||
"""
|
||||
cell = str(cell).strip()
|
||||
if not cell:
|
||||
return []
|
||||
names: list[str] = []
|
||||
last = 0
|
||||
matched = False
|
||||
for m in _CITIZENSHIP_RE.finditer(cell):
|
||||
matched = True
|
||||
chunk = cell[last:m.start()].strip().strip(",;").strip()
|
||||
if chunk:
|
||||
names.append(chunk)
|
||||
last = m.end()
|
||||
if not matched:
|
||||
return [p.strip() for p in cell.split(",") if p.strip()]
|
||||
tail = cell[last:].strip().strip(",;").strip() # stray name after last marker
|
||||
if tail:
|
||||
names.append(tail)
|
||||
return names
|
||||
|
||||
|
||||
def _find_col(row: list, matches) -> int | None:
|
||||
for idx, cell in enumerate(row):
|
||||
if matches(str(cell)):
|
||||
return idx
|
||||
return None
|
||||
|
||||
|
||||
def _cell(row: list, idx: int) -> str:
|
||||
return str(row[idx]) if 0 <= idx < len(row) else ""
|
||||
|
||||
|
||||
def parse_team_rosters(rows: list[list]) -> dict[str, list[str]]:
|
||||
"""Extract {team_name: [nickname, ...]} from a tab's raw rows.
|
||||
|
||||
Scans for every header row that has both a 'Team Name' and a 'Lineup...'
|
||||
column, then reads the rows beneath it (using that section's own column
|
||||
positions) until the team-name column goes blank or a new header appears.
|
||||
Handles multiple stacked sections with differing layouts in one tab.
|
||||
"""
|
||||
rosters: dict[str, list[str]] = {}
|
||||
i, n = 0, len(rows)
|
||||
while i < n:
|
||||
name_col = _find_col(rows[i], lambda c: c.strip().lower() == _TEAM_NAME_HEADER)
|
||||
lineup_col = _find_col(rows[i], lambda c: c.strip().lower().startswith(_LINEUP_HEADER_PREFIX))
|
||||
if name_col is None or lineup_col is None:
|
||||
i += 1
|
||||
continue
|
||||
i += 1 # move past the header into the data block
|
||||
while i < n:
|
||||
team = _cell(rows[i], name_col).strip()
|
||||
if not team or team.lower() == _TEAM_NAME_HEADER:
|
||||
break # blank team-name (or a new header) ends this section
|
||||
players = parse_lineup(_cell(rows[i], lineup_col))
|
||||
if players:
|
||||
rosters.setdefault(team, []).extend(players)
|
||||
i += 1
|
||||
return rosters
|
||||
|
||||
|
||||
def build_username_index(rosters: dict[str, list[str]]) -> dict[str, str]:
|
||||
"""Invert {team: [usernames]} into {normalized username: team}.
|
||||
|
||||
If the same username appears on two teams the last one wins and a warning
|
||||
is logged (a person is expected to be on exactly one team).
|
||||
"""
|
||||
index: dict[str, str] = {}
|
||||
for team, players in rosters.items():
|
||||
for player in players:
|
||||
key = player.strip().lower()
|
||||
if not key:
|
||||
continue
|
||||
if key in index and index[key] != team:
|
||||
log.warning(
|
||||
"Player %r appears on both %r and %r; using %r",
|
||||
player, index[key], team, team,
|
||||
)
|
||||
index[key] = team
|
||||
return index
|
||||
|
||||
|
||||
def _refresh_teams_sync() -> dict[str, list[str]]:
|
||||
global _team_roster, _team_by_username, _team_names
|
||||
creds = Credentials.from_service_account_file(config.GOOGLE_CREDS_PATH, scopes=SCOPES)
|
||||
client = gspread.authorize(creds)
|
||||
spreadsheet = client.open_by_key(config.TEAM_SHEET_ID)
|
||||
|
||||
rosters: dict[str, list[str]] = {}
|
||||
for ws in spreadsheet.worksheets():
|
||||
for team, players in parse_team_rosters(ws.get_all_values()).items():
|
||||
rosters.setdefault(team, []).extend(players)
|
||||
|
||||
_team_roster = rosters
|
||||
_team_by_username = build_username_index(rosters)
|
||||
_team_names = set(rosters)
|
||||
return rosters
|
||||
|
||||
|
||||
async def refresh_teams() -> dict[str, list[str]]:
|
||||
"""Reload the team registration sheet into the in-memory team caches.
|
||||
|
||||
No-op returning {} when TEAM_SHEET_ID is not configured, so the whole
|
||||
feature can be left switched off without touching sync behaviour.
|
||||
"""
|
||||
if not config.TEAM_SHEET_ID:
|
||||
return {}
|
||||
return await asyncio.to_thread(_refresh_teams_sync)
|
||||
|
||||
|
||||
def get_team_for_username(username: str) -> str | None:
|
||||
"""Return the team a Discord username is registered on, or None."""
|
||||
return _team_by_username.get(str(username).strip().lower())
|
||||
|
||||
|
||||
def all_team_names() -> set[str]:
|
||||
"""Every team name known from the registration sheet (the role universe)."""
|
||||
return set(_team_names)
|
||||
|
||||
|
||||
def get_team_rosters() -> dict[str, list[str]]:
|
||||
"""Current {team: [usernames]} cache (mainly for diagnostics/tests)."""
|
||||
return _team_roster
|
||||
|
||||
@@ -16,7 +16,7 @@ The codebase is split into **`core/`** (domain logic), **`commands/`** (Discord
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `core/economy/` | Economy business logic **package**, re-exported via `core/economy/__init__.py` so callers use `from core import economy` + attribute access (`economy.do_daily`, `economy.SHOP`, ...). Submodules: `store.py` (user records, per-user locks, `COOLDOWNS`, `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/economy/` | Economy business logic **package**, re-exported via `core/economy/__init__.py` so callers use `from core import economy` + attribute access (`economy.do_daily`, `economy.SHOP`, ...). Submodules: `store.py` (user records, per-user locks, `COOLDOWNS`, `JAIL_DURATION`, `COIN`, `get_user`/`_commit`/`_txn`), `income.py`, `gambling.py`, `fishing.py`, `jail.py`, `heist.py`, `prestige.py`, `shop.py`, `levels.py`, `quests.py`, `leaderboards.py`, `house.py`, `admin.py` |
|
||||
| `core/pb_client.py` | Async PocketBase REST client - auth token cache, CRUD on `economy_users` collection |
|
||||
| `core/sheets.py` | Google Sheets integration (member sync) |
|
||||
| `core/member_sync.py` | Birthday/member sync helpers |
|
||||
@@ -31,10 +31,10 @@ Each file exposes a `register_<group>_commands(tree, bot, ...)` function. `bot.p
|
||||
| `commands/dev_member_runtime.py` | `on_member_join` flow + `birthday_daily` task body |
|
||||
| `commands/economy_income_commands.py` | `/daily`, `/work`, `/beg`, `/crime`, `/rob` |
|
||||
| `commands/economy_games_commands.py` | `/roulette`, `/slots`, `/blackjack`, `/rps` |
|
||||
| `commands/economy_extra_commands.py` | `/heist`, `/jailbreak`, `/reminders`, `/request`, `/give`, `/lottery`, `/leaderboard`, ... |
|
||||
| `commands/economy_extra_commands.py` | `/heist`, `/jailbreak`, `/reminders`, `/request`, ... |
|
||||
| `commands/economy_fish_commands.py` | `/fish`, `/fishbook`, `/fishsell` |
|
||||
| `commands/economy_profile_commands.py` | `/balance`, `/rank`, `/stats`, `/cooldowns`, `/achievements` |
|
||||
| `commands/economy_support_commands.py` | `/shop`, `/buy`, `/economysetup`, `/consumables`, `/vanity`, `/lootbox`, `/bank`, `/deposit`, `/withdraw` |
|
||||
| `commands/economy_profile_commands.py` | `/balance`, `/rank`, `/stats`, `/cooldowns`, `/leaderboard` |
|
||||
| `commands/economy_support_commands.py` | `/shop`, `/buy`, `/give`, `/economysetup` |
|
||||
| `commands/economy_prestige_commands.py` | `/prestige`, `/prestigeshop`, `/prestigebuy` |
|
||||
| `commands/economy_admin_commands.py` | `/admincoins`, `/adminexp`, `/adminitem`, `/adminjail`, `/adminban`, `/adminreset`, `/adminview` |
|
||||
| `commands/ops_admin_commands.py` | `/sync`, `/restart`, `/shutdown`, `/pause`, `/send`, `/status` |
|
||||
@@ -58,7 +58,7 @@ Pick the `commands/economy_*_commands.py` file that matches the new command's ca
|
||||
Checklist - do all of these, in order:
|
||||
|
||||
1. **`core/economy/<area>.py`** (e.g. `income.py`, `gambling.py`, `fishing.py`) - add the `do_<cmd>` async function with cooldown check, logic, `_commit`, and `_txn` logging (`get_user`/`_commit`/`_txn` live in `store.py`)
|
||||
2. **`core/economy/store.py`** - add the cooldown to `COOLDOWNS` dict if it has one. Compute the effective cooldown in `do_<cmd>` with `effective_cooldown("<cmd>", user["items"])` rather than an inline `timedelta(...) if item in items else ...`
|
||||
2. **`core/economy/store.py`** - add the cooldown to `COOLDOWNS` dict if it has one
|
||||
3. **`core/economy/levels.py`** - add the EXP reward to `EXP_REWARDS` dict
|
||||
4. **`strings/commands.py` `CMD`** - add the slash command description
|
||||
5. **`strings/commands.py` `OPT`** - add any parameter descriptions
|
||||
@@ -66,11 +66,11 @@ Checklist - do all of these, in order:
|
||||
7. **`strings/common.py` `ERR`** - add any error messages (banned, cooldown uses `CD_MSG`, jailed uses `CD_MSG["jailed"]`)
|
||||
8. **`strings/common.py` `CD_MSG`** - add cooldown message if command has a cooldown
|
||||
9. **`strings/commands.py` `HELP_CATEGORIES["tipibot"]["fields"]`** - add the command to the help embed
|
||||
10. **`commands/economy_<group>_commands.py`** - inside `register_*_commands`, add `@tree.command(name="<cmd>", ...)` `cmd_<name>`; handle all `res["reason"]` cases. 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
|
||||
10. **`commands/economy_<group>_commands.py`** - inside `register_*_commands`, add `@tree.command(name="<cmd>", ...)` `cmd_<name>`; handle all `res["reason"]` cases
|
||||
11. **`commands/economy_<group>_commands.py`** - call `maybe_remind(user_id, "<cmd>")` if the command has a cooldown and reminders make sense (the helper is passed in via the `register_*` signature)
|
||||
12. **`commands/economy_<group>_commands.py`** - call `await award_exp(interaction, economy.EXP_REWARDS["<cmd>"])` on success
|
||||
13. **`strings/common.py` `REMINDER_OPTS`** - add a reminder option if the command needs one
|
||||
14. **`core/economy/store.py` `ITEM_COOLDOWNS`** - if an item shortens the command's cooldown, add `"<cmd>": ("<item_id>", timedelta(...))` here. This is the single source of truth: `effective_cooldown` (used by `do_<cmd>`), `_maybe_remind`, and `_restore_reminders` all read it, so you no longer edit the reminder helpers by hand
|
||||
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)
|
||||
|
||||
---
|
||||
|
||||
@@ -116,7 +116,7 @@ All economy state is stored in **PocketBase** (`economy_users` collection). `cor
|
||||
|
||||
| Command | Cooldown | Base Earn | Notes |
|
||||
|---|---|---|---|
|
||||
| `/daily` | 20h (18h w/ korvaklapid) | 150⬡ | ×streak multiplier, ×2 w/ lan_pass, +25⬡ w/ korvaklapid, +5% interest w/ gaming_laptop |
|
||||
| `/daily` | 20h (18h w/ korvaklapid) | 150⬡ | ×streak multiplier, ×2 w/ lan_pass, +5% interest w/ gaming_laptop |
|
||||
| `/work` | 1h (40min w/ monitor) | 15-75⬡ | ×1.5 w/ gaming_hiir, ×1.25 w/ reguleeritav_laud, ×3 30% chance w/ energiajook |
|
||||
| `/beg` | 5min (3min w/ hiirematt) | 10-40⬡ | ×2 w/ klaviatuur |
|
||||
| `/crime` | 2h | 200-500⬡ win | 60% success (75% w/ cat6), +30% w/ mikrofon; fail = fine + jail |
|
||||
|
||||
@@ -34,12 +34,10 @@ from core import economy # noqa: E402
|
||||
PB_URL = config.PB_URL
|
||||
COLLECTIONS = sorted({config.PB_ECONOMY_COLLECTION_DEV, config.PB_ECONOMY_COLLECTION_ECONOMY})
|
||||
|
||||
# _default_user() keys with a None/str default that PocketBase stores as text
|
||||
# (ISO date/datetime strings, plus the vanity badge id).
|
||||
# _default_user() keys whose default is None, all ISO date/datetime strings
|
||||
_TEXT_FIELDS = {
|
||||
"last_daily", "last_work", "last_beg", "last_crime", "last_rob",
|
||||
"last_heist", "last_fish", "last_streak_date", "jailed_until",
|
||||
"vanity_active", "lottery_period",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ from .member import (
|
||||
BIRTHDAY_UI,
|
||||
BIRTHDAY_MONTHS,
|
||||
CHECK_UI,
|
||||
TEAMSYNC_UI,
|
||||
)
|
||||
|
||||
from .economy import (
|
||||
@@ -58,11 +59,6 @@ from .economy import (
|
||||
ITEM_DESCRIPTIONS,
|
||||
CONSUMABLES_UI,
|
||||
CONSUMABLE_DESCRIPTIONS,
|
||||
VANITY_UI,
|
||||
LOOTBOX_UI,
|
||||
BANK_UI,
|
||||
ACHIEVEMENTS_UI,
|
||||
LOTTERY_UI,
|
||||
JAILED_UI,
|
||||
SHOP_BTN,
|
||||
DAILY_UI,
|
||||
@@ -144,6 +140,7 @@ __all__ = [
|
||||
'BIRTHDAY_UI',
|
||||
'BIRTHDAY_MONTHS',
|
||||
'CHECK_UI',
|
||||
'TEAMSYNC_UI',
|
||||
'WORK_JOBS',
|
||||
'BEG_LINES',
|
||||
'BEG_JAIL_LINES',
|
||||
@@ -155,11 +152,6 @@ __all__ = [
|
||||
'ITEM_DESCRIPTIONS',
|
||||
'CONSUMABLES_UI',
|
||||
'CONSUMABLE_DESCRIPTIONS',
|
||||
'VANITY_UI',
|
||||
'LOOTBOX_UI',
|
||||
'BANK_UI',
|
||||
'ACHIEVEMENTS_UI',
|
||||
'LOTTERY_UI',
|
||||
'JAILED_UI',
|
||||
'SHOP_BTN',
|
||||
'DAILY_UI',
|
||||
|
||||
@@ -19,9 +19,6 @@ ADMINVIEW_UI: dict[str, str] = {
|
||||
"banned_yes": "🚫 JAH",
|
||||
"banned_no": "✅ Ei",
|
||||
"f_balance": "💰 Saldo",
|
||||
"f_bank": "🏦 Pangas",
|
||||
"f_extras": "🏅 Muu",
|
||||
"extras_val": "Saavutusi: {ach}\nÕnnekaste: {lootboxes}\nOotel panus: {wager}",
|
||||
"f_exp": "📊 EXP / Tase",
|
||||
"f_streak": "🔥 Streak",
|
||||
"f_banned": "🚫 Keelatud",
|
||||
|
||||
@@ -24,6 +24,7 @@ CMD: dict[str, str] = {
|
||||
"check": "Laadi andmed, täida ID'd ja sünkroniseeri kõik liikmed",
|
||||
"sync": "Sünkroniseeri käsklused Discordi serveriga",
|
||||
"member": "Näita liikme andmeid tabelist",
|
||||
"teamsync": "[Admin] Sünkroniseeri tiimirollid registreerimistabelist",
|
||||
"restart": "Tee taaskäivitus botile",
|
||||
"shutdown": "Lülita bot välja (ilma taaskäivituseta)",
|
||||
"pause": "Peata / jätka kõik käsklused (hooldusrežiim)",
|
||||
@@ -74,13 +75,6 @@ CMD: dict[str, str] = {
|
||||
"patchnotes": "Vaata TipiBOTi viimaseid muudatusi ja uuendusi",
|
||||
"quests": "Vaata oma päeva- ja nädalaülesandeid ning nõua auhinnad",
|
||||
"consumables": "Sirvi ja osta turgutusi (korduvostetavad boostid)",
|
||||
"vanity": "Staatusepood - osta ja kanna kosmeetilisi tiitleid",
|
||||
"lootbox": "Ava õnnekast - juhuslik auhind müntide või boonuse näol",
|
||||
"achievements": "Vaata oma saavutusi ja teeni märkide eest ühekordseid preemiaid",
|
||||
"bank": "Vaata oma panka - röövikindel hoius",
|
||||
"deposit": "Pane münte panka (röövikindel, aga ei teeni intressi)",
|
||||
"withdraw": "Võta münte pangast rahakotti",
|
||||
"lottery": "Vaata TipiLOTO potti või osta pileteid (tühjaks jättes näeb infot)",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -98,11 +92,7 @@ OPT: dict[str, str] = {
|
||||
"give_kasutaja": "Kellele annad?",
|
||||
"give_summa": "Kui palju annad? ('all' = kogu saldo)",
|
||||
"buy_ese": "Eseme nimi (vaata /shop)",
|
||||
"deposit_summa": "Kui palju panna panka? ('all' = kogu vaba raha)",
|
||||
"withdraw_summa": "Kui palju pangast välja võtta? ('all' = kogu pangas)",
|
||||
"lottery_kogus": "Mitu piletit osta (tühjaks jättes näeb potti)",
|
||||
"consumable_ese": "Turgutus, mida osta (tühjaks jättes näeb menüüd)",
|
||||
"vanity_ese": "Tiitel, mida osta või kanda (tühjaks jättes näeb poodi)",
|
||||
"rps_panus": "Valikuline TipiCOINide panus ('all' = kogu saldo)",
|
||||
"rps_vastane": "Väljakutse teisele mängijale (PvP)",
|
||||
"slots_panus": "Panus TipiCOINides ('all' = kogu saldo)",
|
||||
@@ -158,18 +148,10 @@ HELP_CATEGORIES: dict[str, dict] = {
|
||||
("/heist", "Alusta grupiröövi pangahoidlasse. Min 2 mängijat, max 8. 5 min ühinemisaeg. Õnnestumisel jagatakse saak võrdselt - ebaõnnestumisel 1h 30min vangis + trahv. 4h serveri ooteaeg (ei ole isiklik)."),
|
||||
("/jailbreak", "Proovi vanglas olles täringuid visata, et duublit saada (3 katset). Duubli korral saad vabaks. Ebaõnnestumisel saad valida: maksa kautsjon (20-30% saldost, min 350 ⬡) või jää vanglasse kuni aja lõpuni."),
|
||||
("/give @user <amount>", "Anna TipiCOINe teisele mängijale"),
|
||||
("/bank", "Vaata oma panka. Panka pandud mündid on röövikindlad (/rob ja /heist ei puuduta)."),
|
||||
("/deposit <amount>", "Pane münte panka (röövikindel hoius, ei teeni intressi)."),
|
||||
("/withdraw <amount>", "Võta münte pangast rahakotti."),
|
||||
("/quests", "Vaata oma päeva- ja nädalaülesandeid ning nõua auhinnad (uueneb iga päev/nädal)."),
|
||||
("/achievements", "Vaata oma saavutusi. Iga lukust lahti saanud märk annab ühekordse müntipreemia."),
|
||||
("/lottery [kogus]", "Vaata TipiLOTO potti või osta pileteid. Loosimine iga päev - üks võitja saab kogu poti (rohkem pileteid = suurem võiduvõimalus)."),
|
||||
("/leaderboard", "TipiBOTi edetabel - kes on kõige rikkam?"),
|
||||
("/shop", "Sirvi TipiBOTi poodi"),
|
||||
("/buy <item>", "Osta ese TipiBOTi poodist"),
|
||||
("/consumables", "Osta korduvostetavaid turgutusi (ajutised boostid)."),
|
||||
("/lootbox", "Ava õnnekast (1000 ⬡) - juhuslik auhind: mündid või ajutine boonus."),
|
||||
("/vanity", "Staatusepood - osta ja kanna kosmeetilisi tiitleid (näha /profile-l)."),
|
||||
("/request <amount> <reason> [target]", "Saada crowdfundingu taotlus. Keegi saab 'Toeta' nuppu vajutades raha kanda (taotlus kehtib 5 minutit)."),
|
||||
("/reminders", "DM meeldetuletused on vaikimisi sees. Kasuta seda käsku, et lülitada sisse/välja, milliseid käsklusi meelde tuletada."),
|
||||
],
|
||||
|
||||
@@ -175,8 +175,6 @@ ERR: dict[str, str] = {
|
||||
"guild_only": "Seda käsku saab kasutada ainult serveris.",
|
||||
"sheet_error": "❌ Tabeli laadimine ebaõnnestus: ```{error}```",
|
||||
"gamble_cooldown": "🎰 Oled just mänginud! Saad uuesti mängida {ts}.",
|
||||
"payout_failed": "⚠️ Tehniline viga võidu väljamaksmisel - see on logitud ja admin taastab su TipiCOINid. Vabandame!",
|
||||
"db_error": "⚠️ Andmebaas ei vasta praegu. Proovi hetke pärast uuesti.",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -243,8 +241,6 @@ STATUS_UI: dict[str, str] = {
|
||||
"tasks_field": "🔄 Async tasks",
|
||||
"eco_players_field": "👤 Eco players",
|
||||
"members_cache_field": "📋 Liikmed (cache)",
|
||||
"supply_field": "💰 Rahavaru",
|
||||
"supply_val": "Kokku {total}\nMängijatel {players}\nKassas {house} ({house_pct}%)",
|
||||
"log_files_field": "📂 Log files",
|
||||
"log_line": "`{name}` - {size_kb} KB",
|
||||
"none": "-",
|
||||
|
||||
@@ -17,11 +17,6 @@ __all__ = [
|
||||
'ITEM_DESCRIPTIONS',
|
||||
'CONSUMABLES_UI',
|
||||
'CONSUMABLE_DESCRIPTIONS',
|
||||
'VANITY_UI',
|
||||
'LOOTBOX_UI',
|
||||
'BANK_UI',
|
||||
'ACHIEVEMENTS_UI',
|
||||
'LOTTERY_UI',
|
||||
'JAILED_UI',
|
||||
'SHOP_BTN',
|
||||
'DAILY_UI',
|
||||
@@ -245,84 +240,6 @@ CONSUMABLES_UI: dict[str, str] = {
|
||||
"bought_instant": "☕ Kõik ooteajad nullitud!\nUus saldo: {balance}",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vanity shop (cosmetic badges/titles - a pure status sink, no gameplay effect)
|
||||
# ---------------------------------------------------------------------------
|
||||
VANITY_UI: dict[str, str] = {
|
||||
"title": "👑 Staatusepood",
|
||||
"desc": "Puhtalt uhkuse pärast - märgid ja tiitlid ilma igasuguse mänguefektita. Kantud märk paistab su `/profile`-l.\nSaldo: {bal}",
|
||||
"line_owned": "✅ Olemas",
|
||||
"line_active": "⭐ Kantud",
|
||||
"entry_title": "„{title}“",
|
||||
"footer": "Osta või kanna: /vanity <märk> · „Eemalda märk“ võtab tiitli maha",
|
||||
"none_choice": "❌ Eemalda märk",
|
||||
"bought": "{emoji} Ostsid tiitli **{title}** ja panid selle kohe kandma!\nUus saldo: {balance}",
|
||||
"equipped": "{emoji} Kannad nüüd tiitlit **{title}**.",
|
||||
"unequipped": "Märk eemaldatud - su profiil on jälle tavaline.",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lootbox (pay-to-open mystery box, a coin sink with a random reward)
|
||||
# ---------------------------------------------------------------------------
|
||||
LOOTBOX_UI: dict[str, str] = {
|
||||
"title": "🎁 Õnnekast",
|
||||
"opening": "🎁 Avan õnnekasti...",
|
||||
"coins_small": "🪙 Leidsid põhjast paar münti: **+{coins}**",
|
||||
"coins_medium": "💰 Korralik saak: **+{coins}**",
|
||||
"coins_big": "💎 Suur õnn: **+{coins}**",
|
||||
"jackpot": "🎉 **JACKPOT!** **+{coins}**",
|
||||
"buff_earn": "⚡ Boonus: teenimine **×2** järgmiseks **{min} minutiks**!",
|
||||
"buff_exp": "✨ Boonus: EXP **×2** järgmiseks **{min} minutiks**!",
|
||||
"foot_win": "Netovõit: +{net} · Saldo: {balance}",
|
||||
"foot_loss": "Netokahjum: {net} · Saldo: {balance}",
|
||||
"foot_buff": "Saldo: {balance}",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bank vault (rob-proof storage; liquid vs banked coins)
|
||||
# ---------------------------------------------------------------------------
|
||||
BANK_UI: dict[str, str] = {
|
||||
"title": "🏦 TipiPANK",
|
||||
"desc": "Panka pandud mündid on **röövikindlad** (`/rob` ja `/heist` neid ei puuduta), aga neid ei saa kulutada ega panustada enne väljavõtmist ega teeni Botikoopa intressi.",
|
||||
"f_liquid": "💵 Rahakotis (vaba)",
|
||||
"f_bank": "🏦 Pangas (kaitstud)",
|
||||
"deposited": "🏦 Panid **{amount}** panka.\n💵 Rahakotis: {balance} · 🏦 Pangas: {bank}",
|
||||
"withdrawn": "💵 Võtsid **{amount}** pangast välja.\n💵 Rahakotis: {balance} · 🏦 Pangas: {bank}",
|
||||
"nothing_liquid": "❌ Sul pole nii palju vaba raha rahakotis.",
|
||||
"nothing_bank": "❌ Sul pole nii palju raha pangas.",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Achievements (one-time milestone badges over lifetime stats)
|
||||
# ---------------------------------------------------------------------------
|
||||
ACHIEVEMENTS_UI: dict[str, str] = {
|
||||
"title": "🏅 Saavutused",
|
||||
"desc": "Iga lukust lahti saanud märk annab ühekordse müntipreemia.\nAvatud: **{earned}/{total}**",
|
||||
"row_earned": "✅ {emoji} **{name}** · +{reward}",
|
||||
"row_locked": "🔒 {emoji} {name} · {progress}/{goal} · +{reward}",
|
||||
"unlocked_note": "🎉 **Uued saavutused avatud:** {names}\n💰 Preemia: +{reward}",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lottery (daily draw; one weighted winner takes the pot)
|
||||
# ---------------------------------------------------------------------------
|
||||
LOTTERY_UI: dict[str, str] = {
|
||||
"title": "🎟️ TipiLOTO",
|
||||
"desc": "Osta pileteid ja võida kogu pott! Loosimine iga päev **{draw_time}**. Iga pilet = **{cost}**, rohkem pileteid = suurem võiduvõimalus.",
|
||||
"f_pot": "💰 Praegune pott",
|
||||
"f_players": "👥 Osalejaid",
|
||||
"f_your": "🎟️ Sinu piletid",
|
||||
"your_val": "{tickets} tk · võiduvõimalus **{chance}%**",
|
||||
"your_none": "Sul pole veel pileteid. Osta käsuga `/lottery <kogus>`.",
|
||||
"bought": "🎟️ Ostsid **{count}** piletit ({cost}).\nSul on nüüd **{tickets}** piletit selle päeva loosimises.\nSaldo: {balance}",
|
||||
"max_tickets": "❌ Maksimaalne piletite arv ühes loosimises on {cap} (sul on {held}).",
|
||||
"empty_pot": "🎟️ Pott on tühi - ole esimene, kes piletit ostab!",
|
||||
# Draw announcement
|
||||
"draw_title": "🎟️ TipiLOTO loosimine!",
|
||||
"draw_win": "🎉 Võitja: {winner}\n💰 Võit: **{pot}**\n🎟️ {tickets}/{total} piletit ({chance}%)\n👥 {players} osalejat",
|
||||
"draw_none": "🎟️ Täna keegi pileteid ei ostnud - loosimist ei toimunud.",
|
||||
}
|
||||
|
||||
JAILED_UI: dict[str, str] = {
|
||||
"title": "🔒 Praegu vanglas",
|
||||
"empty": "Kõik on vabad! Vanglas pole kedagi.",
|
||||
@@ -362,7 +279,7 @@ STATS_UI: dict[str, str] = {
|
||||
"social_field": "🤝 Sotsiaalne",
|
||||
"social_val": "Kingitud: {given}\nSaadud: {received}",
|
||||
"records_field": "🔥 Rekordid",
|
||||
"records_val": "Pikim päevane streak: **{streak}** päeva\n🎁 Õnnekaste avatud: **{lootboxes}**\n🏅 Saavutusi: **{achievements}/{ach_total}**",
|
||||
"records_val": "Pikim päevane streak: **{streak}** päeva",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -11,6 +11,7 @@ __all__ = [
|
||||
'BIRTHDAY_UI',
|
||||
'BIRTHDAY_MONTHS',
|
||||
'CHECK_UI',
|
||||
'TEAMSYNC_UI',
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -94,3 +95,22 @@ CHECK_UI: dict[str, str] = {
|
||||
"detail_changed": "🔧 **{name}**: {parts}",
|
||||
"ids_filled": "\n🔑 Täideti **{count}** puuduvat kasutaja ID-d.",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /teamsync UI strings (tournament team-role sync from the registration sheet)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TEAMSYNC_UI: dict[str, str] = {
|
||||
"disabled": "⚠️ Tiimide sünkroonimine on välja lülitatud (TEAM_SHEET_ID puudub).",
|
||||
"refresh_error": "⚠️ Registreerimistabeli laadimine ebaõnnestus: {error}",
|
||||
"done": "**Tiimide sünkroonimine lõpetatud!**",
|
||||
"scanned": "👥 Kontrollitud liikmeid: {count}",
|
||||
"assigned": "✅ Tiimirolle antud: {count}",
|
||||
"removed": "➖ Tiimirolle eemaldatud: {count}",
|
||||
"created": "🆕 Loodud uusi tiimirolle: {roles}",
|
||||
"errors": "⚠️ Vead: {count}",
|
||||
"no_changes": "✨ Kõik tiimirollid olid juba korras.",
|
||||
"changes_header": "**Muudatused:**",
|
||||
"changes_more": "... ja {count} rohkem",
|
||||
}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
"""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"]
|
||||
@@ -1,86 +0,0 @@
|
||||
"""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
|
||||
@@ -100,10 +100,3 @@ class TestEffects:
|
||||
user = run(economy.get_user(UID))
|
||||
assert user["last_work"] is None # cooldown wiped -> /work is ready
|
||||
assert economy.store._cooldown_remaining(user, "work") is None
|
||||
|
||||
def test_instant_reset_commands_match_cleared_cooldowns(self):
|
||||
# The Discord layer cancels reminder DMs for exactly these commands after a
|
||||
# kohv, so the list must stay in lockstep with the cooldown fields it wipes.
|
||||
expected = tuple(f.removeprefix("last_") for f in economy.consumables._COOLDOWN_FIELDS)
|
||||
assert economy.INSTANT_RESET_COMMANDS == expected
|
||||
assert economy.INSTANT_RESET_COMMANDS == ("work", "beg", "crime", "rob", "fish")
|
||||
|
||||
@@ -35,29 +35,6 @@ class TestLevels:
|
||||
assert economy.level_role_name(99) == "TipiLEGEND"
|
||||
|
||||
|
||||
class TestEffectiveCooldown:
|
||||
def test_default_when_no_item(self):
|
||||
assert economy.effective_cooldown("work", []) == economy.COOLDOWNS["work"]
|
||||
assert economy.effective_cooldown("daily", []) == economy.COOLDOWNS["daily"]
|
||||
|
||||
def test_item_reduces_cooldown(self):
|
||||
assert economy.effective_cooldown("work", ["monitor"]) == timedelta(minutes=40)
|
||||
assert economy.effective_cooldown("beg", ["hiirematt"]) == timedelta(minutes=3)
|
||||
assert economy.effective_cooldown("daily", ["korvaklapid"]) == timedelta(hours=18)
|
||||
assert economy.effective_cooldown("fish", ["ussipurk"]) == timedelta(seconds=90)
|
||||
|
||||
def test_unrelated_item_does_not_reduce(self):
|
||||
assert economy.effective_cooldown("work", ["hiirematt"]) == economy.COOLDOWNS["work"]
|
||||
|
||||
def test_command_without_cooldown_returns_none(self):
|
||||
assert economy.effective_cooldown("unknown", []) is None
|
||||
|
||||
def test_every_item_cooldown_beats_its_base(self):
|
||||
# An item-reduced cooldown must always be shorter than the base.
|
||||
for cmd, (item, reduced) in economy.store.ITEM_COOLDOWNS.items():
|
||||
assert reduced < economy.COOLDOWNS[cmd]
|
||||
|
||||
|
||||
class TestGambleExp:
|
||||
def test_tiers(self):
|
||||
assert economy.gamble_exp(0) == 0
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
"""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
|
||||
@@ -1,78 +0,0 @@
|
||||
"""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
|
||||
@@ -1,131 +0,0 @@
|
||||
"""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
|
||||
@@ -10,7 +10,6 @@ Covers the two pure-logic fixes:
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from core import economy
|
||||
from core.pb_client import DatabaseError
|
||||
|
||||
from conftest import run
|
||||
|
||||
@@ -90,26 +89,3 @@ class TestBailIdempotency:
|
||||
self._jail(fake_pb, now, balance=1000)
|
||||
res = run(economy.do_bail(UID))
|
||||
assert res["ok"] and res["fine"] >= economy.MIN_BAIL
|
||||
|
||||
|
||||
class TestBlackjackPayoutSafety:
|
||||
"""do_blackjack_payout must not raise on a DB failure - the stake was already
|
||||
deducted in do_blackjack_bet, so a raised exception would blow up the
|
||||
interaction handler and swallow the outcome. It reports db_error instead."""
|
||||
|
||||
async def _boom(self, *args, **kwargs):
|
||||
raise DatabaseError("simulated PocketBase outage")
|
||||
|
||||
def test_payout_returns_db_error_when_read_fails(self, fake_pb, monkeypatch):
|
||||
monkeypatch.setattr(economy.gambling, "get_user", self._boom)
|
||||
res = run(economy.do_blackjack_payout(UID, payout=200, total_invested=100))
|
||||
assert res == {"ok": False, "reason": "db_error"}
|
||||
|
||||
def test_payout_returns_db_error_when_commit_fails(self, fake_pb, monkeypatch):
|
||||
run(economy.get_user(UID))
|
||||
fake_pb.record_for(UID)["balance"] = 500
|
||||
monkeypatch.setattr(economy.gambling, "_commit", self._boom)
|
||||
res = run(economy.do_blackjack_payout(UID, payout=200, total_invested=100))
|
||||
assert res == {"ok": False, "reason": "db_error"}
|
||||
# The failed credit did not persist; balance is untouched (no partial win).
|
||||
assert fake_pb.record_for(UID)["balance"] == 500
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
"""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
|
||||
224
tests/test_team_sync.py
Normal file
224
tests/test_team_sync.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""Tests for team-role sync from the tournament registration sheet.
|
||||
|
||||
Covers the risky parsing (turning a messy, multi-section, merged-cell sheet
|
||||
into {team: [discord usernames]}) and the roster-independent add/remove/
|
||||
auto-create behaviour of sync_team_role, using lightweight fakes for discord
|
||||
+ the sheets cache.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from core import member_sync, sheets # noqa: E402
|
||||
from tests.conftest import run # noqa: E402
|
||||
|
||||
|
||||
# --- parse_lineup ----------------------------------------------------------
|
||||
|
||||
def test_parse_lineup_strips_citizenship_and_splits():
|
||||
cell = "TFT (EST), nqmm (EST), sn1rk (EST), Kevka (EST), Kalatexx (EST)"
|
||||
assert sheets.parse_lineup(cell) == ["TFT", "nqmm", "sn1rk", "Kevka", "Kalatexx"]
|
||||
|
||||
|
||||
def test_parse_lineup_keeps_names_containing_commas_and_semicolons():
|
||||
# A single player's descriptive name contains a ';' - must stay one name.
|
||||
cell = "Onu Klaus ; vahepeal ka mõni teine tegelane (EST), Lurban (EST)"
|
||||
assert sheets.parse_lineup(cell) == [
|
||||
"Onu Klaus ; vahepeal ka mõni teine tegelane",
|
||||
"Lurban",
|
||||
]
|
||||
|
||||
|
||||
def test_parse_lineup_handles_odd_usernames():
|
||||
cell = "-acc +vac (EST), m (EST), M1X3RRRRRR (EST)"
|
||||
assert sheets.parse_lineup(cell) == ["-acc +vac", "m", "M1X3RRRRRR"]
|
||||
|
||||
|
||||
def test_parse_lineup_mixed_citizenship():
|
||||
cell = "imp (LAT), milteg (EST), Freesies (EST)"
|
||||
assert sheets.parse_lineup(cell) == ["imp", "milteg", "Freesies"]
|
||||
|
||||
|
||||
def test_parse_lineup_empty():
|
||||
assert sheets.parse_lineup("") == []
|
||||
assert sheets.parse_lineup(" ") == []
|
||||
|
||||
|
||||
def test_parse_lineup_fallback_without_citizenship():
|
||||
assert sheets.parse_lineup("alice, bob") == ["alice", "bob"]
|
||||
|
||||
|
||||
# --- parse_team_rosters (multi-section sheet) ------------------------------
|
||||
|
||||
# Mirrors the real sheet: merged title rows, a header row, team rows, a blank
|
||||
# separator, then a SECOND section with a different column count.
|
||||
SHEET_ROWS = [
|
||||
["", "", "", "", "", "", ""],
|
||||
["name", "members", "vrs ranking", "registration_date", "game", "", ""],
|
||||
["[merged] TipiLAN 2026 CS2 Registration Log"] + [""] * 6,
|
||||
["No", "Team Name", "Lineup (nickname, citizenship)", "VRS Ranking",
|
||||
"Registration Timestamp", "Status", "Participation confirmed?"],
|
||||
["1", "Piirivalvurid", "TFT (EST), nqmm (EST)", "N/A", "01.05.2026 15:07", "Confirmed", "Yes"],
|
||||
["2", "GENESIS", "kapa (EST), neaQ (EST)", "N/A", "01.05.2026 15:24", "Confirmed", "Yes"],
|
||||
["", "", "", "", "", "", ""],
|
||||
["[merged] TipiLAN 2026 LoL Registration Log"] + [""] * 4,
|
||||
["No", "Team Name", "Lineup (nickname, citizenship)",
|
||||
"Registration Timestamp (dd.mm.yyyy hh:mm)", "Confirmation Status"],
|
||||
["1", "Pushing 30s", "Onu Klaus (EST), Lurban (EST)", "01.05.2026 21:40", ""],
|
||||
["", "", "", "", ""],
|
||||
]
|
||||
|
||||
|
||||
def test_parse_team_rosters_multiple_sections():
|
||||
rosters = sheets.parse_team_rosters(SHEET_ROWS)
|
||||
assert rosters == {
|
||||
"Piirivalvurid": ["TFT", "nqmm"],
|
||||
"GENESIS": ["kapa", "neaQ"],
|
||||
"Pushing 30s": ["Onu Klaus", "Lurban"],
|
||||
}
|
||||
|
||||
|
||||
def test_parse_team_rosters_ignores_non_table_content():
|
||||
# No header row anywhere -> nothing extracted, no crash.
|
||||
assert sheets.parse_team_rosters([["just", "some", "prose"], ["more"]]) == {}
|
||||
|
||||
|
||||
def test_build_username_index_is_case_insensitive():
|
||||
index = sheets.build_username_index({"GENESIS": ["Kapa", "neaQ"]})
|
||||
assert index == {"kapa": "GENESIS", "neaq": "GENESIS"}
|
||||
|
||||
|
||||
# --- sync_team_role behaviour (roster-independent) -------------------------
|
||||
|
||||
class FakeRole:
|
||||
def __init__(self, rid: int, name: str):
|
||||
self.id = rid
|
||||
self.name = name
|
||||
|
||||
def __eq__(self, other):
|
||||
return isinstance(other, FakeRole) and other.id == self.id
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.id)
|
||||
|
||||
|
||||
class FakeMember:
|
||||
def __init__(self, uid: int, name: str, roles, bot: bool = False):
|
||||
self.id = uid
|
||||
self.name = name
|
||||
self.display_name = name
|
||||
self.bot = bot
|
||||
self.roles = list(roles)
|
||||
|
||||
async def add_roles(self, *roles, reason=None):
|
||||
self.roles.extend(roles)
|
||||
|
||||
async def remove_roles(self, *roles, reason=None):
|
||||
self.roles = [r for r in self.roles if r not in roles]
|
||||
|
||||
|
||||
class FakeGuild:
|
||||
def __init__(self, roles, members=None):
|
||||
self.roles = list(roles)
|
||||
self.members = list(members or [])
|
||||
self._next = 9000
|
||||
self.created: list[str] = []
|
||||
|
||||
async def create_role(self, name, reason=None):
|
||||
self._next += 1
|
||||
role = FakeRole(self._next, name)
|
||||
self.roles.append(role)
|
||||
self.created.append(name)
|
||||
return role
|
||||
|
||||
|
||||
def test_sync_creates_missing_team_role_and_removes_old_one(monkeypatch):
|
||||
old_team = FakeRole(1, "OldTeam")
|
||||
keeper = FakeRole(2, "Member") # not a team role - must be left alone
|
||||
member = FakeMember(1, "tft", roles=[old_team, keeper])
|
||||
guild = FakeGuild([old_team, keeper])
|
||||
|
||||
monkeypatch.setattr(sheets, "get_team_for_username",
|
||||
lambda n: "GENESIS" if n.lower() == "tft" else None)
|
||||
monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS", "OldTeam"})
|
||||
|
||||
result = run(member_sync.sync_team_role(member, guild))
|
||||
|
||||
assert result.created == "GENESIS" # auto-created the missing role
|
||||
assert "GENESIS" in guild.created
|
||||
assert result.added == "GENESIS"
|
||||
assert result.removed == ["OldTeam"] # left their previous team
|
||||
role_names = {r.name for r in member.roles}
|
||||
assert "GENESIS" in role_names
|
||||
assert "OldTeam" not in role_names
|
||||
assert "Member" in role_names # unrelated role untouched
|
||||
|
||||
|
||||
def test_sync_uses_existing_team_role(monkeypatch):
|
||||
genesis = FakeRole(3, "GENESIS")
|
||||
member = FakeMember(1, "kapa", roles=[])
|
||||
guild = FakeGuild([genesis])
|
||||
|
||||
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: "GENESIS")
|
||||
monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS"})
|
||||
|
||||
result = run(member_sync.sync_team_role(member, guild))
|
||||
|
||||
assert guild.created == [] # did NOT create a duplicate
|
||||
assert result.created is None
|
||||
assert result.added == "GENESIS"
|
||||
assert genesis in member.roles
|
||||
|
||||
|
||||
def test_sync_strips_team_role_when_not_registered(monkeypatch):
|
||||
old_team = FakeRole(1, "OldTeam")
|
||||
member = FakeMember(1, "ghost", roles=[old_team])
|
||||
guild = FakeGuild([old_team])
|
||||
|
||||
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: None)
|
||||
monkeypatch.setattr(sheets, "all_team_names", lambda: {"OldTeam"})
|
||||
|
||||
result = run(member_sync.sync_team_role(member, guild))
|
||||
|
||||
assert result.removed == ["OldTeam"]
|
||||
assert result.added is None
|
||||
assert old_team not in member.roles
|
||||
|
||||
|
||||
def test_sync_no_team_sheet_is_noop(monkeypatch):
|
||||
keeper = FakeRole(2, "Member")
|
||||
member = FakeMember(1, "someone", roles=[keeper])
|
||||
guild = FakeGuild([keeper])
|
||||
|
||||
# Empty caches = feature switched off: no removals even of a stale team role.
|
||||
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: None)
|
||||
monkeypatch.setattr(sheets, "all_team_names", lambda: set())
|
||||
|
||||
result = run(member_sync.sync_team_role(member, guild))
|
||||
|
||||
assert result.removed == []
|
||||
assert guild.created == []
|
||||
assert keeper in member.roles
|
||||
|
||||
|
||||
def test_sync_all_team_roles_aggregates_and_skips_bots(monkeypatch):
|
||||
genesis = FakeRole(3, "GENESIS")
|
||||
m1 = FakeMember(1, "kapa", roles=[]) # will get GENESIS
|
||||
m2 = FakeMember(2, "nobody", roles=[]) # not registered, unchanged
|
||||
bot_member = FakeMember(3, "botto", roles=[], bot=True) # skipped
|
||||
guild = FakeGuild([genesis], members=[m1, m2, bot_member])
|
||||
|
||||
teams = {"kapa": "GENESIS"}
|
||||
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: teams.get(n.lower()))
|
||||
monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS"})
|
||||
|
||||
summary = run(member_sync.sync_all_team_roles(guild))
|
||||
|
||||
assert summary.scanned == 2 # bot excluded
|
||||
assert summary.assigned == 1
|
||||
assert summary.removed == 0
|
||||
assert summary.changes == ["kapa: +GENESIS"]
|
||||
@@ -1,78 +0,0 @@
|
||||
"""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