add per-user locks, fix game conditons, startup config validation
This commit is contained in:
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
|
||||
|
||||
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)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user