diff options
Diffstat (limited to 'internal/casino')
| -rw-r--r-- | internal/casino/baccarat.go | 64 | ||||
| -rw-r--r-- | internal/casino/blackjack.go | 61 | ||||
| -rw-r--r-- | internal/casino/cards.go | 16 | ||||
| -rw-r--r-- | internal/casino/credits.go | 7 | ||||
| -rw-r--r-- | internal/casino/machine.go | 56 | ||||
| -rw-r--r-- | internal/casino/manager_test.go | 25 | ||||
| -rw-r--r-- | internal/casino/money_regression_test.go | 177 | ||||
| -rw-r--r-- | internal/casino/types.go | 11 |
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 |
