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

@@ -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)
)