diff --git a/DEV_NOTES.md b/DEV_NOTES.md index 27470de..273fe67 100644 --- a/DEV_NOTES.md +++ b/DEV_NOTES.md @@ -20,7 +20,7 @@ Checklist - do all of these, in order: -1. **`economy.py`** - add the `do_` async function with cooldown check, logic, `_commit`, and `_txn` logging +1. **`economy.py`** - add the `do_` 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 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. @@ -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 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 diff --git a/bot.py b/bot.py index 3fccd1a..8aeeede 100644 --- a/bot.py +++ b/bot.py @@ -1774,15 +1774,6 @@ class HeistLobbyView(discord.ui.View): @discord.ui.button(label=S.HEIST_UI["btn_join"], style=discord.ButtonStyle.danger) 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) if not res["ok"]: 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 ) 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) _active_games.add(interaction.user.id) 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: await interaction.response.send_message(S.ERR["heist_active"], ephemeral=True) 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: await interaction.response.send_message(S.ERR["already_in_game"], 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 - + # Claim the lobby singleton and the game slot before the awaits below - + # concurrent /heist invocations would otherwise all pass the checks above + # and the last lobby would silently overwrite the others. view = HeistLobbyView(interaction.user) _active_heist = view _active_games.add(interaction.user.id) - await interaction.response.send_message(embed=view._lobby_embed(), view=view) - view.message = await interaction.original_response() + opened = False + 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"]) challenge_view = RpsChallengeView(game) - await interaction.response.send_message(embed=embed, view=challenge_view) _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 # ── vs Bot mode ────────────────────────────────────────────────────── @@ -3360,9 +3377,13 @@ async def cmd_blackjack(interaction: discord.Interaction, panus: str): if interaction.user.id in _active_games: await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) 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) if not res["ok"]: + _active_games.discard(interaction.user.id) if res["reason"] == "banned": await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) 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 ) return - _active_games.add(interaction.user.id) deck = _bj_deck() player_hand: list = [] @@ -3790,6 +3810,7 @@ def _asyncio_exception_handler(loop: asyncio.AbstractEventLoop, context: dict) - if __name__ == "__main__": if not config.DISCORD_TOKEN: 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: loop = asyncio.get_event_loop() diff --git a/economy.py b/economy.py index 48d1b33..acc0f9c 100644 --- a/economy.py +++ b/economy.py @@ -6,9 +6,12 @@ All public async functions are the single source of truth for mutations. from __future__ import annotations +import asyncio +import functools import logging import math import random +from contextlib import asynccontextmanager from datetime import date, datetime, timedelta, timezone from typing import TypedDict @@ -320,6 +323,59 @@ def _default_user() -> UserData: # --------------------------------------------------------------------------- _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) # --------------------------------------------------------------------------- @@ -333,7 +389,9 @@ def set_house(user_id: 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: return 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) +@_serialized(house=True, id_args=()) async def set_heist_global_cd(until: float) -> None: """Persist heist global cooldown expiry to the house account in PocketBase.""" if HOUSE_ID is None: @@ -358,6 +417,7 @@ async def set_heist_global_cd(until: float) -> None: await _commit(HOUSE_ID, house) +@_serialized() async def do_spam_jail(user_id: int) -> None: """Jail a user for 30 minutes due to suspected automated command spam.""" user = await get_user(user_id) @@ -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] +@_serialized() async def award_exp(user_id: int, amount: int) -> dict: """Add EXP to a user. Returns old_level, new_level, total exp.""" user = await get_user(user_id) @@ -570,6 +631,7 @@ async def _commit(user_id: int, user: UserData) -> None: # --------------------------------------------------------------------------- # /daily # --------------------------------------------------------------------------- +@_serialized() async def do_daily(user_id: int) -> dict: user = await get_user(user_id) if user.get("eco_banned"): @@ -641,6 +703,7 @@ async def do_daily(user_id: int) -> dict: _WORK_JOBS = strings.WORK_JOBS +@_serialized() async def do_work(user_id: int) -> dict: user = await get_user(user_id) if user.get("eco_banned"): @@ -688,6 +751,7 @@ _BEG_LINES = strings.BEG_LINES _BEG_JAIL_LINES = strings.BEG_JAIL_LINES +@_serialized() async def do_beg(user_id: int) -> dict: user = await get_user(user_id) if user.get("eco_banned"): @@ -725,6 +789,7 @@ _CRIME_WIN = strings.CRIME_WIN _CRIME_LOSE = strings.CRIME_LOSE +@_serialized(house=True) async def do_crime(user_id: int) -> dict: user = await get_user(user_id) if user.get("eco_banned"): @@ -778,6 +843,7 @@ async def do_crime(user_id: int) -> dict: # --------------------------------------------------------------------------- # /jailbreak (Monopoly-style dice rolls) # --------------------------------------------------------------------------- +@_serialized() async def set_jailbreak_used(user_id: int) -> None: """Mark that the user has consumed their dice attempt for this jail sentence.""" user = await get_user(user_id) @@ -785,6 +851,7 @@ async def set_jailbreak_used(user_id: int) -> None: await _commit(user_id, user) +@_serialized() async def do_jail_free(user_id: int) -> dict: """Remove jail status after rolling doubles.""" user = await get_user(user_id) @@ -797,6 +864,7 @@ async def do_jail_free(user_id: int) -> dict: MIN_BAIL = 350 +@_serialized() async def do_bail(user_id: int) -> dict: """Charge bail fine after exhausting jailbreak rolls and free the user. Fine = 20-30% of current balance, floored at 350. If balance < 350, stay jailed.""" @@ -818,6 +886,7 @@ async def do_bail(user_id: int) -> dict: # --------------------------------------------------------------------------- # /rob # --------------------------------------------------------------------------- +@_serialized(house=True, id_args=(0, 1)) async def do_rob(robber_id: int, target_id: int) -> dict: robber = await get_user(robber_id) if robber.get("eco_banned"): @@ -893,6 +962,7 @@ async def do_rob(robber_id: int, target_id: int) -> dict: # --------------------------------------------------------------------------- # /roulette # --------------------------------------------------------------------------- +@_serialized(house=True) async def do_roulette(user_id: int, bet: int, colour: str) -> dict: user = await get_user(user_id) if user.get("eco_banned"): @@ -932,6 +1002,7 @@ async def do_roulette(user_id: int, bet: int, colour: str) -> dict: # --------------------------------------------------------------------------- # /rps (bet resolution) # --------------------------------------------------------------------------- +@_serialized(house=True) async def do_game_bet(user_id: int, bet: int, outcome: str) -> dict: """Settle a simple win/tie/lose bet. outcome: 'win' | 'tie' | 'lose'.""" user = await get_user(user_id) @@ -986,6 +1057,7 @@ def _spin() -> str: return random.choices(list(symbols), weights=list(weights), k=1)[0] +@_serialized(house=True) async def do_slots(user_id: int, bet: int) -> dict: user = await get_user(user_id) if user.get("eco_banned"): @@ -1039,6 +1111,7 @@ async def do_slots(user_id: int, bet: int) -> dict: # --------------------------------------------------------------------------- # /give # --------------------------------------------------------------------------- +@_serialized(id_args=(0, 1)) async def do_give(giver_id: int, receiver_id: int, amount: int) -> dict: giver = await get_user(giver_id) if giver.get("eco_banned"): @@ -1069,6 +1142,7 @@ async def do_give(giver_id: int, receiver_id: int, amount: int) -> dict: # --------------------------------------------------------------------------- # /buy # --------------------------------------------------------------------------- +@_serialized() async def do_buy(user_id: int, item_id: str) -> dict: if item_id not in SHOP: return {"ok": False, "reason": "not_found"} @@ -1107,6 +1181,7 @@ async def do_buy(user_id: int, item_id: str) -> dict: # --------------------------------------------------------------------------- # Admin actions # --------------------------------------------------------------------------- +@_serialized() async def do_admin_coins(target_id: int, amount: int, admin_id: int, reason: str) -> dict: """Give (positive) or take (negative) coins from a user. Balance is floored at 0.""" user = await get_user(target_id) @@ -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} +@_serialized() async def do_admin_jail(target_id: int, minutes: int, admin_id: int, reason: str) -> dict: """Manually jail a user for `minutes` minutes.""" user = await get_user(target_id) @@ -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"]} +@_serialized() async def do_admin_unjail(target_id: int, admin_id: int) -> dict: """Remove jail from a user.""" 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} +@_serialized() async def do_admin_ban(target_id: int, admin_id: int, reason: str) -> dict: """Ban a user from all economy commands.""" user = await get_user(target_id) @@ -1146,6 +1224,7 @@ async def do_admin_ban(target_id: int, admin_id: int, reason: str) -> dict: return {"ok": True} +@_serialized() async def do_admin_unban(target_id: int, admin_id: int) -> dict: """Lift an economy ban.""" 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} +@_serialized() async def do_admin_reset(target_id: int, admin_id: int) -> dict: """Wipe a user's economy data back to defaults.""" user = await get_user(target_id) @@ -1174,6 +1254,7 @@ async def do_admin_inspect(target_id: int) -> dict: # --------------------------------------------------------------------------- # /reminders # --------------------------------------------------------------------------- +@_serialized() async def do_set_reminders(user_id: int, commands: list[str]) -> None: """Overwrite the user's reminder list with the given command names.""" user = await get_user(user_id) @@ -1184,6 +1265,7 @@ async def do_set_reminders(user_id: int, commands: list[str]) -> None: # --------------------------------------------------------------------------- # /blackjack # --------------------------------------------------------------------------- +@_serialized() async def do_blackjack_bet(user_id: int, bet: int) -> dict: """Deduct the initial blackjack bet. Returns ok/fail.""" 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"]} +@_serialized(house=True) async def do_blackjack_payout(user_id: int, payout: int, total_invested: int = 0) -> dict: """Credit the net payout. House receives the difference when payout < total_invested.""" user = await get_user(user_id) @@ -1249,6 +1332,7 @@ async def do_heist_check(user_id: int) -> dict: return {"ok": True} +@_serialized(house=True) async def do_heist_resolve(user_ids: list[int], success: bool) -> dict: """Apply heist outcome to all participants. On win, steals from house.""" now = _now() @@ -1286,3 +1370,80 @@ async def do_heist_resolve(user_ids: list[int], success: bool) -> dict: await _commit(uid, user) 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) + ) diff --git a/strings.py b/strings.py index 85babe4..1e3cdaa 100644 --- a/strings.py +++ b/strings.py @@ -245,7 +245,7 @@ HELP_CATEGORIES: dict[str, dict] = { ("<:TipiMATT:1483387697132208128> XL hiirematt - 600 ⬡", "Kerjamise ooteaeg 5min → 3min."), ("<:TipiKLAPID:1483387694083084349> Kõrvaklapid - 1200 ⬡", "Päevase boonuse ooteaeg 20h → 18h."), ("<: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."), ("<: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)."),