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:
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