add per-user locks, fix game conditons, startup config validation

This commit is contained in:
Rene Arumetsa
2026-07-26 20:03:56 +03:00
parent fdb0e5eb5b
commit fb3a91fc71
4 changed files with 218 additions and 34 deletions

View File

@@ -20,7 +20,7 @@
Checklist - do all of these, in order: Checklist - do all of these, in order:
1. **`economy.py`** - add the `do_<cmd>` async function with cooldown check, logic, `_commit`, and `_txn` logging 1. **`economy.py`** - add the `do_<cmd>` async function with cooldown check, logic, `_commit`, and `_txn` logging. Decorate it with `@_serialized()` (add `house=True` if it calls `_credit_house`, `id_args=(0, 1)` if it mutates two users) - this holds the per-user lock that prevents concurrent commands from overwriting each other's PocketBase writes.
2. **`economy.py`** - add the cooldown to `COOLDOWNS` dict if it has one 2. **`economy.py`** - add the cooldown to `COOLDOWNS` dict if it has one
3. **`economy.py`** - add the EXP reward to `EXP_REWARDS` dict 3. **`economy.py`** - add the EXP reward to `EXP_REWARDS` dict
3a. **PocketBase** - if the function stores new fields, add them as columns via `python scripts/add_stats_fields.py` (or manually in the PB admin UI at `http://127.0.0.1:8090/_/`). Fields not in the PB schema are silently dropped on PATCH. 3a. **PocketBase** - if the function stores new fields, add them as columns via `python scripts/add_stats_fields.py` (or manually in the PB admin UI at `http://127.0.0.1:8090/_/`). Fields not in the PB schema are silently dropped on PATCH.
@@ -36,6 +36,8 @@ Checklist - do all of these, in order:
13. **`strings.py` `REMINDER_OPTS`** - add a reminder option if the command needs one 13. **`strings.py` `REMINDER_OPTS`** - add a reminder option if the command needs one
14. **`bot.py` `_maybe_remind`** - if the command has an item-modified cooldown, add an `elif` branch 14. **`bot.py` `_maybe_remind`** - if the command has an item-modified cooldown, add an `elif` branch
> **Safety net:** `economy.validate_config()` runs at boot (called from `bot.py __main__`) and cross-checks COOLDOWNS↔CD_MSG, SHOP↔SHOP_TIERS↔SHOP_LEVEL_REQ↔ITEM_DESCRIPTIONS, the /help shop page (emoji, cost, tier tag), REMINDER_OPTS, and the slots tables. A forgotten checklist step crashes the bot at startup with a message naming the missing entry instead of failing at command time.
--- ---
## Adding a New Shop Item ## Adding a New Shop Item

83
bot.py
View File

@@ -1774,15 +1774,6 @@ class HeistLobbyView(discord.ui.View):
@discord.ui.button(label=S.HEIST_UI["btn_join"], style=discord.ButtonStyle.danger) @discord.ui.button(label=S.HEIST_UI["btn_join"], style=discord.ButtonStyle.danger)
async def join(self, interaction: discord.Interaction, _: discord.ui.Button): async def join(self, interaction: discord.Interaction, _: discord.ui.Button):
if any(p.id == interaction.user.id for p in self.participants):
await interaction.response.send_message(S.HEIST_UI["already_joined"], ephemeral=True)
return
if len(self.participants) >= _HEIST_MAX_PLAYERS:
await interaction.response.send_message(S.ERR["heist_full"], ephemeral=True)
return
if interaction.user.id in _active_games:
await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True)
return
res = await economy.do_heist_check(interaction.user.id) res = await economy.do_heist_check(interaction.user.id)
if not res["ok"]: if not res["ok"]:
if res["reason"] == "banned": if res["reason"] == "banned":
@@ -1796,6 +1787,17 @@ class HeistLobbyView(discord.ui.View):
S.CD_MSG["heist"].format(ts=_cd_ts(res["remaining"])), ephemeral=True S.CD_MSG["heist"].format(ts=_cd_ts(res["remaining"])), ephemeral=True
) )
return return
# These checks must come after the await above: a double-click could
# otherwise pass them twice and join the lobby twice.
if any(p.id == interaction.user.id for p in self.participants):
await interaction.response.send_message(S.HEIST_UI["already_joined"], ephemeral=True)
return
if len(self.participants) >= _HEIST_MAX_PLAYERS:
await interaction.response.send_message(S.ERR["heist_full"], ephemeral=True)
return
if interaction.user.id in _active_games:
await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True)
return
self.participants.append(interaction.user) self.participants.append(interaction.user)
_active_games.add(interaction.user.id) _active_games.add(interaction.user.id)
await interaction.response.edit_message(embed=self._lobby_embed()) await interaction.response.edit_message(embed=self._lobby_embed())
@@ -1923,31 +1925,42 @@ async def cmd_heist(interaction: discord.Interaction):
if _active_heist is not None: if _active_heist is not None:
await interaction.response.send_message(S.ERR["heist_active"], ephemeral=True) await interaction.response.send_message(S.ERR["heist_active"], ephemeral=True)
return return
_heist_cd = await economy.get_heist_global_cd()
if time.time() < _heist_cd:
await interaction.response.send_message(
S.CD_MSG["heist_global"].format(ts=_cd_ts(datetime.timedelta(seconds=_heist_cd - time.time()))),
ephemeral=True,
)
return
if interaction.user.id in _active_games: if interaction.user.id in _active_games:
await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True)
return return
res = await economy.do_heist_check(interaction.user.id) # Claim the lobby singleton and the game slot before the awaits below -
if not res["ok"]: # concurrent /heist invocations would otherwise all pass the checks above
if res["reason"] == "banned": # and the last lobby would silently overwrite the others.
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
elif res["reason"] == "jailed":
await interaction.response.send_message(
S.CD_MSG["jailed"].format(ts=_cd_ts(res["remaining"])), ephemeral=True
)
return
view = HeistLobbyView(interaction.user) view = HeistLobbyView(interaction.user)
_active_heist = view _active_heist = view
_active_games.add(interaction.user.id) _active_games.add(interaction.user.id)
await interaction.response.send_message(embed=view._lobby_embed(), view=view) opened = False
view.message = await interaction.original_response() try:
_heist_cd = await economy.get_heist_global_cd()
if time.time() < _heist_cd:
await interaction.response.send_message(
S.CD_MSG["heist_global"].format(ts=_cd_ts(datetime.timedelta(seconds=_heist_cd - time.time()))),
ephemeral=True,
)
return
res = await economy.do_heist_check(interaction.user.id)
if not res["ok"]:
if res["reason"] == "banned":
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
elif res["reason"] == "jailed":
await interaction.response.send_message(
S.CD_MSG["jailed"].format(ts=_cd_ts(res["remaining"])), ephemeral=True
)
return
await interaction.response.send_message(embed=view._lobby_embed(), view=view)
view.message = await interaction.original_response()
opened = True
finally:
if not opened:
if _active_heist is view:
_active_heist = None
_active_games.discard(interaction.user.id)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -2898,9 +2911,13 @@ async def cmd_rps(interaction: discord.Interaction, panus: str = "0", vastane: d
) )
embed.set_footer(text=S.RPS_UI["challenge_footer"]) embed.set_footer(text=S.RPS_UI["challenge_footer"])
challenge_view = RpsChallengeView(game) challenge_view = RpsChallengeView(game)
await interaction.response.send_message(embed=embed, view=challenge_view)
_active_games.add(interaction.user.id) _active_games.add(interaction.user.id)
game.server_message = await interaction.original_response() try:
await interaction.response.send_message(embed=embed, view=challenge_view)
game.server_message = await interaction.original_response()
except Exception:
_active_games.discard(interaction.user.id)
raise
return return
# ── vs Bot mode ────────────────────────────────────────────────────── # ── vs Bot mode ──────────────────────────────────────────────────────
@@ -3360,9 +3377,13 @@ async def cmd_blackjack(interaction: discord.Interaction, panus: str):
if interaction.user.id in _active_games: if interaction.user.id in _active_games:
await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True)
return return
# Claim the game slot before the bet await - a double-invoke in that window
# would otherwise pass the check twice and deduct two bets.
_active_games.add(interaction.user.id)
res = await economy.do_blackjack_bet(interaction.user.id, bet) res = await economy.do_blackjack_bet(interaction.user.id, bet)
if not res["ok"]: if not res["ok"]:
_active_games.discard(interaction.user.id)
if res["reason"] == "banned": if res["reason"] == "banned":
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
elif res["reason"] == "jailed": elif res["reason"] == "jailed":
@@ -3374,7 +3395,6 @@ async def cmd_blackjack(interaction: discord.Interaction, panus: str):
S.ERR["broke"].format(bal=_coin(_data["balance"])), ephemeral=True S.ERR["broke"].format(bal=_coin(_data["balance"])), ephemeral=True
) )
return return
_active_games.add(interaction.user.id)
deck = _bj_deck() deck = _bj_deck()
player_hand: list = [] player_hand: list = []
@@ -3790,6 +3810,7 @@ def _asyncio_exception_handler(loop: asyncio.AbstractEventLoop, context: dict) -
if __name__ == "__main__": if __name__ == "__main__":
if not config.DISCORD_TOKEN: if not config.DISCORD_TOKEN:
raise SystemExit("DISCORD_TOKEN pole seadistatud. Kopeeri .env.example failiks .env ja täida see.") raise SystemExit("DISCORD_TOKEN pole seadistatud. Kopeeri .env.example failiks .env ja täida see.")
economy.validate_config() # fail at boot on constants/strings drift, not at command time
async def _main() -> None: async def _main() -> None:
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()

View File

@@ -6,9 +6,12 @@ All public async functions are the single source of truth for mutations.
from __future__ import annotations from __future__ import annotations
import asyncio
import functools
import logging import logging
import math import math
import random import random
from contextlib import asynccontextmanager
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from typing import TypedDict from typing import TypedDict
@@ -320,6 +323,59 @@ def _default_user() -> UserData:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_log = logging.getLogger("tipiCOIN.economy") _log = logging.getLogger("tipiCOIN.economy")
# ---------------------------------------------------------------------------
# Per-user write serialization
#
# Every mutating function follows get_user() → mutate → _commit() with awaits
# in between, so two concurrent commands touching the same user would read the
# same stale record and the last PATCH would win (lost coins/cooldowns).
# The _serialized decorator holds a per-user asyncio.Lock for the whole call.
# ---------------------------------------------------------------------------
_user_locks: dict[int, asyncio.Lock] = {}
def _lock_for(user_id: int) -> asyncio.Lock:
lock = _user_locks.get(user_id)
if lock is None:
lock = _user_locks[user_id] = asyncio.Lock()
return lock
@asynccontextmanager
async def _locked(*user_ids: int | None):
"""Hold the lock of every given user (None entries ignored).
Locks are acquired in sorted-ID order so multi-user ops cannot deadlock."""
ids = sorted({uid for uid in user_ids if uid is not None})
locks = [_lock_for(uid) for uid in ids]
for lock in locks:
await lock.acquire()
try:
yield
finally:
for lock in reversed(locks):
lock.release()
def _serialized(*, house: bool = False, id_args: tuple[int, ...] = (0,)):
"""Serialize a mutating function on the user-id positional args in id_args.
A list arg contributes all of its ids (do_heist_resolve). house=True also
locks the house account - required by every function that may call
_credit_house, which itself takes no lock."""
def deco(fn):
@functools.wraps(fn)
async def wrapper(*args, **kwargs):
ids: list[int | None] = [HOUSE_ID] if house else []
for i in id_args:
val = args[i]
if isinstance(val, list):
ids.extend(val)
else:
ids.append(val)
async with _locked(*ids):
return await fn(*args, **kwargs)
return wrapper
return deco
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# House account (bot user) # House account (bot user)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -333,7 +389,9 @@ def set_house(user_id: int) -> None:
async def _credit_house(amount: int) -> None: async def _credit_house(amount: int) -> None:
"""Add `amount` coins to the house account. No-op if house not set.""" """Add `amount` coins to the house account. No-op if house not set.
Takes no lock itself: every caller is a @_serialized(house=True) function
that already holds the house lock."""
if HOUSE_ID is None or amount <= 0: if HOUSE_ID is None or amount <= 0:
return return
user = await get_user(HOUSE_ID) user = await get_user(HOUSE_ID)
@@ -349,6 +407,7 @@ async def get_heist_global_cd() -> float:
return float(house.get("heist_global_cd_until") or 0) return float(house.get("heist_global_cd_until") or 0)
@_serialized(house=True, id_args=())
async def set_heist_global_cd(until: float) -> None: async def set_heist_global_cd(until: float) -> None:
"""Persist heist global cooldown expiry to the house account in PocketBase.""" """Persist heist global cooldown expiry to the house account in PocketBase."""
if HOUSE_ID is None: if HOUSE_ID is None:
@@ -358,6 +417,7 @@ async def set_heist_global_cd(until: float) -> None:
await _commit(HOUSE_ID, house) await _commit(HOUSE_ID, house)
@_serialized()
async def do_spam_jail(user_id: int) -> None: async def do_spam_jail(user_id: int) -> None:
"""Jail a user for 30 minutes due to suspected automated command spam.""" """Jail a user for 30 minutes due to suspected automated command spam."""
user = await get_user(user_id) user = await get_user(user_id)
@@ -510,6 +570,7 @@ async def get_leaderboard_exp(top_n: int | None = 10) -> list[tuple[str, int, in
return entries if top_n is None else entries[:top_n] return entries if top_n is None else entries[:top_n]
@_serialized()
async def award_exp(user_id: int, amount: int) -> dict: async def award_exp(user_id: int, amount: int) -> dict:
"""Add EXP to a user. Returns old_level, new_level, total exp.""" """Add EXP to a user. Returns old_level, new_level, total exp."""
user = await get_user(user_id) user = await get_user(user_id)
@@ -570,6 +631,7 @@ async def _commit(user_id: int, user: UserData) -> None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# /daily # /daily
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@_serialized()
async def do_daily(user_id: int) -> dict: async def do_daily(user_id: int) -> dict:
user = await get_user(user_id) user = await get_user(user_id)
if user.get("eco_banned"): if user.get("eco_banned"):
@@ -641,6 +703,7 @@ async def do_daily(user_id: int) -> dict:
_WORK_JOBS = strings.WORK_JOBS _WORK_JOBS = strings.WORK_JOBS
@_serialized()
async def do_work(user_id: int) -> dict: async def do_work(user_id: int) -> dict:
user = await get_user(user_id) user = await get_user(user_id)
if user.get("eco_banned"): if user.get("eco_banned"):
@@ -688,6 +751,7 @@ _BEG_LINES = strings.BEG_LINES
_BEG_JAIL_LINES = strings.BEG_JAIL_LINES _BEG_JAIL_LINES = strings.BEG_JAIL_LINES
@_serialized()
async def do_beg(user_id: int) -> dict: async def do_beg(user_id: int) -> dict:
user = await get_user(user_id) user = await get_user(user_id)
if user.get("eco_banned"): if user.get("eco_banned"):
@@ -725,6 +789,7 @@ _CRIME_WIN = strings.CRIME_WIN
_CRIME_LOSE = strings.CRIME_LOSE _CRIME_LOSE = strings.CRIME_LOSE
@_serialized(house=True)
async def do_crime(user_id: int) -> dict: async def do_crime(user_id: int) -> dict:
user = await get_user(user_id) user = await get_user(user_id)
if user.get("eco_banned"): if user.get("eco_banned"):
@@ -778,6 +843,7 @@ async def do_crime(user_id: int) -> dict:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# /jailbreak (Monopoly-style dice rolls) # /jailbreak (Monopoly-style dice rolls)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@_serialized()
async def set_jailbreak_used(user_id: int) -> None: async def set_jailbreak_used(user_id: int) -> None:
"""Mark that the user has consumed their dice attempt for this jail sentence.""" """Mark that the user has consumed their dice attempt for this jail sentence."""
user = await get_user(user_id) user = await get_user(user_id)
@@ -785,6 +851,7 @@ async def set_jailbreak_used(user_id: int) -> None:
await _commit(user_id, user) await _commit(user_id, user)
@_serialized()
async def do_jail_free(user_id: int) -> dict: async def do_jail_free(user_id: int) -> dict:
"""Remove jail status after rolling doubles.""" """Remove jail status after rolling doubles."""
user = await get_user(user_id) user = await get_user(user_id)
@@ -797,6 +864,7 @@ async def do_jail_free(user_id: int) -> dict:
MIN_BAIL = 350 MIN_BAIL = 350
@_serialized()
async def do_bail(user_id: int) -> dict: async def do_bail(user_id: int) -> dict:
"""Charge bail fine after exhausting jailbreak rolls and free the user. """Charge bail fine after exhausting jailbreak rolls and free the user.
Fine = 20-30% of current balance, floored at 350. If balance < 350, stay jailed.""" Fine = 20-30% of current balance, floored at 350. If balance < 350, stay jailed."""
@@ -818,6 +886,7 @@ async def do_bail(user_id: int) -> dict:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# /rob # /rob
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@_serialized(house=True, id_args=(0, 1))
async def do_rob(robber_id: int, target_id: int) -> dict: async def do_rob(robber_id: int, target_id: int) -> dict:
robber = await get_user(robber_id) robber = await get_user(robber_id)
if robber.get("eco_banned"): if robber.get("eco_banned"):
@@ -893,6 +962,7 @@ async def do_rob(robber_id: int, target_id: int) -> dict:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# /roulette # /roulette
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@_serialized(house=True)
async def do_roulette(user_id: int, bet: int, colour: str) -> dict: async def do_roulette(user_id: int, bet: int, colour: str) -> dict:
user = await get_user(user_id) user = await get_user(user_id)
if user.get("eco_banned"): if user.get("eco_banned"):
@@ -932,6 +1002,7 @@ async def do_roulette(user_id: int, bet: int, colour: str) -> dict:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# /rps (bet resolution) # /rps (bet resolution)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@_serialized(house=True)
async def do_game_bet(user_id: int, bet: int, outcome: str) -> dict: async def do_game_bet(user_id: int, bet: int, outcome: str) -> dict:
"""Settle a simple win/tie/lose bet. outcome: 'win' | 'tie' | 'lose'.""" """Settle a simple win/tie/lose bet. outcome: 'win' | 'tie' | 'lose'."""
user = await get_user(user_id) user = await get_user(user_id)
@@ -986,6 +1057,7 @@ def _spin() -> str:
return random.choices(list(symbols), weights=list(weights), k=1)[0] return random.choices(list(symbols), weights=list(weights), k=1)[0]
@_serialized(house=True)
async def do_slots(user_id: int, bet: int) -> dict: async def do_slots(user_id: int, bet: int) -> dict:
user = await get_user(user_id) user = await get_user(user_id)
if user.get("eco_banned"): if user.get("eco_banned"):
@@ -1039,6 +1111,7 @@ async def do_slots(user_id: int, bet: int) -> dict:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# /give # /give
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@_serialized(id_args=(0, 1))
async def do_give(giver_id: int, receiver_id: int, amount: int) -> dict: async def do_give(giver_id: int, receiver_id: int, amount: int) -> dict:
giver = await get_user(giver_id) giver = await get_user(giver_id)
if giver.get("eco_banned"): if giver.get("eco_banned"):
@@ -1069,6 +1142,7 @@ async def do_give(giver_id: int, receiver_id: int, amount: int) -> dict:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# /buy # /buy
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@_serialized()
async def do_buy(user_id: int, item_id: str) -> dict: async def do_buy(user_id: int, item_id: str) -> dict:
if item_id not in SHOP: if item_id not in SHOP:
return {"ok": False, "reason": "not_found"} return {"ok": False, "reason": "not_found"}
@@ -1107,6 +1181,7 @@ async def do_buy(user_id: int, item_id: str) -> dict:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Admin actions # Admin actions
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@_serialized()
async def do_admin_coins(target_id: int, amount: int, admin_id: int, reason: str) -> dict: async def do_admin_coins(target_id: int, amount: int, admin_id: int, reason: str) -> dict:
"""Give (positive) or take (negative) coins from a user. Balance is floored at 0.""" """Give (positive) or take (negative) coins from a user. Balance is floored at 0."""
user = await get_user(target_id) user = await get_user(target_id)
@@ -1117,6 +1192,7 @@ async def do_admin_coins(target_id: int, amount: int, admin_id: int, reason: str
return {"ok": True, "balance": user["balance"], "change": amount} return {"ok": True, "balance": user["balance"], "change": amount}
@_serialized()
async def do_admin_jail(target_id: int, minutes: int, admin_id: int, reason: str) -> dict: async def do_admin_jail(target_id: int, minutes: int, admin_id: int, reason: str) -> dict:
"""Manually jail a user for `minutes` minutes.""" """Manually jail a user for `minutes` minutes."""
user = await get_user(target_id) user = await get_user(target_id)
@@ -1127,6 +1203,7 @@ async def do_admin_jail(target_id: int, minutes: int, admin_id: int, reason: str
return {"ok": True, "jailed_until": user["jailed_until"]} return {"ok": True, "jailed_until": user["jailed_until"]}
@_serialized()
async def do_admin_unjail(target_id: int, admin_id: int) -> dict: async def do_admin_unjail(target_id: int, admin_id: int) -> dict:
"""Remove jail from a user.""" """Remove jail from a user."""
user = await get_user(target_id) user = await get_user(target_id)
@@ -1137,6 +1214,7 @@ async def do_admin_unjail(target_id: int, admin_id: int) -> dict:
return {"ok": True} return {"ok": True}
@_serialized()
async def do_admin_ban(target_id: int, admin_id: int, reason: str) -> dict: async def do_admin_ban(target_id: int, admin_id: int, reason: str) -> dict:
"""Ban a user from all economy commands.""" """Ban a user from all economy commands."""
user = await get_user(target_id) user = await get_user(target_id)
@@ -1146,6 +1224,7 @@ async def do_admin_ban(target_id: int, admin_id: int, reason: str) -> dict:
return {"ok": True} return {"ok": True}
@_serialized()
async def do_admin_unban(target_id: int, admin_id: int) -> dict: async def do_admin_unban(target_id: int, admin_id: int) -> dict:
"""Lift an economy ban.""" """Lift an economy ban."""
user = await get_user(target_id) user = await get_user(target_id)
@@ -1155,6 +1234,7 @@ async def do_admin_unban(target_id: int, admin_id: int) -> dict:
return {"ok": True} return {"ok": True}
@_serialized()
async def do_admin_reset(target_id: int, admin_id: int) -> dict: async def do_admin_reset(target_id: int, admin_id: int) -> dict:
"""Wipe a user's economy data back to defaults.""" """Wipe a user's economy data back to defaults."""
user = await get_user(target_id) user = await get_user(target_id)
@@ -1174,6 +1254,7 @@ async def do_admin_inspect(target_id: int) -> dict:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# /reminders # /reminders
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@_serialized()
async def do_set_reminders(user_id: int, commands: list[str]) -> None: async def do_set_reminders(user_id: int, commands: list[str]) -> None:
"""Overwrite the user's reminder list with the given command names.""" """Overwrite the user's reminder list with the given command names."""
user = await get_user(user_id) user = await get_user(user_id)
@@ -1184,6 +1265,7 @@ async def do_set_reminders(user_id: int, commands: list[str]) -> None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# /blackjack # /blackjack
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@_serialized()
async def do_blackjack_bet(user_id: int, bet: int) -> dict: async def do_blackjack_bet(user_id: int, bet: int) -> dict:
"""Deduct the initial blackjack bet. Returns ok/fail.""" """Deduct the initial blackjack bet. Returns ok/fail."""
user = await get_user(user_id) user = await get_user(user_id)
@@ -1198,6 +1280,7 @@ async def do_blackjack_bet(user_id: int, bet: int) -> dict:
return {"ok": True, "balance": user["balance"]} return {"ok": True, "balance": user["balance"]}
@_serialized(house=True)
async def do_blackjack_payout(user_id: int, payout: int, total_invested: int = 0) -> dict: async def do_blackjack_payout(user_id: int, payout: int, total_invested: int = 0) -> dict:
"""Credit the net payout. House receives the difference when payout < total_invested.""" """Credit the net payout. House receives the difference when payout < total_invested."""
user = await get_user(user_id) user = await get_user(user_id)
@@ -1249,6 +1332,7 @@ async def do_heist_check(user_id: int) -> dict:
return {"ok": True} return {"ok": True}
@_serialized(house=True)
async def do_heist_resolve(user_ids: list[int], success: bool) -> dict: async def do_heist_resolve(user_ids: list[int], success: bool) -> dict:
"""Apply heist outcome to all participants. On win, steals from house.""" """Apply heist outcome to all participants. On win, steals from house."""
now = _now() now = _now()
@@ -1286,3 +1370,80 @@ async def do_heist_resolve(user_ids: list[int], success: bool) -> dict:
await _commit(uid, user) await _commit(uid, user)
return {"ok": True, "payout_each": payout_each, "success": success} return {"ok": True, "payout_each": payout_each, "success": success}
# ---------------------------------------------------------------------------
# Startup validation
# ---------------------------------------------------------------------------
def validate_config() -> None:
"""Cross-check the economy constants against strings.py.
Called once at startup (bot.py __main__). Raises RuntimeError listing every
mismatch, so a forgotten step of the DEV_NOTES.md checklists fails loudly
at boot instead of surfacing as a KeyError or stale text at command time.
"""
problems: list[str] = []
# Every cooldown command needs a cooldown message.
for cmd in COOLDOWNS:
if cmd not in strings.CD_MSG:
problems.append(f"COOLDOWNS[{cmd!r}] has no strings.CD_MSG entry")
# Reminder options must refer to real cooldown commands.
for cmd, _label, _desc in strings.REMINDER_OPTS:
if cmd not in COOLDOWNS:
problems.append(f"strings.REMINDER_OPTS entry {cmd!r} is not in COOLDOWNS")
# Shop items: exactly one tier, a description, a level gate if T2/T3.
tiered = [k for keys in SHOP_TIERS.values() for k in keys]
for key in SHOP:
if tiered.count(key) != 1:
problems.append(f"SHOP[{key!r}] must appear in exactly one SHOP_TIERS tier")
if key not in strings.ITEM_DESCRIPTIONS:
problems.append(f"SHOP[{key!r}] has no strings.ITEM_DESCRIPTIONS entry")
for key in tiered:
if key not in SHOP:
problems.append(f"SHOP_TIERS lists unknown item {key!r}")
for tier in (2, 3):
for key in SHOP_TIERS.get(tier, []):
if key not in SHOP_LEVEL_REQ:
problems.append(f"tier-{tier} item {key!r} is missing from SHOP_LEVEL_REQ")
for key in SHOP_LEVEL_REQ:
if key not in SHOP:
problems.append(f"SHOP_LEVEL_REQ lists unknown item {key!r}")
for key in strings.ITEM_DESCRIPTIONS:
if key not in SHOP:
problems.append(f"strings.ITEM_DESCRIPTIONS has entry for unknown item {key!r}")
# The /help shop page must show every item with its current cost and tier tag.
shop_help = strings.HELP_CATEGORIES["shop"]["fields"]
for tier, keys in SHOP_TIERS.items():
for key in keys:
if key not in SHOP:
continue # already reported above
item = SHOP[key]
title = next((t for t, _ in shop_help if item["emoji"] in t), None)
if title is None:
problems.append(
f"SHOP[{key!r}] has no HELP_CATEGORIES['shop'] entry (matched by emoji)"
)
continue
if f" {item['cost']} " not in title:
problems.append(
f"help shop entry for {key!r} shows a stale cost (SHOP says {item['cost']}): {title!r}"
)
if tier >= 2 and f"(T{tier})" not in title:
problems.append(f"help shop entry for {key!r} is missing its (T{tier}) tag")
# Slots: every reel symbol needs a triple multiplier, every outcome a string.
for sym, _weight in _SLOTS_SYMBOLS:
if sym not in _SLOTS_TRIPLE_MULT:
problems.append(f"slots symbol {sym} has no _SLOTS_TRIPLE_MULT entry")
for tier_name in ("jackpot", "triple", "pair", "miss"):
if tier_name not in strings.SLOTS_TIERS:
problems.append(f"strings.SLOTS_TIERS is missing {tier_name!r}")
if problems:
raise RuntimeError(
"Economy config validation failed:\n - " + "\n - ".join(problems)
)

View File

@@ -245,7 +245,7 @@ HELP_CATEGORIES: dict[str, dict] = {
("<:TipiMATT:1483387697132208128> XL hiirematt - 600 ⬡", "Kerjamise ooteaeg 5min → 3min."), ("<:TipiMATT:1483387697132208128> XL hiirematt - 600 ⬡", "Kerjamise ooteaeg 5min → 3min."),
("<:TipiKLAPID:1483387694083084349> Kõrvaklapid - 1200 ⬡", "Päevase boonuse ooteaeg 20h → 18h."), ("<:TipiKLAPID:1483387694083084349> Kõrvaklapid - 1200 ⬡", "Päevase boonuse ooteaeg 20h → 18h."),
("<:TipiPILET:1483004308353060904> LAN pilet (2025) - 1200 ⬡", "Päevane boonus on duubeldatud."), ("<:TipiPILET:1483004308353060904> LAN pilet (2025) - 1200 ⬡", "Päevane boonus on duubeldatud."),
("<:TipiVAC:1483004309510819860> Anticheat - 750 ⬡", "Röövimine sinu vastu ebaõnnestub. Pärast 2 kasutust pead ostma uue."), ("<:TipiVAC:1483004309510819860> Anticheat - 1000 ⬡", "Röövimine sinu vastu ebaõnnestub. Pärast 2 kasutust pead ostma uue."),
("<:TipiBULL:1483004310924300409> Red Bull - 800 ⬡", "30% tõenäosus, et teenid töötades 3x rohkem."), ("<:TipiBULL:1483004310924300409> Red Bull - 800 ⬡", "30% tõenäosus, et teenid töötades 3x rohkem."),
("<:TipiLAP:1483004307161874566> Botikoobas - 1500 ⬡", "RTX 5090 jooksutab botte 24/7. Päevane boonus genereerib 5% intressi sinu saldo pealt."), ("<:TipiLAP:1483004307161874566> Botikoobas - 1500 ⬡", "RTX 5090 jooksutab botte 24/7. Päevane boonus genereerib 5% intressi sinu saldo pealt."),
("<:TipiLAUD:1483387695576125440> Reguleeritav laud - 3500 ⬡ *(T2)*", "/work teenib 25% rohkem (stackib mängurihiirega)."), ("<:TipiLAUD:1483387695576125440> Reguleeritav laud - 3500 ⬡ *(T2)*", "/work teenib 25% rohkem (stackib mängurihiirega)."),