forked from sass/tipibot
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:
10
bot.py
10
bot.py
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
102
tests/test_pending_wager.py
Normal 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
|
||||
Reference in New Issue
Block a user