aboutsummaryrefslogtreecommitdiff
path: root/internal/casino
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-15 19:55:21 -0400
committerhistoria <[not public]>2026-09-15 19:55:21 -0400
commit6f3c43aca62a7d33a19f14f6ad7c16891952673a (patch)
tree0b0665ef624ef442df87ec7131129aaecc3420bd /internal/casino
parentcd95de60364aba8f5bdf8ec86f7234a9903eacc8 (diff)
downloadthehouseoficarus-6f3c43aca62a7d33a19f14f6ad7c16891952673a.tar.gz
fix: outstanding-stake ledger, owner backpressure, editor state preservation
Diffstat (limited to 'internal/casino')
-rw-r--r--internal/casino/baccarat.go64
-rw-r--r--internal/casino/blackjack.go61
-rw-r--r--internal/casino/cards.go16
-rw-r--r--internal/casino/credits.go7
-rw-r--r--internal/casino/machine.go56
-rw-r--r--internal/casino/manager_test.go25
-rw-r--r--internal/casino/money_regression_test.go177
-rw-r--r--internal/casino/types.go11
8 files changed, 369 insertions, 48 deletions
diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go
index d415901..fa54ac8 100644
--- a/internal/casino/baccarat.go
+++ b/internal/casino/baccarat.go
@@ -179,6 +179,8 @@ func (t *baccaratTable) rebet(name string) (string, int) {
// deal plays out the hand by the punto banco tableau, settles every wager,
// and resets the table for the next round.
func (t *baccaratTable) deal() []Event {
+ // The cut-card reshuffle happens between rounds, never mid-hand.
+ t.shoe.shuffleIfDue()
player, banker := playTableau(t.shoe)
playerTotal, bankerTotal := baccaratTotal(player), baccaratTotal(banker)
round := t.event("New baccarat hand!", true)
@@ -189,19 +191,38 @@ func (t *baccaratTable) deal() []Event {
continue
}
result, payout := settleBaccaratBet(p.lastSpot, p.Wager.Total, playerTotal, bankerTotal, t.Rules)
+ payoutFailed := false
if payout > 0 && p.Wallet != nil {
- p.Wallet.PayCredits(payout)
+ if err := p.Wallet.PayCredits(payout); err != nil {
+ // The payout could not be made durable; recovery will
+ // re-apply the owed amount from the open journal record.
+ // Do not announce the win as paid and leave the stakes
+ // open for recovery to settle.
+ payoutFailed = true
+ publicResult, _ := baccaratResultMessages(p.Name, result, payout)
+ settlement := t.playerAction(p.Name, publicResult, payoutFailedMessage())
+ if result == "win" {
+ settlement.Color = "casino_big_win"
+ }
+ events = append(events, settlement)
+ }
}
- publicResult, privateResult := baccaratResultMessages(p.Name, result, payout)
- settlement := t.playerAction(p.Name, publicResult, privateResult)
- if result == "win" {
- if p.lastSpot == baccaratSpotTie {
- settlement.Color = "casino_jackpot_win"
- } else {
- settlement.Color = "casino_big_win"
+ if !payoutFailed {
+ publicResult, privateResult := baccaratResultMessages(p.Name, result, payout)
+ settlement := t.playerAction(p.Name, publicResult, privateResult)
+ if result == "win" {
+ if p.lastSpot == baccaratSpotTie {
+ settlement.Color = "casino_jackpot_win"
+ } else {
+ settlement.Color = "casino_big_win"
+ }
}
+ events = append(events, settlement)
+ }
+ if p.Wallet != nil && !payoutFailed {
+ // The round resolved; the stakes are no longer outstanding.
+ p.Wallet.ResolveStakes()
}
- events = append(events, settlement)
}
t.resetRound()
for _, p := range t.players.ordered() {
@@ -247,6 +268,11 @@ func (t *baccaratTable) markDisconnected(name string) []Event {
if !t.players.has(name) {
return nil
}
+ // A disconnected player forfeits the round in flight: resolve the
+ // stakes now so startup recovery does not refund them later.
+ if p := t.players.get(name); p != nil && p.HasBet && p.Wallet != nil {
+ p.Wallet.ResolveStakes()
+ }
t.players.remove(name)
events := []Event{t.event(fmt.Sprintf("%s stands up.", name), true)}
if t.players.count() == 0 {
@@ -364,7 +390,8 @@ func baccaratResultMessages(name, result string, payout int) (string, string) {
}
// refundAll resolves every committed wager at this table (graceful
-// shutdown): stakes return to their players through the wallet.
+// shutdown/eviction): the wallet returns every outstanding stake exactly
+// once.
func (t *baccaratTable) refundAll() []Event {
t.mu.Lock()
defer t.mu.Unlock()
@@ -373,17 +400,18 @@ func (t *baccaratTable) refundAll() []Event {
if !p.HasBet || p.Wallet == nil {
continue
}
- name, refund := p.Name, p.Wager.Total
- if refund <= 0 {
- continue
- }
- if err := p.Wallet.PayCredits(refund); err != nil {
- events = append(events, t.private(name, "Shutdown refund could not be persisted; the stake is recorded for recovery."))
+ name := p.Name
+ chips, credits, err := p.Wallet.RefundOutstanding()
+ refund := chips + credits
+ if err != nil || refund <= 0 {
+ if err != nil {
+ events = append(events, t.private(name, "Shutdown refund could not be persisted; the stake is recorded for recovery."))
+ }
continue
}
events = append(events, t.playerAction(name,
- fmt.Sprintf("%s is refunded %d credits as the casino closes.", name, refund),
- fmt.Sprintf("The house refunds your %d credit stake for the shutdown.", refund)))
+ fmt.Sprintf("%s is refunded %d as the casino closes.", name, refund),
+ fmt.Sprintf("The house refunds your %d stake for the shutdown.", refund)))
}
return events
}
diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go
index 7c01c34..d3b26af 100644
--- a/internal/casino/blackjack.go
+++ b/internal/casino/blackjack.go
@@ -380,6 +380,8 @@ func splitCompatible(a, b card, unlikeTens bool) bool {
}
func (t *blackjackTable) startRound() []Event {
+ // The cut-card reshuffle happens between rounds, never mid-hand.
+ t.shoe.shuffleIfDue()
t.dealer = blackjackHand{cards: []card{t.shoe.draw(), t.shoe.draw()}}
for _, p := range t.players.ordered() {
if !p.HasBet {
@@ -542,10 +544,27 @@ func (t *blackjackTable) settle() []Event {
if !p.HasBet {
continue
}
+ payoutFailed := false
for _, hand := range p.hands {
result, payout := settleHand(hand, dealerTotal, dealerBlackjack, t.Rules)
if payout > 0 && p.Wallet != nil {
- p.Wallet.PayCredits(payout)
+ if err := p.Wallet.PayCredits(payout); err != nil {
+ // The payout could not be made durable. The journal
+ // record stays open: recovery re-applies the owed
+ // amount. The win is not announced as paid, and the
+ // stakes stay open so recovery settles them against
+ // the payout record.
+ payoutFailed = true
+ publicResult, _ := blackjackResultMessages(p.Name, result, payout)
+ settlement := t.playerAction(p.Name, publicResult, payoutFailedMessage())
+ if result == "blackjack" {
+ settlement.Color = "casino_jackpot_win"
+ } else if result == "win" {
+ settlement.Color = "casino_big_win"
+ }
+ events = append(events, settlement)
+ continue
+ }
}
publicResult, privateResult := blackjackResultMessages(p.Name, result, payout)
settlement := t.playerAction(p.Name, publicResult, privateResult)
@@ -562,9 +581,20 @@ func (t *blackjackTable) settle() []Event {
insurancePayout, _ = MulPayout(p.insurance, 1+t.Rules.InsurancePayout)
}
if insurancePayout > 0 {
- p.Wallet.PayCredits(insurancePayout)
+ if err := p.Wallet.PayCredits(insurancePayout); err != nil {
+ payoutFailed = true
+ events = append(events, t.private(p.Name, payoutFailedMessage()))
+ } else {
+ events = append(events, t.private(p.Name, fmt.Sprintf("Insurance pays %d credits.", insurancePayout)))
+ }
+ } else {
+ events = append(events, t.private(p.Name, "Insurance pays 0 credits."))
}
- events = append(events, t.private(p.Name, fmt.Sprintf("Insurance pays %d credits.", insurancePayout)))
+ }
+ if p.Wallet != nil && !payoutFailed {
+ // The round resolved; the stakes are no longer outstanding and
+ // startup recovery must not refund them.
+ p.Wallet.ResolveStakes()
}
}
t.resetRound()
@@ -701,6 +731,11 @@ func (t *blackjackTable) markDisconnectedLocked(name string) []Event {
if index < 0 {
return nil
}
+ // A disconnected player forfeits the round in flight: resolve the
+ // stakes now so startup recovery does not refund them later.
+ if p := t.players.get(name); p != nil && p.HasBet && p.Wallet != nil {
+ p.Wallet.ResolveStakes()
+ }
t.players.remove(name)
events := []Event{t.event(fmt.Sprintf("%s stands up.", name), true)}
if t.players.count() == 0 {
@@ -730,7 +765,8 @@ func (t *blackjackTable) hasParticipant(name string) bool {
}
// refundAll resolves every committed wager at this table (graceful
-// shutdown): stakes return to their players through the wallet.
+// shutdown/eviction): the wallet returns every outstanding stake — opening
+// bet, doubles, splits, and insurance — exactly once.
func (t *blackjackTable) refundAll() []Event {
t.mu.Lock()
defer t.mu.Unlock()
@@ -739,17 +775,18 @@ func (t *blackjackTable) refundAll() []Event {
if !p.HasBet || p.Wallet == nil {
continue
}
- name, refund := p.Name, p.Wager.Total
- if refund <= 0 {
- continue
- }
- if err := p.Wallet.PayCredits(refund); err != nil {
- events = append(events, t.private(name, "Shutdown refund could not be persisted; the stake is recorded for recovery."))
+ name := p.Name
+ chips, credits, err := p.Wallet.RefundOutstanding()
+ refund := chips + credits
+ if err != nil || refund <= 0 {
+ if err != nil {
+ events = append(events, t.private(name, "Shutdown refund could not be persisted; the stake is recorded for recovery."))
+ }
continue
}
events = append(events, t.playerAction(name,
- fmt.Sprintf("%s is refunded %d credits as the casino closes.", name, refund),
- fmt.Sprintf("The house refunds your %d credit stake for the shutdown.", refund)))
+ fmt.Sprintf("%s is refunded %d as the casino closes.", name, refund),
+ fmt.Sprintf("The house refunds your %d stake for the shutdown.", refund)))
}
return events
}
diff --git a/internal/casino/cards.go b/internal/casino/cards.go
index f263eb6..b1c2cbb 100644
--- a/internal/casino/cards.go
+++ b/internal/casino/cards.go
@@ -69,10 +69,24 @@ func (s *shoe) shuffle() {
}
func (s *shoe) draw() card {
- if s.shuffleAt > 0 && len(s.cards) <= int(float64(s.decks*52)*s.shuffleAt) {
+ if len(s.cards) == 0 {
+ // The shoe ran dry mid-round (only possible when the configured
+ // deck count is very small relative to the table): rebuild it
+ // rather than panic. The cut-card reshuffle itself happens
+ // between rounds via shuffleIfDue, never per draw.
s.shuffle()
}
card := s.cards[len(s.cards)-1]
s.cards = s.cards[:len(s.cards)-1]
return card
}
+
+// shuffleIfDue reshuffles the shoe once its remaining cards have fallen
+// to the shuffleAt fraction (the cut card). It must be called at round
+// boundaries only — shuffling between individual draws would let cards
+// already dealt to hands reappear from the new shoe.
+func (s *shoe) shuffleIfDue() {
+ if s.shuffleAt > 0 && len(s.cards) <= int(float64(s.decks*52)*s.shuffleAt) {
+ s.shuffle()
+ }
+}
diff --git a/internal/casino/credits.go b/internal/casino/credits.go
index ce47d41..2b4e2d7 100644
--- a/internal/casino/credits.go
+++ b/internal/casino/credits.go
@@ -82,3 +82,10 @@ func walletErrorMessage(err error, context ...string) string {
}
return "You don't have enough chips and credits for that bet."
}
+
+// payoutFailedMessage explains a settlement the wallet could not persist:
+// the journal record for the payout stays open, so startup recovery will
+// re-apply the owed amount. Games must NOT announce such a payout as paid.
+func payoutFailedMessage() string {
+ return "The house could not process your payout (persistence failure). Your settlement is recorded and will be restored when you return."
+}
diff --git a/internal/casino/machine.go b/internal/casino/machine.go
index 6a7b7aa..caff1bf 100644
--- a/internal/casino/machine.go
+++ b/internal/casino/machine.go
@@ -228,10 +228,26 @@ func (m *Machine) finishSpin() []Event {
}
credits := int(math.Floor(result))
m.payoutRemainder = result - float64(credits)
+ payFailed := false
if credits > 0 && m.player.Wallet != nil {
- m.player.Wallet.PayCredits(credits)
+ if err := m.player.Wallet.PayCredits(credits); err != nil {
+ // The payout could not be made durable; recovery will re-apply
+ // the owed amount from the open journal record. Do not announce
+ // the win as paid, stop any autobet chain, and leave the stake
+ // open so recovery settles it against the payout record.
+ payFailed = true
+ m.autoBet, m.autoCountdown = false, 0
+ failedEvents := m.private(payoutFailedMessage())
+ failedEvents[0].Color = "casino_payout"
+ events = append(events, failedEvents...)
+ }
+ }
+ if !payFailed && m.player.Wallet != nil {
+ // The paid spin resolved (free spins that follow wager nothing):
+ // the stake is no longer outstanding.
+ m.player.Wallet.ResolveStakes()
}
- if credits > 0 {
+ if credits > 0 && !payFailed {
winEvents := m.private(fmt.Sprintf("\nYou win %d credits.", credits))
winEvents[0].Color = "casino_payout"
if credits > m.spinBet {
@@ -283,8 +299,12 @@ func (m *Machine) leave(name string) []Event {
}
// resetSeat frees the machine, discarding any in-flight spin and autobet
-// state. A wager already taken for a spin in progress is forfeited.
+// state. A wager already taken for a spin in progress is forfeited: its
+// stake is resolved so startup recovery does not refund it.
func (m *Machine) resetSeat() {
+ if m.player != nil && m.player.Wallet != nil {
+ m.player.Wallet.ResolveStakes()
+ }
m.player = nil
m.bet = 0
m.spin = nil
@@ -387,27 +407,31 @@ func (m *Machine) menuEvents(prefix string) []Event {
return m.privateMenu(prefix)
}
-// refundAll refunds any committed stake at graceful shutdown. Locked.
+// refundAll refunds any outstanding stake at graceful shutdown/eviction.
+// The wallet resolves every outstanding stake exactly once; a completed
+// spin has no outstanding stake and is not refunded again. Locked.
func (m *Machine) refundAll() []Event {
m.mu.Lock()
defer m.mu.Unlock()
- if m.player == nil || m.player.Wallet == nil || m.spinBet <= 0 {
+ if m.player == nil || m.player.Wallet == nil {
return nil
}
- name, refund := m.player.Name, m.spinBet
- wallet := m.player.Wallet
- m.spinBet = 0
- m.bet = 0
- if err := wallet.PayCredits(refund); err != nil {
- return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult,
- PrivateTo: name, Player: name,
- PrivateMessage: "Shutdown refund could not be persisted; the stake is recorded for recovery.",
- Color: "casino_action", Public: false}}
+ name, wallet := m.player.Name, m.player.Wallet
+ chips, credits, err := wallet.RefundOutstanding()
+ refund := chips + credits
+ if err != nil || refund <= 0 {
+ if err != nil {
+ return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult,
+ PrivateTo: name, Player: name,
+ PrivateMessage: "Shutdown refund could not be persisted; the stake is recorded for recovery.",
+ Color: "casino_action", Public: false}}
+ }
+ return nil
}
return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult,
PrivateTo: name, Player: name,
- Message: fmt.Sprintf("%s is refunded %d credits as the casino closes.", name, refund),
- PrivateMessage: fmt.Sprintf("The house refunds your %d credit stake for the shutdown.", refund),
+ Message: fmt.Sprintf("%s is refunded %d as the casino closes.", name, refund),
+ PrivateMessage: fmt.Sprintf("The house refunds your %d stake for the shutdown.", refund),
Color: "casino_action", Public: true}}
}
diff --git a/internal/casino/manager_test.go b/internal/casino/manager_test.go
index 50cf114..fa26aa1 100644
--- a/internal/casino/manager_test.go
+++ b/internal/casino/manager_test.go
@@ -4,6 +4,7 @@ import "testing"
type testWallet struct {
chips, credits, paid int
+ open []Wager
}
func (w *testWallet) Wager(amount int) (Wager, error) {
@@ -17,7 +18,9 @@ func (w *testWallet) Wager(amount int) (Wager, error) {
w.chips -= chips
credits := amount - chips
w.credits -= credits
- return Wager{Total: amount, Chips: chips, Credits: credits}, nil
+ wager := Wager{Total: amount, Chips: chips, Credits: credits}
+ w.open = append(w.open, wager)
+ return wager, nil
}
func (w *testWallet) PayCredits(amount int) error {
@@ -25,6 +28,26 @@ func (w *testWallet) PayCredits(amount int) error {
return nil
}
+// ResolveStakes mirrors the production wallet: every outstanding stake is
+// settled (no longer refundable).
+func (w *testWallet) ResolveStakes() { w.open = nil }
+
+// RefundOutstanding returns every outstanding stake to the balances and
+// resolves the stakes, mirroring the production refund path: credits are
+// paid through PayCredits (recorded in paid), chips return as chips.
+func (w *testWallet) RefundOutstanding() (chips, credits int, err error) {
+ for _, wager := range w.open {
+ chips += wager.Chips
+ credits += wager.Credits
+ }
+ w.open = nil
+ w.chips += chips
+ if credits > 0 {
+ w.paid += credits
+ }
+ return chips, credits, nil
+}
+
func TestMixedWagerUsesChipsFirst(t *testing.T) {
w := &testWallet{chips: 7, credits: 50}
wager, err := w.Wager(12)
diff --git a/internal/casino/money_regression_test.go b/internal/casino/money_regression_test.go
new file mode 100644
index 0000000..43bf505
--- /dev/null
+++ b/internal/casino/money_regression_test.go
@@ -0,0 +1,177 @@
+package casino
+
+// Casino money-movement regressions: shutdown/eviction refunds cover every
+// committed stake (bet, double, split, insurance) exactly once, completed
+// spins are not refunded, failed payouts are never announced as paid, and
+// the card shoe reshuffles between rounds only.
+
+import (
+ "strings"
+ "testing"
+)
+
+// failingPayoutWallet rejects every payout with a persistence failure.
+type failingPayoutWallet struct {
+ testWallet
+}
+
+func (w *failingPayoutWallet) PayCredits(int) error { return ErrWalletPersistence }
+
+// TestBlackjackRefundCoversAllStakes: splitting an eligible pair commits a
+// second stake; the shutdown refund must return both, and a completed round
+// (stakes resolved) must not be refunded at all.
+func TestBlackjackRefundCoversAllStakes(t *testing.T) {
+ table := newBlackjackTable(1, TableConfig{ID: "bj", Game: GameBlackjack}, newTestRand())
+ table.shoe = tableauShoe(
+ card{rank: "10"}, card{rank: "7"}, // dealer
+ card{rank: "8"}, card{rank: "8"}, // alice's splittable pair
+ card{rank: "2"}, card{rank: "3"}, // split draws
+ )
+ wallet := &testWallet{credits: 100}
+ table.join("alice")
+ table.bet("alice", "", 10, wallet)
+ table.action("alice", "split")
+ if wallet.credits != 80 {
+ t.Fatalf("setup: split did not commit a second stake (credits=%d)", wallet.credits)
+ }
+
+ // The round is in flight: the refund must return every committed stake.
+ table.refundAll()
+ if total := wallet.credits + wallet.paid; total != 100 {
+ t.Fatalf("split-then-refund returned %d of 100 committed credits", total)
+ }
+
+ // A refund must be idempotent: the stakes are resolved after the first.
+ table.refundAll()
+ if total := wallet.credits + wallet.paid; total != 100 {
+ t.Fatalf("second refund double-paid: %d of 100", total)
+ }
+}
+
+// TestBlackjackDoubleRefundCoversStake: doubling commits a second stake that
+// the shutdown refund must return while the round is still in flight.
+func TestBlackjackDoubleRefundCoversStake(t *testing.T) {
+ table := newBlackjackTable(1, TableConfig{ID: "bj", Game: GameBlackjack}, newTestRand())
+ table.shoe = tableauShoe(
+ card{rank: "10"}, card{rank: "7"}, // dealer: 17
+ card{rank: "5"}, card{rank: "6"}, // alice: 11, doubles to 15
+ card{rank: "10"}, card{rank: "6"}, // bob: 16, still to act
+ card{rank: "4"}, // alice's double draw: 15, stands
+ )
+ walletA := &testWallet{credits: 100}
+ walletB := &testWallet{credits: 100}
+ table.join("alice")
+ table.join("bob")
+ table.bet("alice", "", 10, walletA)
+ table.bet("bob", "", 10, walletB)
+ table.action("alice", "double")
+ if walletA.credits != 80 {
+ t.Fatalf("setup: double did not commit a second stake (credits=%d)", walletA.credits)
+ }
+ // The round is still in flight (bob has not acted).
+ table.refundAll()
+ if got := walletA.credits + walletA.paid; got != 100 {
+ t.Fatalf("alice's double-then-refund returned %d of 100", got)
+ }
+ if got := walletB.credits + walletB.paid; got != 100 {
+ t.Fatalf("bob's refund returned %d of 100", got)
+ }
+}
+
+// TestSlotsRefundOnlyOutstandingStake: a completed losing spin has no
+// outstanding stake; refunding a seated player must not pay it again. An
+// in-flight spin's stake is refunded exactly once.
+func TestSlotsRefundOnlyOutstandingStake(t *testing.T) {
+ machine := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 1}, newTestRand())
+ wallet := &testWallet{credits: 100}
+ machine.join("alice")
+ machine.setBet("alice", 10, wallet)
+ machine.spin = &SlotSpin{} // deterministic losing spin
+ for i := 0; i < 20 && machine.phase != machineReady; i++ {
+ machine.tick()
+ }
+ if machine.phase != machineReady || wallet.paid != 0 {
+ t.Fatal("setup: the spin must have ended without payout")
+ }
+
+ // The completed spin's stake was resolved with the round: no refund.
+ machine.refundAll()
+ if wallet.paid != 0 {
+ t.Fatalf("completed losing spin refunded %d credits at shutdown; want 0", wallet.paid)
+ }
+
+ // An in-flight spin's stake IS refunded.
+ wallet2 := &testWallet{credits: 100}
+ machine2 := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 50}, newTestRand())
+ machine2.join("alice")
+ machine2.setBet("alice", 10, wallet2)
+ machine2.refundAll()
+ if got := wallet2.credits + wallet2.paid; got != 100 {
+ t.Fatalf("in-flight spin refund returned %d of 100", got)
+ }
+}
+
+// TestBlackjackFailedPayoutNotAnnouncedAsPaid: when the wallet cannot make
+// the payout durable, the table must not tell the player they were paid.
+func TestBlackjackFailedPayoutNotAnnouncedAsPaid(t *testing.T) {
+ table := newBlackjackTable(1, TableConfig{ID: "bj", Game: GameBlackjack}, newTestRand())
+ table.shoe = tableauShoe(
+ card{rank: "10"}, card{rank: "7"}, // dealer: 17
+ card{rank: "10"}, card{rank: "8"}, // alice: 18, stands, wins
+ )
+ wallet := &failingPayoutWallet{testWallet{credits: 100}}
+ table.join("alice")
+ table.bet("alice", "", 10, wallet)
+ events := table.action("alice", "stand")
+ for _, e := range events {
+ if strings.Contains(e.PrivateMessage, "You win! +") {
+ t.Fatalf("announced a successful payout after the wallet rejected it: %q", e.PrivateMessage)
+ }
+ if strings.Contains(e.PrivateMessage, "persistence failure") {
+ return // the failure was explained to the player
+ }
+ }
+ t.Fatal("failed payout was neither announced as paid (good) nor explained (bad)")
+}
+
+// TestBaccaratFailedPayoutNotAnnouncedAsPaid: same rule for baccarat.
+func TestBaccaratFailedPayoutNotAnnouncedAsPaid(t *testing.T) {
+ table := newBaccaratTable(1, TableConfig{ID: "bac", Game: GameBaccarat}, newTestRand())
+ // Player 9 (natural), banker 0: no tableau draws, player wins.
+ table.shoe = tableauShoe(
+ card{rank: "9"}, card{rank: "K"}, // player: 9
+ card{rank: "8"}, card{rank: "2"}, // banker: 0
+ )
+ wallet := &failingPayoutWallet{testWallet{credits: 100}}
+ table.join("alice")
+ // With a lone player, the bet starts and deals the round immediately.
+ events := table.bet("alice", "player", 10, wallet)
+ for _, e := range events {
+ if strings.Contains(e.PrivateMessage, "You win! +") {
+ t.Fatalf("announced a successful payout after the wallet rejected it: %q", e.PrivateMessage)
+ }
+ }
+ if got := wallet.credits + wallet.paid; got != 90 {
+ t.Fatalf("failed payout changed the balance: %d, want 90", got)
+ }
+}
+
+// TestShoeReshufflesOnlyAtRoundBoundaries: crossing the shuffle threshold
+// between individual draws must not replace the shoe mid-hand.
+func TestShoeReshufflesOnlyAtRoundBoundaries(t *testing.T) {
+ shoe := newShoe(1, .25, newTestRand())
+ for len(shoe.cards) > 14 {
+ shoe.draw()
+ }
+ shoe.draw()
+ shoe.draw()
+ if got := len(shoe.cards); got != 12 {
+ t.Fatalf("mid-hand draws reshuffled the shoe: %d cards remain, want 12", got)
+ }
+
+ // At a round boundary the cut-card threshold applies.
+ shoe.shuffleIfDue()
+ if got := len(shoe.cards); got <= 12 {
+ t.Fatalf("shuffleIfDue did not reshuffle at the boundary (%d cards)", got)
+ }
+}
diff --git a/internal/casino/types.go b/internal/casino/types.go
index 75d041b..d000375 100644
--- a/internal/casino/types.go
+++ b/internal/casino/types.go
@@ -171,6 +171,17 @@ type Wallet interface {
// credit could not be persisted (the game must not report a win that
// is not durable).
PayCredits(amount int) error
+ // ResolveStakes marks every outstanding stake for this player as
+ // settled: the round resolved (won, lost, pushed, or forfeited), so
+ // startup recovery must no longer refund it. A stake whose wager
+ // record remains open at restart is refunded by recovery.
+ ResolveStakes()
+ // RefundOutstanding returns every outstanding stake to the player
+ // (chips as chips, credits through the durable payout path) and
+ // resolves the stakes. It reports the amounts returned. An error
+ // means the refund could not be made durable: the stakes remain open
+ // and startup recovery completes the refund.
+ RefundOutstanding() (chips, credits int, err error)
}
type EventType string