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
This commit is contained in:
@@ -166,6 +166,11 @@ class UserData(TypedDict, total=False):
|
||||
# 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:
|
||||
@@ -222,6 +227,8 @@ def _default_user() -> UserData:
|
||||
# ── Quests ───────────────────────────────────────────────────────────
|
||||
"quest_daily": {},
|
||||
"quest_weekly": {},
|
||||
# ── Interactive-game escrow (blackjack / RPS PvP) ────────────────────
|
||||
"pending_wager": {},
|
||||
}
|
||||
|
||||
|
||||
@@ -399,6 +406,59 @@ async def _commit(user_id: int, user: UserData) -> dict | None:
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user