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:
Rene Arumetsa
2026-09-04 02:37:43 +03:00
parent f72d72b355
commit 8481003edf
5 changed files with 202 additions and 1 deletions

10
bot.py
View File

@@ -441,6 +441,16 @@ async def on_ready():
# Re-schedule any reminder tasks lost on restart
await _restore_reminders()
# Refund stakes escrowed by interactive games (blackjack/RPS PvP) that a
# restart interrupted, so a mid-hand crash never eats a player's coins.
try:
refunded = await economy.reconcile_pending_wagers()
if refunded:
total = sum(amt for _, amt, _ in refunded)
log.info("Reconciled %d interrupted wager(s), refunded %d coins", len(refunded), total)
except Exception:
log.exception("Pending-wager reconciliation failed")
# Notify the channel where /restart was triggered
if _RESTART_FILE.exists():
try:

View File

@@ -290,10 +290,12 @@ def register_economy_games_commands(
if self.bet > 0:
if winner == "a":
await economy.do_rps_pvp_payout(self.player_a.id, self.bet)
await economy.do_rps_pvp_forfeit(self.player_b.id)
bet_line_a = f"\n+{coin(self.bet)}"
bet_line_b = f"\n-{coin(self.bet)}"
elif winner == "b":
await economy.do_rps_pvp_payout(self.player_b.id, self.bet)
await economy.do_rps_pvp_forfeit(self.player_a.id)
bet_line_a = f"\n-{coin(self.bet)}"
bet_line_b = f"\n+{coin(self.bet)}"
else:

View File

@@ -6,7 +6,10 @@ import random
from ..pb_client import DatabaseError
from ..emoji import EMOJI as E
from .store import _commit, _is_jailed, _locked_by, _log, _txn, get_user
from .store import (
_commit, _is_jailed, _locked_by, _log, _txn, add_pending_wager,
clear_pending_wager, get_user,
)
from .house import _credit_house
@@ -105,6 +108,7 @@ async def do_rps_pvp_deposit(user_id: int, bet: int) -> dict:
return {"ok": False, "reason": "insufficient"}
user["balance"] -= bet
user["total_wagered"] = user.get("total_wagered", 0) + bet
add_pending_wager(user, "rps", bet) # escrow survives a restart
try:
await _commit(user_id, user)
except DatabaseError:
@@ -125,6 +129,7 @@ async def do_rps_pvp_payout(winner_id: int, bet: int) -> dict:
user["lifetime_earned"] = user.get("lifetime_earned", 0) + bet
user["biggest_win"] = max(user.get("biggest_win", 0), bet)
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
clear_pending_wager(user) # winner's escrow settled
try:
await _commit(winner_id, user)
except DatabaseError:
@@ -133,6 +138,23 @@ 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_forfeit(loser_id: int) -> dict:
"""Release the loser's escrow marker without refunding - their stake was paid
to the winner as part of the 2*bet payout. Without this the loser's
pending_wager would linger and be wrongly refunded on the next restart."""
try:
user = await get_user(loser_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
clear_pending_wager(user)
try:
await _commit(loser_id, user)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
return {"ok": True}
@_locked_by(0)
async def do_rps_pvp_refund(user_id: int, bet: int) -> dict:
"""Refund a previously escrowed bet (tie / timeout / cancel)."""
@@ -142,6 +164,7 @@ async def do_rps_pvp_refund(user_id: int, bet: int) -> dict:
return {"ok": False, "reason": "db_error"}
user["balance"] = user.get("balance", 0) + bet
user["total_wagered"] = max(0, user.get("total_wagered", 0) - bet)
clear_pending_wager(user) # escrow returned
try:
await _commit(user_id, user)
except DatabaseError:
@@ -248,6 +271,9 @@ async def do_blackjack_bet(user_id: int, bet: int) -> dict:
if user["balance"] < bet:
return {"ok": False, "reason": "insufficient", "balance": user["balance"]}
user["balance"] -= bet
# Escrow the stake in the same commit (accumulates across double/split), so a
# restart mid-hand refunds it via reconcile_pending_wagers instead of eating it.
add_pending_wager(user, "blackjack", bet)
try:
await _commit(user_id, user)
except DatabaseError:
@@ -279,6 +305,7 @@ async def do_blackjack_payout(user_id: int, payout: int, total_invested: int = 0
elif net < 0:
user["lifetime_lost"] = user.get("lifetime_lost", 0) + abs(net)
user["biggest_loss"] = max(user.get("biggest_loss", 0), abs(net))
clear_pending_wager(user) # hand settled - release the escrow marker
try:
await _commit(user_id, user)
except DatabaseError:

View File

@@ -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
# ---------------------------------------------------------------------------

102
tests/test_pending_wager.py Normal file
View File

@@ -0,0 +1,102 @@
"""Tests for pending-wager escrow persistence.
Interactive games (blackjack, RPS PvP) deduct a stake up front and hold it in an
in-memory View. These tests verify the stake is recorded on the user record in
the same commit, cleared on settlement, and refunded by reconcile on restart.
"""
from core import economy
from conftest import run
UID = 555
OPP = 556
def _fund(fake_pb, uid: int, amount: int) -> None:
run(economy.get_user(uid))
fake_pb.record_for(uid)["balance"] = amount
def _pending(fake_pb, uid: int) -> dict:
return fake_pb.record_for(uid).get("pending_wager") or {}
class TestBlackjackEscrow:
def test_bet_records_pending_and_payout_clears(self, fake_pb):
_fund(fake_pb, UID, 1000)
run(economy.do_blackjack_bet(UID, 100))
pw = _pending(fake_pb, UID)
assert pw["kind"] == "blackjack" and pw["amount"] == 100
run(economy.do_blackjack_payout(UID, payout=200, total_invested=100))
assert _pending(fake_pb, UID) == {} # settled
assert fake_pb.record_for(UID)["balance"] == 1000 - 100 + 200
def test_double_split_accumulates_escrow(self, fake_pb):
_fund(fake_pb, UID, 1000)
run(economy.do_blackjack_bet(UID, 100)) # initial
run(economy.do_blackjack_bet(UID, 100)) # double / split adds a hand
assert _pending(fake_pb, UID)["amount"] == 200
def test_losing_hand_clears_escrow(self, fake_pb):
_fund(fake_pb, UID, 1000)
run(economy.do_blackjack_bet(UID, 100))
run(economy.do_blackjack_payout(UID, payout=0, total_invested=100)) # bust/loss
assert _pending(fake_pb, UID) == {}
class TestRpsEscrow:
def test_deposit_records_and_payout_forfeit_clear(self, fake_pb):
_fund(fake_pb, UID, 500)
_fund(fake_pb, OPP, 500)
run(economy.do_rps_pvp_deposit(UID, 100))
run(economy.do_rps_pvp_deposit(OPP, 100))
assert _pending(fake_pb, UID)["kind"] == "rps"
assert _pending(fake_pb, OPP)["amount"] == 100
run(economy.do_rps_pvp_payout(UID, 100)) # UID wins
run(economy.do_rps_pvp_forfeit(OPP)) # OPP loses
assert _pending(fake_pb, UID) == {}
assert _pending(fake_pb, OPP) == {}
assert fake_pb.record_for(UID)["balance"] == 500 - 100 + 200
assert fake_pb.record_for(OPP)["balance"] == 500 - 100
def test_refund_clears_escrow(self, fake_pb):
_fund(fake_pb, UID, 500)
run(economy.do_rps_pvp_deposit(UID, 100))
run(economy.do_rps_pvp_refund(UID, 100))
assert _pending(fake_pb, UID) == {}
assert fake_pb.record_for(UID)["balance"] == 500
class TestReconcile:
def test_refunds_interrupted_stakes(self, fake_pb):
# Simulate a restart: two players left mid-game with escrowed stakes.
_fund(fake_pb, UID, 400)
run(economy.do_blackjack_bet(UID, 100)) # 300 left, 100 escrowed
_fund(fake_pb, OPP, 500)
fake_pb.record_for(OPP)["balance"] = 500
run(economy.do_rps_pvp_deposit(OPP, 250)) # 250 left, 250 escrowed
refunded = run(economy.reconcile_pending_wagers())
by_uid = {uid: (amt, kind) for uid, amt, kind in refunded}
assert by_uid[UID] == (100, "blackjack")
assert by_uid[OPP] == (250, "rps")
assert fake_pb.record_for(UID)["balance"] == 400 # stake restored
assert fake_pb.record_for(OPP)["balance"] == 500
assert _pending(fake_pb, UID) == {}
assert _pending(fake_pb, OPP) == {}
def test_noop_when_nothing_pending(self, fake_pb):
_fund(fake_pb, UID, 100)
assert run(economy.reconcile_pending_wagers()) == []
assert fake_pb.record_for(UID)["balance"] == 100
def test_reconcile_is_idempotent(self, fake_pb):
_fund(fake_pb, UID, 400)
run(economy.do_blackjack_bet(UID, 100))
run(economy.reconcile_pending_wagers())
# A second run (e.g. another restart) must not double-refund.
assert run(economy.reconcile_pending_wagers()) == []
assert fake_pb.record_for(UID)["balance"] == 400