Added tests, fix README.
This commit is contained in:
254
core/economy.py
254
core/economy.py
@@ -7,6 +7,7 @@ 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
|
||||
@@ -465,25 +466,81 @@ def _default_user() -> UserData:
|
||||
# ---------------------------------------------------------------------------
|
||||
_log = logging.getLogger("tipiCOIN.economy")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-user write locks
|
||||
# ---------------------------------------------------------------------------
|
||||
# Every mutation is a read-modify-write cycle (get_user → mutate → _commit);
|
||||
# without serialization, two concurrent commands for the same user overwrite
|
||||
# each other's commit. Locking rules that keep this deadlock-free:
|
||||
# - a decorated function must never call another decorated function
|
||||
# - house balance changes go through _credit_house (an atomic PocketBase
|
||||
# increment, no lock), so they are safe while holding user locks
|
||||
_user_locks: dict[int, asyncio.Lock] = {}
|
||||
|
||||
|
||||
def _user_lock(user_id: int) -> asyncio.Lock:
|
||||
lock = _user_locks.get(user_id)
|
||||
if lock is None:
|
||||
lock = _user_locks[user_id] = asyncio.Lock()
|
||||
return lock
|
||||
|
||||
|
||||
def _locked_by(*arg_positions: int):
|
||||
"""Serialize the decorated function per user id found at the given
|
||||
positional-argument indices. Multiple ids are acquired in sorted order so
|
||||
two-user functions (do_give, do_rob) cannot deadlock each other."""
|
||||
def decorator(fn):
|
||||
@functools.wraps(fn)
|
||||
async def wrapper(*args, **kwargs):
|
||||
locks = [_user_lock(uid) for uid in sorted({args[pos] for pos in arg_positions})]
|
||||
for lock in locks:
|
||||
await lock.acquire()
|
||||
try:
|
||||
return await fn(*args, **kwargs)
|
||||
finally:
|
||||
for lock in reversed(locks):
|
||||
lock.release()
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# House account (bot user)
|
||||
# ---------------------------------------------------------------------------
|
||||
HOUSE_ID: int | None = None
|
||||
_house_pb_id: str | None = None
|
||||
|
||||
|
||||
def set_house(user_id: int) -> None:
|
||||
"""Register the bot's Discord user ID as the house account."""
|
||||
global HOUSE_ID
|
||||
global HOUSE_ID, _house_pb_id
|
||||
if HOUSE_ID != user_id:
|
||||
_house_pb_id = None
|
||||
HOUSE_ID = user_id
|
||||
|
||||
|
||||
async def _house_record_id() -> str | None:
|
||||
"""PocketBase record id of the house account (cached; creates the record on first use)."""
|
||||
global _house_pb_id
|
||||
if HOUSE_ID is None:
|
||||
return None
|
||||
if _house_pb_id is None:
|
||||
house = await get_user(HOUSE_ID)
|
||||
_house_pb_id = house.get("_pb_id") # type: ignore[typeddict-item]
|
||||
return _house_pb_id
|
||||
|
||||
|
||||
async def _credit_house(amount: int) -> None:
|
||||
"""Add `amount` coins to the house account. No-op if house not set."""
|
||||
if HOUSE_ID is None or amount <= 0:
|
||||
"""Add `amount` coins to the house via an atomic PocketBase increment.
|
||||
|
||||
Deliberately lock-free: callers hold per-user locks, so this must never
|
||||
acquire one itself (see the locking rules above)."""
|
||||
if amount <= 0:
|
||||
return
|
||||
user = await get_user(HOUSE_ID)
|
||||
user["balance"] = user.get("balance", 0) + amount
|
||||
await _commit(HOUSE_ID, user)
|
||||
record_id = await _house_record_id()
|
||||
if record_id is None:
|
||||
return
|
||||
await pb_client.update_record(record_id, {"balance+": amount})
|
||||
|
||||
|
||||
async def get_heist_global_cd() -> float:
|
||||
@@ -496,13 +553,13 @@ async def get_heist_global_cd() -> float:
|
||||
|
||||
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:
|
||||
record_id = await _house_record_id()
|
||||
if record_id is None:
|
||||
return
|
||||
house = await get_user(HOUSE_ID)
|
||||
house["heist_global_cd_until"] = until
|
||||
await _commit(HOUSE_ID, house)
|
||||
await pb_client.update_record(record_id, {"heist_global_cd_until": until})
|
||||
|
||||
|
||||
@_locked_by(0)
|
||||
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)
|
||||
@@ -643,6 +700,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]
|
||||
|
||||
|
||||
@_locked_by(0)
|
||||
async def award_exp(user_id: int, amount: int) -> dict:
|
||||
"""Add EXP to a user. Applies prestige exp_mult. Returns old_level, new_level, total exp."""
|
||||
user = await get_user(user_id)
|
||||
@@ -690,17 +748,20 @@ async def do_season_reset(top_n: int = 10) -> list[tuple[str, int, int]]:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal write helper
|
||||
# ---------------------------------------------------------------------------
|
||||
async def _commit(user_id: int, user: UserData) -> None:
|
||||
async def _commit(user_id: int, user: UserData) -> dict | None:
|
||||
"""Persist the full user record. Returns the record as PocketBase stored it
|
||||
(fields absent from the collection schema are silently dropped by PB)."""
|
||||
record_id = user.get("_pb_id") # type: ignore[typeddict-item]
|
||||
clean = {k: v for k, v in user.items() if k != "_pb_id"}
|
||||
clean["user_id"] = str(user_id)
|
||||
try:
|
||||
if record_id:
|
||||
await pb_client.update_record(record_id, clean)
|
||||
return await pb_client.update_record(record_id, clean)
|
||||
else:
|
||||
_log.warning("_commit for user %s had no _pb_id; creating new record", user_id)
|
||||
created = await pb_client.create_record(clean)
|
||||
user["_pb_id"] = created["id"] # type: ignore[typeddict-unknown-key]
|
||||
return created
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError, RuntimeError) as exc:
|
||||
_log.error("_commit failed for user %s: %s", user_id, exc)
|
||||
raise DatabaseError(f"Failed to persist user {user_id}: {exc}") from exc
|
||||
@@ -713,7 +774,7 @@ async def _commit(user_id: int, user: UserData) -> None:
|
||||
# Progress = (current counter value) - (snapshot taken when the quest was rolled).
|
||||
# Reset is lazy & per-user: the active set is regenerated the first time a user
|
||||
# interacts after the day / ISO-week rolls over (mirrors the streak-date logic).
|
||||
# Rotation is seeded by the period key, so every player gets the same set.
|
||||
# Rotation is seeded by user id + period key, so each player gets their own set.
|
||||
class QuestDef(TypedDict):
|
||||
stat: str # UserData counter field the quest tracks
|
||||
goal: int
|
||||
@@ -758,9 +819,10 @@ def _pick_quests(pool: dict[str, QuestDef], count: int, seed: str) -> list[str]:
|
||||
|
||||
|
||||
def _new_quest_block(
|
||||
user: UserData, pool: dict[str, QuestDef], count: int, period_val: str, period_field: str
|
||||
user: UserData, user_id: int, pool: dict[str, QuestDef], count: int,
|
||||
period_val: str, period_field: str
|
||||
) -> dict:
|
||||
chosen = _pick_quests(pool, count, f"{period_field}:{period_val}")
|
||||
chosen = _pick_quests(pool, count, f"{user_id}:{period_field}:{period_val}")
|
||||
return {
|
||||
period_field: period_val,
|
||||
"quests": {
|
||||
@@ -770,16 +832,16 @@ def _new_quest_block(
|
||||
}
|
||||
|
||||
|
||||
def _ensure_quests(user: UserData) -> bool:
|
||||
def _ensure_quests(user: UserData, user_id: int) -> bool:
|
||||
"""Roll fresh daily/weekly quest sets if their period elapsed.
|
||||
Mutates `user` in place; returns True if anything changed (caller commits)."""
|
||||
changed = False
|
||||
day_key, week_key = _period_keys()
|
||||
if (user.get("quest_daily") or {}).get("date") != day_key:
|
||||
user["quest_daily"] = _new_quest_block(user, QUESTS_DAILY, DAILY_QUEST_COUNT, day_key, "date")
|
||||
user["quest_daily"] = _new_quest_block(user, user_id, QUESTS_DAILY, DAILY_QUEST_COUNT, day_key, "date")
|
||||
changed = True
|
||||
if (user.get("quest_weekly") or {}).get("week") != week_key:
|
||||
user["quest_weekly"] = _new_quest_block(user, QUESTS_WEEKLY, WEEKLY_QUEST_COUNT, week_key, "week")
|
||||
user["quest_weekly"] = _new_quest_block(user, user_id, QUESTS_WEEKLY, WEEKLY_QUEST_COUNT, week_key, "week")
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
@@ -808,20 +870,27 @@ def _quest_view(user: UserData) -> dict:
|
||||
}
|
||||
|
||||
|
||||
@_locked_by(0)
|
||||
async def get_quests(user_id: int) -> dict:
|
||||
"""Return the user's active quests, rolling new sets if the period elapsed."""
|
||||
user = await get_user(user_id)
|
||||
if _ensure_quests(user):
|
||||
await _commit(user_id, user)
|
||||
if _ensure_quests(user, user_id):
|
||||
saved = await _commit(user_id, user)
|
||||
if saved is not None and "quest_daily" not in saved:
|
||||
_log.warning(
|
||||
"PocketBase collection has no quest fields - quest state is not "
|
||||
"persisted and progress will stay at 0. Run scripts/add_quest_fields.py."
|
||||
)
|
||||
return _quest_view(user)
|
||||
|
||||
|
||||
@_locked_by(0)
|
||||
async def claim_quests(user_id: int) -> dict:
|
||||
"""Grant coins for every completed-but-unclaimed quest and mark them claimed.
|
||||
Coins (with prestige coin_mult) are paid here; EXP is returned raw for the
|
||||
caller to award via the shared award_exp path (keeps level-up notices)."""
|
||||
user = await get_user(user_id)
|
||||
_ensure_quests(user)
|
||||
_ensure_quests(user, user_id)
|
||||
coin_mult, _ = _prestige_mult(user)
|
||||
total_coins = total_exp = claimed = 0
|
||||
for pool, block in (
|
||||
@@ -850,6 +919,7 @@ async def claim_quests(user_id: int) -> dict:
|
||||
# ---------------------------------------------------------------------------
|
||||
# /daily
|
||||
# ---------------------------------------------------------------------------
|
||||
@_locked_by(0)
|
||||
async def do_daily(user_id: int) -> dict:
|
||||
try:
|
||||
user = await get_user(user_id)
|
||||
@@ -929,6 +999,7 @@ async def do_daily(user_id: int) -> dict:
|
||||
_WORK_JOBS = strings.WORK_JOBS
|
||||
|
||||
|
||||
@_locked_by(0)
|
||||
async def do_work(user_id: int) -> dict:
|
||||
try:
|
||||
user = await get_user(user_id)
|
||||
@@ -982,6 +1053,7 @@ _BEG_LINES = strings.BEG_LINES
|
||||
_BEG_JAIL_LINES = strings.BEG_JAIL_LINES
|
||||
|
||||
|
||||
@_locked_by(0)
|
||||
async def do_beg(user_id: int) -> dict:
|
||||
try:
|
||||
user = await get_user(user_id)
|
||||
@@ -1019,6 +1091,7 @@ async def do_beg(user_id: int) -> dict:
|
||||
# ---------------------------------------------------------------------------
|
||||
# /fish
|
||||
# ---------------------------------------------------------------------------
|
||||
@_locked_by(0)
|
||||
async def do_fish_start(user_id: int) -> dict:
|
||||
"""Check cooldown + jail, set cooldown. Call before starting the fishing minigame."""
|
||||
try:
|
||||
@@ -1039,6 +1112,7 @@ async def do_fish_start(user_id: int) -> dict:
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@_locked_by(0)
|
||||
async def do_fish_resolve(user_id: int, fish_id: str, weight: int) -> dict:
|
||||
"""Add catch to inventory + update fish_book. Returns catch info incl. pre-calculated value."""
|
||||
user = await get_user(user_id)
|
||||
@@ -1084,6 +1158,7 @@ async def do_fish_resolve(user_id: int, fish_id: str, weight: int) -> dict:
|
||||
}
|
||||
|
||||
|
||||
@_locked_by(0)
|
||||
async def do_fish_sell(user_id: int, indices: list[int] | None = None) -> dict:
|
||||
"""Sell fish from inventory. indices=None sells all. Returns coins earned."""
|
||||
user = await get_user(user_id)
|
||||
@@ -1144,6 +1219,7 @@ async def do_fishbook(user_id: int) -> dict:
|
||||
# ---------------------------------------------------------------------------
|
||||
# /prestige
|
||||
# ---------------------------------------------------------------------------
|
||||
@_locked_by(0)
|
||||
async def do_prestige(user_id: int) -> dict:
|
||||
"""Prestige: requires level 30, earns PP, resets balance/exp/items/cooldowns."""
|
||||
try:
|
||||
@@ -1192,6 +1268,7 @@ async def do_prestige(user_id: int) -> dict:
|
||||
}
|
||||
|
||||
|
||||
@_locked_by(0)
|
||||
async def do_prestige_buy(user_id: int, upgrade_id: str) -> dict:
|
||||
"""Spend PP to buy a prestige upgrade level."""
|
||||
if upgrade_id not in PRESTIGE_SHOP:
|
||||
@@ -1293,6 +1370,7 @@ _CRIME_WIN = strings.CRIME_WIN
|
||||
_CRIME_LOSE = strings.CRIME_LOSE
|
||||
|
||||
|
||||
@_locked_by(0)
|
||||
async def do_crime(user_id: int) -> dict:
|
||||
try:
|
||||
user = await get_user(user_id)
|
||||
@@ -1349,6 +1427,7 @@ async def do_crime(user_id: int) -> dict:
|
||||
# ---------------------------------------------------------------------------
|
||||
# /jailbreak (Monopoly-style dice rolls)
|
||||
# ---------------------------------------------------------------------------
|
||||
@_locked_by(0)
|
||||
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)
|
||||
@@ -1356,6 +1435,7 @@ async def set_jailbreak_used(user_id: int) -> None:
|
||||
await _commit(user_id, user)
|
||||
|
||||
|
||||
@_locked_by(0)
|
||||
async def do_jail_free(user_id: int) -> dict:
|
||||
"""Remove jail status after rolling doubles."""
|
||||
user = await get_user(user_id)
|
||||
@@ -1368,6 +1448,7 @@ async def do_jail_free(user_id: int) -> dict:
|
||||
|
||||
MIN_BAIL = 350
|
||||
|
||||
@_locked_by(0)
|
||||
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."""
|
||||
@@ -1389,6 +1470,7 @@ async def do_bail(user_id: int) -> dict:
|
||||
# ---------------------------------------------------------------------------
|
||||
# /rob
|
||||
# ---------------------------------------------------------------------------
|
||||
@_locked_by(0, 1)
|
||||
async def do_rob(robber_id: int, target_id: int) -> dict:
|
||||
try:
|
||||
robber = await get_user(robber_id)
|
||||
@@ -1490,6 +1572,7 @@ async def do_rob(robber_id: int, target_id: int) -> dict:
|
||||
# ---------------------------------------------------------------------------
|
||||
# /roulette
|
||||
# ---------------------------------------------------------------------------
|
||||
@_locked_by(0)
|
||||
async def do_roulette(user_id: int, bet: int, colour: str) -> dict:
|
||||
try:
|
||||
user = await get_user(user_id)
|
||||
@@ -1532,6 +1615,7 @@ async def do_roulette(user_id: int, bet: int, colour: str) -> dict:
|
||||
# ---------------------------------------------------------------------------
|
||||
# /rps (bet resolution)
|
||||
# ---------------------------------------------------------------------------
|
||||
@_locked_by(0)
|
||||
async def do_game_bet(user_id: int, bet: int, outcome: str) -> dict:
|
||||
"""Settle a simple win/tie/lose bet. outcome: 'win' | 'tie' | 'lose'."""
|
||||
try:
|
||||
@@ -1565,6 +1649,7 @@ async def do_game_bet(user_id: int, bet: int, outcome: str) -> dict:
|
||||
# ---------------------------------------------------------------------------
|
||||
# /rps PvP escrow (deposit/payout/refund)
|
||||
# ---------------------------------------------------------------------------
|
||||
@_locked_by(0)
|
||||
async def do_rps_pvp_deposit(user_id: int, bet: int) -> dict:
|
||||
"""Hold `bet` coins from a player as escrow for a PvP RPS duel."""
|
||||
try:
|
||||
@@ -1587,6 +1672,7 @@ async def do_rps_pvp_deposit(user_id: int, bet: int) -> dict:
|
||||
return {"ok": True, "balance": user["balance"]}
|
||||
|
||||
|
||||
@_locked_by(0)
|
||||
async def do_rps_pvp_payout(winner_id: int, bet: int) -> dict:
|
||||
"""Credit the duel winner with 2*bet (their stake back + opponent's)."""
|
||||
try:
|
||||
@@ -1606,6 +1692,7 @@ async def do_rps_pvp_payout(winner_id: int, bet: int) -> dict:
|
||||
return {"ok": True, "balance": user["balance"]}
|
||||
|
||||
|
||||
@_locked_by(0)
|
||||
async def do_rps_pvp_refund(user_id: int, bet: int) -> dict:
|
||||
"""Refund a previously escrowed bet (tie / timeout / cancel)."""
|
||||
try:
|
||||
@@ -1649,6 +1736,7 @@ def _spin() -> str:
|
||||
return random.choices(list(symbols), weights=list(weights), k=1)[0]
|
||||
|
||||
|
||||
@_locked_by(0)
|
||||
async def do_slots(user_id: int, bet: int) -> dict:
|
||||
try:
|
||||
user = await get_user(user_id)
|
||||
@@ -1705,6 +1793,7 @@ async def do_slots(user_id: int, bet: int) -> dict:
|
||||
# ---------------------------------------------------------------------------
|
||||
# /give
|
||||
# ---------------------------------------------------------------------------
|
||||
@_locked_by(0, 1)
|
||||
async def do_give(giver_id: int, receiver_id: int, amount: int) -> dict:
|
||||
try:
|
||||
giver = await get_user(giver_id)
|
||||
@@ -1756,6 +1845,7 @@ async def do_give(giver_id: int, receiver_id: int, amount: int) -> dict:
|
||||
# ---------------------------------------------------------------------------
|
||||
# /buy
|
||||
# ---------------------------------------------------------------------------
|
||||
@_locked_by(0)
|
||||
async def do_buy(user_id: int, item_id: str) -> dict:
|
||||
if item_id not in SHOP:
|
||||
return {"ok": False, "reason": "not_found"}
|
||||
@@ -1797,6 +1887,7 @@ async def do_buy(user_id: int, item_id: str) -> dict:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin actions
|
||||
# ---------------------------------------------------------------------------
|
||||
@_locked_by(0)
|
||||
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)
|
||||
@@ -1807,6 +1898,7 @@ async def do_admin_coins(target_id: int, amount: int, admin_id: int, reason: str
|
||||
return {"ok": True, "balance": user["balance"], "change": amount}
|
||||
|
||||
|
||||
@_locked_by(0)
|
||||
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)
|
||||
@@ -1817,6 +1909,7 @@ async def do_admin_jail(target_id: int, minutes: int, admin_id: int, reason: str
|
||||
return {"ok": True, "jailed_until": user["jailed_until"]}
|
||||
|
||||
|
||||
@_locked_by(0)
|
||||
async def do_admin_unjail(target_id: int, admin_id: int) -> dict:
|
||||
"""Remove jail from a user."""
|
||||
user = await get_user(target_id)
|
||||
@@ -1827,6 +1920,7 @@ async def do_admin_unjail(target_id: int, admin_id: int) -> dict:
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@_locked_by(0)
|
||||
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)
|
||||
@@ -1836,6 +1930,7 @@ async def do_admin_ban(target_id: int, admin_id: int, reason: str) -> dict:
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@_locked_by(0)
|
||||
async def do_admin_unban(target_id: int, admin_id: int) -> dict:
|
||||
"""Lift an economy ban."""
|
||||
user = await get_user(target_id)
|
||||
@@ -1845,6 +1940,7 @@ async def do_admin_unban(target_id: int, admin_id: int) -> dict:
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@_locked_by(0)
|
||||
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)
|
||||
@@ -1861,6 +1957,7 @@ async def do_admin_inspect(target_id: int) -> dict:
|
||||
return {"ok": True, "data": dict(user)}
|
||||
|
||||
|
||||
@_locked_by(0)
|
||||
async def do_admin_exp(target_id: int, amount: int, admin_id: int, reason: str) -> dict:
|
||||
"""Give (positive) or take (negative) EXP from a user. EXP is floored at 0."""
|
||||
user = await get_user(target_id)
|
||||
@@ -1882,6 +1979,7 @@ async def do_admin_exp(target_id: int, amount: int, admin_id: int, reason: str)
|
||||
}
|
||||
|
||||
|
||||
@_locked_by(0)
|
||||
async def do_admin_item(target_id: int, item_id: str, action: str, admin_id: int) -> dict:
|
||||
"""Give or remove an item. action='give'|'remove'. Returns ok/reason."""
|
||||
if item_id not in SHOP:
|
||||
@@ -1915,6 +2013,7 @@ async def do_admin_item(target_id: int, item_id: str, action: str, admin_id: int
|
||||
# ---------------------------------------------------------------------------
|
||||
# /reminders
|
||||
# ---------------------------------------------------------------------------
|
||||
@_locked_by(0)
|
||||
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)
|
||||
@@ -1925,6 +2024,7 @@ async def do_set_reminders(user_id: int, commands: list[str]) -> None:
|
||||
# ---------------------------------------------------------------------------
|
||||
# /blackjack
|
||||
# ---------------------------------------------------------------------------
|
||||
@_locked_by(0)
|
||||
async def do_blackjack_bet(user_id: int, bet: int) -> dict:
|
||||
"""Deduct the initial blackjack bet. Returns ok/fail."""
|
||||
try:
|
||||
@@ -1942,6 +2042,7 @@ async def do_blackjack_bet(user_id: int, bet: int) -> dict:
|
||||
return {"ok": True, "balance": user["balance"]}
|
||||
|
||||
|
||||
@_locked_by(0)
|
||||
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)
|
||||
@@ -2009,57 +2110,58 @@ async def do_heist_resolve(user_ids: list[int], success: bool) -> dict:
|
||||
if success and HOUSE_ID is not None:
|
||||
try:
|
||||
house = await get_user(HOUSE_ID)
|
||||
pct = random.uniform(0.20, 0.55)
|
||||
total = max(300, int(house["balance"] * pct))
|
||||
payout_each = total // len(user_ids)
|
||||
# Atomic decrement (capped at the balance we read) instead of a full
|
||||
# record commit, so concurrent _credit_house increments aren't lost.
|
||||
debit = min(total, house["balance"])
|
||||
if debit > 0:
|
||||
await pb_client.update_record(house["_pb_id"], {"balance-": debit}) # type: ignore[typeddict-item]
|
||||
except DatabaseError:
|
||||
return {"ok": False, "reason": "db_error"}
|
||||
pct = random.uniform(0.20, 0.55)
|
||||
total = max(300, int(house["balance"] * pct))
|
||||
payout_each = total // len(user_ids)
|
||||
house["balance"] = max(0, house["balance"] - total)
|
||||
try:
|
||||
await _commit(HOUSE_ID, house)
|
||||
except DatabaseError:
|
||||
return {"ok": False, "reason": "db_error"}
|
||||
_txn("HEIST_HOUSE", change=f"-{total}", house_bal=house["balance"])
|
||||
_txn("HEIST_HOUSE", change=f"-{debit}", house_bal=house["balance"] - debit)
|
||||
|
||||
for uid in user_ids:
|
||||
try:
|
||||
user = await get_user(uid)
|
||||
except DatabaseError:
|
||||
failed_users.append(uid)
|
||||
if success and payout_each > 0:
|
||||
await _refund_house_safe(payout_each, "heist_win_compensate", uid)
|
||||
continue
|
||||
user["last_heist"] = now.isoformat()
|
||||
user["heists_joined"] = user.get("heists_joined", 0) + 1
|
||||
fine_credited = False
|
||||
if success:
|
||||
user["balance"] += payout_each
|
||||
user["heists_won"] = user.get("heists_won", 0) + 1
|
||||
user["lifetime_earned"] = user.get("lifetime_earned", 0) + payout_each
|
||||
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
|
||||
_txn("HEIST_WIN", user=uid, change=f"+{payout_each}", bal=user["balance"])
|
||||
else:
|
||||
fine = max(150, min(1000, int(user["balance"] * 0.15)))
|
||||
user["balance"] = max(0, user["balance"] - fine)
|
||||
user["jailed_until"] = (now + HEIST_JAIL).isoformat()
|
||||
user["jailbreak_used"] = False
|
||||
user["times_jailed"] = user.get("times_jailed", 0) + 1
|
||||
user["lifetime_lost"] = user.get("lifetime_lost", 0) + fine
|
||||
_txn("HEIST_FAIL", user=uid, fine=f"-{fine}", jailed_until=user["jailed_until"], bal=user["balance"])
|
||||
if fine > 0:
|
||||
try:
|
||||
await _credit_house(fine)
|
||||
fine_credited = True
|
||||
except DatabaseError:
|
||||
pass # user commit will still be attempted; if both fail, no economy effect
|
||||
try:
|
||||
await _commit(uid, user)
|
||||
except DatabaseError:
|
||||
failed_users.append(uid)
|
||||
if success and payout_each > 0:
|
||||
await _refund_house_safe(payout_each, "heist_win_compensate", uid)
|
||||
elif not success and fine_credited:
|
||||
await _refund_user_safe(HOUSE_ID, fine if 'fine' in locals() else 0, "heist_fail_compensate", uid)
|
||||
async with _user_lock(uid):
|
||||
try:
|
||||
user = await get_user(uid)
|
||||
except DatabaseError:
|
||||
failed_users.append(uid)
|
||||
if success and payout_each > 0:
|
||||
await _refund_house_safe(payout_each, "heist_win_compensate", uid)
|
||||
continue
|
||||
user["last_heist"] = now.isoformat()
|
||||
user["heists_joined"] = user.get("heists_joined", 0) + 1
|
||||
fine_credited = False
|
||||
if success:
|
||||
user["balance"] += payout_each
|
||||
user["heists_won"] = user.get("heists_won", 0) + 1
|
||||
user["lifetime_earned"] = user.get("lifetime_earned", 0) + payout_each
|
||||
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
|
||||
_txn("HEIST_WIN", user=uid, change=f"+{payout_each}", bal=user["balance"])
|
||||
else:
|
||||
fine = max(150, min(1000, int(user["balance"] * 0.15)))
|
||||
user["balance"] = max(0, user["balance"] - fine)
|
||||
user["jailed_until"] = (now + HEIST_JAIL).isoformat()
|
||||
user["jailbreak_used"] = False
|
||||
user["times_jailed"] = user.get("times_jailed", 0) + 1
|
||||
user["lifetime_lost"] = user.get("lifetime_lost", 0) + fine
|
||||
_txn("HEIST_FAIL", user=uid, fine=f"-{fine}", jailed_until=user["jailed_until"], bal=user["balance"])
|
||||
if fine > 0:
|
||||
try:
|
||||
await _credit_house(fine)
|
||||
fine_credited = True
|
||||
except DatabaseError:
|
||||
pass # user commit will still be attempted; if both fail, no economy effect
|
||||
try:
|
||||
await _commit(uid, user)
|
||||
except DatabaseError:
|
||||
failed_users.append(uid)
|
||||
if success and payout_each > 0:
|
||||
await _refund_house_safe(payout_each, "heist_win_compensate", uid)
|
||||
elif not success and fine_credited:
|
||||
await _refund_user_safe(HOUSE_ID, fine if 'fine' in locals() else 0, "heist_fail_compensate", uid)
|
||||
|
||||
return {"ok": True, "payout_each": payout_each, "success": success, "failed_users": failed_users}
|
||||
|
||||
@@ -2069,9 +2171,7 @@ async def _refund_house_safe(amount: int, context: str, related_uid: int) -> Non
|
||||
if HOUSE_ID is None or amount <= 0:
|
||||
return
|
||||
try:
|
||||
house = await get_user(HOUSE_ID)
|
||||
house["balance"] = house.get("balance", 0) + amount
|
||||
await _commit(HOUSE_ID, house)
|
||||
await _credit_house(amount)
|
||||
except DatabaseError as exc:
|
||||
_log.critical(
|
||||
"House compensation failed (%s, related uid %s, amount %s): %s",
|
||||
@@ -2080,16 +2180,14 @@ async def _refund_house_safe(amount: int, context: str, related_uid: int) -> Non
|
||||
|
||||
|
||||
async def _refund_user_safe(_unused_house_id, amount: int, context: str, uid: int) -> None:
|
||||
"""Best-effort debit of `amount` from the house (compensates a failed user fine).
|
||||
|
||||
Reads house, subtracts amount, commits. Logs critical if it fails.
|
||||
"""
|
||||
"""Best-effort atomic debit of `amount` from the house (compensates a failed
|
||||
user fine). Logs critical if it fails."""
|
||||
if HOUSE_ID is None or amount <= 0:
|
||||
return
|
||||
try:
|
||||
house = await get_user(HOUSE_ID)
|
||||
house["balance"] = max(0, house.get("balance", 0) - amount)
|
||||
await _commit(HOUSE_ID, house)
|
||||
record_id = await _house_record_id()
|
||||
if record_id:
|
||||
await pb_client.update_record(record_id, {"balance-": amount})
|
||||
except DatabaseError as exc:
|
||||
_log.critical(
|
||||
"House debit compensation failed (%s, related uid %s, amount %s): %s",
|
||||
|
||||
Reference in New Issue
Block a user