add per-user locks, fix game conditons, startup config validation
This commit is contained in:
@@ -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
83
bot.py
@@ -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()
|
||||||
|
|||||||
163
economy.py
163
economy.py
@@ -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)
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)."),
|
||||||
|
|||||||
Reference in New Issue
Block a user