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

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