diff --git a/bot.py b/bot.py index d066e92..bbaae03 100644 --- a/bot.py +++ b/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: diff --git a/commands/economy_games_commands.py b/commands/economy_games_commands.py index 7b80251..3691d61 100644 --- a/commands/economy_games_commands.py +++ b/commands/economy_games_commands.py @@ -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: diff --git a/core/economy/gambling.py b/core/economy/gambling.py index 1ebe713..e803fc1 100644 --- a/core/economy/gambling.py +++ b/core/economy/gambling.py @@ -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: diff --git a/core/economy/store.py b/core/economy/store.py index d1f8e5d..0cb4819 100644 --- a/core/economy/store.py +++ b/core/economy/store.py @@ -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 # --------------------------------------------------------------------------- diff --git a/tests/test_pending_wager.py b/tests/test_pending_wager.py new file mode 100644 index 0000000..b52e6a5 --- /dev/null +++ b/tests/test_pending_wager.py @@ -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