Added tests, fix README.

This commit is contained in:
Rene Arumetsa
2026-07-26 20:32:13 +03:00
parent cb18d9b882
commit 5ba5642694
9 changed files with 769 additions and 138 deletions

View File

@@ -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",