Files
tipibot/core/economy/store.py
Rene Arumetsa 8481003edf feat(economy): persist interactive-game stakes so a restart can't eat them
Blackjack and RPS PvP deduct a stake up front and held it only in an in-memory
View - a restart mid-hand lost the coins. Now the stake is escrowed on the user
record (new pending_wager JSON field) in the SAME commit as the deduction, and:

- do_blackjack_bet accumulates the escrow (covers double/split); do_blackjack_payout
  clears it on settlement (incl. the 0-payout loss/timeout paths).
- do_rps_pvp_deposit records it; do_rps_pvp_payout/refund clear it, and a new
  do_rps_pvp_forfeit clears the loser's marker (their stake went to the winner).
- reconcile_pending_wagers() runs on startup (on_ready) and refunds any stake left
  escrowed by an interrupted game. It's idempotent and locks per user.

Schema: pending_wager auto-types as json via sync_pb_schema. Tests cover the full
escrow lifecycle, loser forfeit, and idempotent reconciliation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT
2026-09-04 02:37:43 +03:00

471 lines
18 KiB
Python

"""Shared foundation: user records, locks, time, cooldowns, txn log."""
from __future__ import annotations
import asyncio
import functools
import logging
from datetime import datetime, timedelta, timezone
from typing import TypedDict
import aiohttp
from .. import pb_client
from ..pb_client import DatabaseError
from ..emoji import EMOJI as E
def _clock() -> datetime:
"""Actual time source - a seam so tests can freeze time everywhere at once."""
return datetime.now(tz=timezone.utc)
def _now() -> datetime:
return _clock()
_txn_log = logging.getLogger("tipiCOIN.txn")
def _txn(event: str, **fields) -> None:
"""Log a single economy transaction to the transactions logger."""
body = " ".join(f"{k}={v}" for k, v in fields.items())
_txn_log.info("%-16s %s", event, body)
# Per-profile emoji values live in core/emoji.py; add new IDs there.
COIN = E["TipiCOIN"]
PP_EMOJI = E["TipiFIRE"]
# ---------------------------------------------------------------------------
# Prestige shop catalogue
# ---------------------------------------------------------------------------
class PrestigeItem(TypedDict):
emoji: str
max_level: int
pp_cost: int
effect: float
PRESTIGE_SHOP: dict[str, PrestigeItem] = {
"coin_mult": {
"emoji": E["TipiCOIN"],
"max_level": 5,
"pp_cost": 5,
"effect": 0.08,
},
"exp_mult": {
"emoji": "",
"max_level": 5,
"pp_cost": 5,
"effect": 0.08,
},
"daily_plus": {
"emoji": "📅",
"max_level": 3,
"pp_cost": 7,
"effect": 0.20,
},
"work_plus": {
"emoji": "💼",
"max_level": 3,
"pp_cost": 7,
"effect": 0.20,
},
}
# ---------------------------------------------------------------------------
# Cooldowns
# ---------------------------------------------------------------------------
COOLDOWNS: dict[str, timedelta] = {
"daily": timedelta(hours=20),
"work": timedelta(hours=1),
"beg": timedelta(minutes=5),
"crime": timedelta(hours=2),
"rob": timedelta(hours=2),
"fish": timedelta(minutes=2),
}
# Items that shorten a command's cooldown: command -> (item_id, reduced cooldown).
# Single source of truth so the cooldown check (do_*), the reminder scheduler
# (_maybe_remind) and the restart restore (_restore_reminders) never drift apart.
ITEM_COOLDOWNS: dict[str, tuple[str, timedelta]] = {
"work": ("monitor", timedelta(minutes=40)),
"beg": ("hiirematt", timedelta(minutes=3)),
"daily": ("korvaklapid", timedelta(hours=18)),
"fish": ("ussipurk", timedelta(seconds=90)),
}
def effective_cooldown(cmd: str, items) -> timedelta | None:
"""The cooldown for `cmd` given the user's owned `items`, applying any
item-based reduction. Returns None for commands with no cooldown."""
override = ITEM_COOLDOWNS.get(cmd)
if override is not None and override[0] in items:
return override[1]
return COOLDOWNS.get(cmd)
JAIL_DURATION = timedelta(minutes=30)
HEIST_JAIL = timedelta(hours=1, minutes=30)
# ---------------------------------------------------------------------------
# User schema
# ---------------------------------------------------------------------------
class UserData(TypedDict, total=False):
balance: int
exp: int # lifetime EXP (resets each season)
last_daily: str | None
last_work: str | None
last_beg: str | None
last_crime: str | None
last_rob: str | None
last_heist: str | None
daily_streak: int
last_streak_date: str | None # ISO date "YYYY-MM-DD"
items: list[str]
item_uses: dict # {item_id: remaining_uses} for consumables
active_buffs: dict # {buff_kind: expiry_iso} for timed consumable boosts
vanity_owned: list[str] # cosmetic badge ids the user has purchased
vanity_active: str | None # currently-equipped vanity badge id (shown on /profile)
jailed_until: str | None # ISO datetime or None
jailbreak_used: bool
reminders: list[str] # command names user wants DM reminders for
eco_banned: bool # if True, user cannot use any economy commands
# Lifetime statistics
peak_balance: int
lifetime_earned: int
lifetime_lost: int
work_count: int
beg_count: int
total_wagered: int
biggest_win: int
biggest_loss: int
slots_jackpots: int
crimes_attempted: int
crimes_succeeded: int
times_jailed: int
total_bail_paid: int
heists_joined: int
heists_won: int
total_given: int
total_received: int
best_daily_streak: int
heist_global_cd_until: float
# Prestige system
prestige_level: int
prestige_points: int
season_total_exp: int # cumulative EXP this season (survives prestige resets)
prestige_upgrades: dict # {upgrade_id: level}
# Fishing system
last_fish: str | None
fish_book: dict # {fish_id: times_caught}
total_fish_caught: int
fish_inventory: list # [{fish_id, weight, value}] - survives prestige
# Quest system
quest_daily: dict # {"date": "YYYY-MM-DD", "quests": {qid: {snap, claimed}}}
quest_weekly: dict # {"week": "YYYY-Www", "quests": {qid: {snap, claimed}}}
# Coins a running interactive game (blackjack/RPS PvP) has deducted but not
# yet settled. {"kind": ..., "amount": int, "ts": iso} while escrowed, {}
# otherwise. Reconciled (refunded) on startup so a restart mid-game never
# eats the stake. See reconcile_pending_wagers.
pending_wager: dict
def _default_user() -> UserData:
return {
"balance": 0,
"exp": 0,
"last_daily": None,
"last_work": None,
"last_beg": None,
"last_crime": None,
"last_rob": None,
"last_heist": None,
"daily_streak": 0,
"last_streak_date": None,
"items": [],
"item_uses": {},
"active_buffs": {},
"vanity_owned": [],
"vanity_active": None,
"jailed_until": None,
"jailbreak_used": False,
"reminders": ["daily", "work", "beg", "crime", "rob"],
"eco_banned": False,
# ── Lifetime stats ──────────────────────────────────────────────────
"peak_balance": 0,
"lifetime_earned": 0,
"lifetime_lost": 0,
"work_count": 0,
"beg_count": 0,
"total_wagered": 0,
"biggest_win": 0,
"biggest_loss": 0,
"slots_jackpots": 0,
"crimes_attempted": 0,
"crimes_succeeded": 0,
"times_jailed": 0,
"total_bail_paid": 0,
"heists_joined": 0,
"heists_won": 0,
"total_given": 0,
"total_received": 0,
"best_daily_streak": 0,
"heist_global_cd_until": 0.0,
# ── Prestige ─────────────────────────────────────────────────────────
"prestige_level": 0,
"prestige_points": 0,
"season_total_exp": 0,
"prestige_upgrades": {},
# ── Fishing ──────────────────────────────────────────────────────────
"last_fish": None,
"fish_book": {},
"total_fish_caught": 0,
"fish_inventory": [],
# ── Quests ───────────────────────────────────────────────────────────
"quest_daily": {},
"quest_weekly": {},
# ── Interactive-game escrow (blackjack / RPS PvP) ────────────────────
"pending_wager": {},
}
# ---------------------------------------------------------------------------
# Persistence (PocketBase backend)
# ---------------------------------------------------------------------------
_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
# ---------------------------------------------------------------------------
# Public helpers
# ---------------------------------------------------------------------------
async def missing_schema_fields() -> list[str]:
"""Compare the live PocketBase collection schema against every field the
bot persists. PocketBase silently drops writes to undeclared fields, so
any name returned here means broken features without error messages."""
live = await pb_client.get_collection_fields()
expected = set(_default_user()) | {"user_id"}
return sorted(expected - live)
async def get_all_users_raw() -> dict[str, "UserData"]:
"""Return a snapshot of all user records."""
records = await pb_client.list_all_records()
result: dict[str, UserData] = {}
for record in records:
uid = record.get("user_id", "")
if not uid:
continue
user = _default_user()
for key in list(user.keys()):
if key in record:
user[key] = record[key] # type: ignore[literal-required]
user["_pb_id"] = record["id"] # type: ignore[typeddict-unknown-key]
result[uid] = user
return result
def _parse_dt(s: str | None) -> datetime | None:
if not s:
return None
dt = datetime.fromisoformat(s)
# Ensure timezone-aware
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
def _cooldown_remaining(
user: UserData, action: str, override_cd: timedelta | None = None
) -> timedelta | None:
"""Return remaining cooldown, or None if the action is ready."""
last = _parse_dt(user.get(f"last_{action}"))
if last is None:
return None
cd = override_cd if override_cd is not None else COOLDOWNS[action]
remaining = cd - (_now() - last)
return remaining if remaining.total_seconds() > 0 else None
def _is_jailed(user: UserData) -> timedelta | None:
"""Return remaining jail time, or None if free."""
until = _parse_dt(user.get("jailed_until"))
if until is None:
return None
remaining = until - _now()
return remaining if remaining.total_seconds() > 0 else None
def jailed_remaining(user: UserData) -> timedelta | None:
"""Public wrapper - return remaining jail time, or None if free."""
return _is_jailed(user)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def format_td(td: timedelta) -> str:
"""Human-readable timedelta: '1t 23m' / '45m 12s' / '8s'."""
total = int(td.total_seconds())
h, rem = divmod(total, 3600)
m, s = divmod(rem, 60)
if h:
return f"{h}t {m}m"
if m:
return f"{m}m {s}s"
return f"{s}s"
async def get_user(user_id: int) -> UserData:
"""Fetch user data from PocketBase, creating a default record if first seen."""
uid = str(user_id)
try:
record = await pb_client.get_record(uid)
if record is None:
default = _default_user()
default["user_id"] = uid # type: ignore[typeddict-unknown-key]
record = await pb_client.create_record(default)
except (aiohttp.ClientError, asyncio.TimeoutError, RuntimeError) as exc:
_log.error("PocketBase unreachable for user %s: %s", user_id, exc)
raise DatabaseError(f"Database unavailable: {exc}") from exc
user = _default_user()
for key in list(user.keys()):
if key in record:
user[key] = record[key] # type: ignore[literal-required]
user["_pb_id"] = record["id"] # type: ignore[typeddict-unknown-key]
return user
def _prestige_mult(user: UserData) -> tuple[float, float]:
"""Return (coin_mult, exp_mult) based on prestige upgrades. Both ≥1.0."""
upgrades: dict = user.get("prestige_upgrades") or {} # type: ignore[assignment]
coin_level = upgrades.get("coin_mult", 0)
exp_level = upgrades.get("exp_mult", 0)
return (
1.0 + coin_level * PRESTIGE_SHOP["coin_mult"]["effect"],
1.0 + exp_level * PRESTIGE_SHOP["exp_mult"]["effect"],
)
# ---------------------------------------------------------------------------
# Internal write helper
# ---------------------------------------------------------------------------
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:
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
# ---------------------------------------------------------------------------
# Pending-wager escrow (interactive games survive a restart)
# ---------------------------------------------------------------------------
# Interactive games (blackjack, RPS PvP) deduct a stake up front and hold it in
# an in-memory View until the hand resolves. A restart would drop the View and
# lose the coins. To prevent that, the deduction commit also records the escrowed
# amount on the user (add_pending_wager), the settlement commit clears it
# (clear_pending_wager), and reconcile_pending_wagers refunds anything still
# outstanding at startup. All three mutate the user dict in place so the escrow
# state rides along in the SAME commit as the balance change (atomic).
def add_pending_wager(user: UserData, kind: str, amount: int) -> None:
"""Record/accumulate `amount` coins as escrowed by a `kind` game."""
pw = dict(user.get("pending_wager") or {})
pw = {
"kind": kind,
"amount": int(pw.get("amount", 0) or 0) + amount,
"ts": _now().isoformat(),
}
user["pending_wager"] = pw
def clear_pending_wager(user: UserData) -> None:
"""Mark the user's escrow settled (call in the settlement commit)."""
user["pending_wager"] = {}
async def reconcile_pending_wagers() -> list[tuple[int, int, str]]:
"""Refund every stake left escrowed by a game that a restart interrupted.
Runs once at startup (before commands are served). Returns the list of
(user_id, refunded_amount, kind) so the caller can log a summary."""
refunded: list[tuple[int, int, str]] = []
for uid_str, snapshot in (await get_all_users_raw()).items():
pw = snapshot.get("pending_wager") or {}
if int(pw.get("amount", 0) or 0) <= 0:
continue
uid = int(uid_str)
async with _user_lock(uid):
user = await get_user(uid)
pw = user.get("pending_wager") or {}
amount = int(pw.get("amount", 0) or 0)
if amount <= 0:
continue
kind = str(pw.get("kind", "?"))
user["balance"] += amount
clear_pending_wager(user)
await _commit(uid, user)
_txn("WAGER_RECONCILE", user=uid, refund=f"+{amount}", kind=kind, bal=user["balance"])
_log.info("Refunded interrupted %s wager: %s coins to user %s", kind, amount, uid)
refunded.append((uid, amount, kind))
return refunded
# ---------------------------------------------------------------------------
# /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)
user["reminders"] = list(commands)
await _commit(user_id, user)