aboutsummaryrefslogtreecommitdiff
path: root/internal/casino
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino')
-rw-r--r--internal/casino/baccarat.go10
-rw-r--r--internal/casino/blackjack.go62
-rw-r--r--internal/casino/machine.go8
-rw-r--r--internal/casino/manager_test.go20
-rw-r--r--internal/casino/money_regression_test.go2
-rw-r--r--internal/casino/types.go19
6 files changed, 99 insertions, 22 deletions
diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go
index fa54ac8..f4f5fbd 100644
--- a/internal/casino/baccarat.go
+++ b/internal/casino/baccarat.go
@@ -28,6 +28,9 @@ type baccaratPlayer struct {
Participant
lastBet int
lastSpot string
+ // stakeID is the wallet handle of the wager record committed this
+ // round; the settlement returns it inside the payout.
+ stakeID string
}
func (p *baccaratPlayer) participantPtr() *Participant { return &p.Participant }
@@ -128,7 +131,7 @@ func (t *baccaratTable) bet(name, spot string, amount int, wallet Wallet) []Even
if err != nil {
return []Event{t.private(name, walletErrorMessage(err))}
}
- p.HasBet, p.Wager, p.Wallet, p.lastBet, p.lastSpot = true, wager, wallet, amount, spot
+ p.HasBet, p.Wager, p.Wallet, p.lastBet, p.lastSpot, p.stakeID = true, wager, wallet, amount, spot, wager.ID
events := []Event{t.playerAction(name,
fmt.Sprintf("%s bets %d credits on %s.", name, amount, spot),
fmt.Sprintf("You bet %d credits on %s.", amount, spot))}
@@ -193,7 +196,7 @@ func (t *baccaratTable) deal() []Event {
result, payout := settleBaccaratBet(p.lastSpot, p.Wager.Total, playerTotal, bankerTotal, t.Rules)
payoutFailed := false
if payout > 0 && p.Wallet != nil {
- if err := p.Wallet.PayCredits(payout); err != nil {
+ if err := p.Wallet.PayStakeCover(payout, []string{p.stakeID}); 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
@@ -257,6 +260,9 @@ func (t *baccaratTable) resetRound() {
t.phase = baccaratWaiting
t.window.close()
clearBets(t.players.participants())
+ for _, p := range t.players.ordered() {
+ p.stakeID = ""
+ }
}
// markDisconnected removes the player from the table, forfeiting any wager
diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go
index 39146f7..0a0551c 100644
--- a/internal/casino/blackjack.go
+++ b/internal/casino/blackjack.go
@@ -19,8 +19,12 @@ const (
)
type blackjackHand struct {
- cards []card
- bet int
+ cards []card
+ bet int
+ // stakes are the wallet handles of the wager records committed to this
+ // hand (the opening bet plus each double or split wager). The hand's
+ // settlement returns their total; the linkage lets a refund skip them.
+ stakes []string
fromSplit bool
doubled bool
stood bool
@@ -53,6 +57,7 @@ type blackjackPlayer struct {
Participant
hands []*blackjackHand
insurance int
+ insuranceStakeID string
declinedInsurance bool
lastBet int
}
@@ -264,8 +269,10 @@ func (t *blackjackTable) action(name, action string) []Event {
if len(hand.cards) != 2 || hand.doubled || hand.surrendered || fromSplitAces(hand) || (hand.fromSplit && !t.Rules.AllowsDoubleAfterSplit()) {
return []Event{t.private(name, "You can only double a qualifying hand.")}
}
- if _, err := p.Wallet.Wager(hand.bet); err != nil {
+ if wager, err := p.Wallet.Wager(hand.bet); err != nil {
return []Event{t.private(name, walletErrorMessage(err, "You don't have enough chips and credits to double that hand."))}
+ } else {
+ hand.stakes = append(hand.stakes, wager.ID)
}
hand.bet *= 2
hand.doubled = true
@@ -306,8 +313,10 @@ func (t *blackjackTable) insuranceAction(name, action string) []Event {
if amount <= 0 {
return []Event{t.private(name, "Your bet is too small to insure.")}
}
- if _, err := p.Wallet.Wager(amount); err != nil {
+ if wager, err := p.Wallet.Wager(amount); err != nil {
return []Event{t.private(name, walletErrorMessage(err, "You don't have enough chips and credits for insurance."))}
+ } else {
+ p.insuranceStakeID = wager.ID
}
p.insurance = amount
events = append(events, t.playerAction(name, fmt.Sprintf("%s takes insurance.", name), fmt.Sprintf("You place %d credits on insurance.", amount)))
@@ -352,11 +361,12 @@ func (t *blackjackTable) splitHand(name string, p *blackjackPlayer, hand *blackj
if hand.cards[0].rank == "A" && hand.fromSplit && !t.Rules.AllowsResplitAces() {
return []Event{t.private(name, "Aces cannot be re-split at this table.")}
}
- if _, err := p.Wallet.Wager(hand.bet); err != nil {
+ wager, err := p.Wallet.Wager(hand.bet)
+ if err != nil {
return []Event{t.private(name, walletErrorMessage(err, "You don't have enough chips and credits to split that hand."))}
}
- left := &blackjackHand{cards: []card{hand.cards[0]}, bet: hand.bet, fromSplit: true}
- right := &blackjackHand{cards: []card{hand.cards[1]}, bet: hand.bet, fromSplit: true}
+ left := &blackjackHand{cards: []card{hand.cards[0]}, bet: hand.bet, fromSplit: true, stakes: hand.stakes}
+ right := &blackjackHand{cards: []card{hand.cards[1]}, bet: hand.bet, fromSplit: true, stakes: []string{wager.ID}}
left.cards = append(left.cards, t.shoe.draw())
right.cards = append(right.cards, t.shoe.draw())
index := t.activeHand
@@ -392,7 +402,7 @@ func (t *blackjackTable) startRound() []Event {
if !p.HasBet {
continue
}
- p.hands = []*blackjackHand{{cards: []card{t.shoe.draw(), t.shoe.draw()}, bet: p.Wager.Total}}
+ p.hands = []*blackjackHand{{cards: []card{t.shoe.draw(), t.shoe.draw()}, bet: p.Wager.Total, stakes: []string{p.Wager.ID}}}
if p.hands[0].bet/2 <= 0 {
// A half-bet that rounds to zero cannot be insured.
p.declinedInsurance = true
@@ -553,12 +563,12 @@ func (t *blackjackTable) settle() []Event {
for _, hand := range p.hands {
result, payout := settleHand(hand, dealerTotal, dealerBlackjack, t.Rules)
if payout > 0 && p.Wallet != nil {
- if err := p.Wallet.PayCredits(payout); err != nil {
+ if err := p.Wallet.PayStakeCover(payout, hand.stakes); 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.
+ // record stays open listing the hand's stakes as
+ // covered: recovery re-applies the owed payout (which
+ // returns those stakes) and refunds must not pay them
+ // again. The win is not announced as paid.
payoutFailed = true
publicResult, _ := blackjackResultMessages(p.Name, result, payout)
settlement := t.playerAction(p.Name, publicResult, payoutFailedMessage())
@@ -570,6 +580,15 @@ func (t *blackjackTable) settle() []Event {
events = append(events, settlement)
continue
}
+ // The hand was paid: its stakes are resolved immediately
+ // so a later sibling-hand payout failure cannot cause a
+ // refund of a stake this settlement already returned.
+ t.resolveHandStakes(p.Wallet, hand)
+ } else if payout == 0 && p.Wallet != nil {
+ // A lost hand resolves its stakes even when a sibling
+ // hand's payout is still in flight: the stake was
+ // consumed by the rules of the game.
+ t.resolveHandStakes(p.Wallet, hand)
}
publicResult, privateResult := blackjackResultMessages(p.Name, result, payout)
settlement := t.playerAction(p.Name, publicResult, privateResult)
@@ -586,13 +605,16 @@ func (t *blackjackTable) settle() []Event {
insurancePayout, _ = MulPayout(p.insurance, 1+t.Rules.InsurancePayout)
}
if insurancePayout > 0 {
- if err := p.Wallet.PayCredits(insurancePayout); err != nil {
+ if err := p.Wallet.PayStakeCover(insurancePayout, []string{p.insuranceStakeID}); err != nil {
payoutFailed = true
events = append(events, t.private(p.Name, payoutFailedMessage()))
} else {
+ p.Wallet.ResolveStake(p.insuranceStakeID)
events = append(events, t.private(p.Name, fmt.Sprintf("Insurance pays %d credits.", insurancePayout)))
}
} else {
+ // Insurance lost: its stake was consumed by the rules.
+ p.Wallet.ResolveStake(p.insuranceStakeID)
events = append(events, t.private(p.Name, "Insurance pays 0 credits."))
}
}
@@ -648,7 +670,17 @@ func (t *blackjackTable) resetRound() {
t.window.close()
t.timer.stop()
for _, p := range t.players.ordered() {
- p.HasBet, p.Wager, p.hands, p.insurance, p.declinedInsurance = false, Wager{}, nil, 0, false
+ p.HasBet, p.Wager, p.hands, p.insurance, p.insuranceStakeID, p.declinedInsurance = false, Wager{}, nil, 0, "", false
+ }
+}
+
+// resolveHandStakes settles every stake wager a hand committed. Nil-safe.
+func (t *blackjackTable) resolveHandStakes(wallet Wallet, hand *blackjackHand) {
+ if wallet == nil {
+ return
+ }
+ for _, stakeID := range hand.stakes {
+ wallet.ResolveStake(stakeID)
}
}
diff --git a/internal/casino/machine.go b/internal/casino/machine.go
index 757588b..0f137ec 100644
--- a/internal/casino/machine.go
+++ b/internal/casino/machine.go
@@ -428,9 +428,11 @@ func (m *Machine) menuEvents(prefix string) []Event {
// 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. A stake whose
-// payout failed to persist (stakeFailed) is refunded here as well — its
-// owed settlement remains open separately and startup recovery re-applies
-// it. A bonus in progress keeps its triggering stake outstanding, so a
+// payout failed to persist (stakeFailed) is NOT returned here when an open
+// payout record covers it — the journaled winnings prove the round
+// resolved, so the stake was consumed by playing it and recovery re-applies
+// the owed payout instead of double-paying the round. A bonus in progress
+// keeps its triggering stake outstanding with no payout journaled, so a
// shutdown during the feature returns the stake (the remaining free-spin
// EV is forfeit, never the wager). Locked.
func (m *Machine) refundAll() []Event {
diff --git a/internal/casino/manager_test.go b/internal/casino/manager_test.go
index fa26aa1..b037efa 100644
--- a/internal/casino/manager_test.go
+++ b/internal/casino/manager_test.go
@@ -18,7 +18,7 @@ func (w *testWallet) Wager(amount int) (Wager, error) {
w.chips -= chips
credits := amount - chips
w.credits -= credits
- wager := Wager{Total: amount, Chips: chips, Credits: credits}
+ wager := Wager{Total: amount, Chips: chips, Credits: credits, ID: string(rune('a' + len(w.open)))}
w.open = append(w.open, wager)
return wager, nil
}
@@ -28,6 +28,24 @@ func (w *testWallet) PayCredits(amount int) error {
return nil
}
+// PayStakeCover routes through PayCredits so a wallet rejecting payouts
+// (failingPayoutWallet) rejects stake-covering settlements identically.
+func (w *testWallet) PayStakeCover(amount int, _ []string) error {
+ return w.PayCredits(amount)
+}
+
+// ResolveStake settles one outstanding stake by handle, mirroring the
+// production wallet.
+func (w *testWallet) ResolveStake(stakeID string) {
+ kept := w.open[:0]
+ for _, wager := range w.open {
+ if wager.ID != stakeID {
+ kept = append(kept, wager)
+ }
+ }
+ w.open = kept
+}
+
// ResolveStakes mirrors the production wallet: every outstanding stake is
// settled (no longer refundable).
func (w *testWallet) ResolveStakes() { w.open = nil }
diff --git a/internal/casino/money_regression_test.go b/internal/casino/money_regression_test.go
index e6b3ef8..79f9b46 100644
--- a/internal/casino/money_regression_test.go
+++ b/internal/casino/money_regression_test.go
@@ -17,6 +17,8 @@ type failingPayoutWallet struct {
func (w *failingPayoutWallet) PayCredits(int) error { return ErrWalletPersistence }
+func (w *failingPayoutWallet) PayStakeCover(int, []string) 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.
diff --git a/internal/casino/types.go b/internal/casino/types.go
index d000375..283aa43 100644
--- a/internal/casino/types.go
+++ b/internal/casino/types.go
@@ -160,6 +160,10 @@ type Wager struct {
Total int
Chips int
Credits int
+ // ID is the wallet's opaque handle for the journaled stake record:
+ // table settlements pay through PayStakeCover with the hand's stake
+ // IDs so the journal can prove which wager a settlement returns.
+ ID string
}
type Wallet interface {
@@ -169,13 +173,26 @@ type Wallet interface {
Wager(amount int) (Wager, error)
// PayCredits pays a settlement durably, returning an error when the
// credit could not be persisted (the game must not report a win that
- // is not durable).
+ // is not durable). The journaled record carries no stake linkage:
+ // recovery treats it as resolving the round (a slots payout is
+ // winnings only; the stake was consumed by playing the round).
PayCredits(amount int) error
+ // PayStakeCover pays a settlement whose amount INCLUDES the return of
+ // the listed stake wager IDs (a table hand pays back its stake inside
+ // the payout). When the payout cannot be made durable, the open
+ // journal record still proves those stakes were returned inside it, so
+ // a shutdown refund must not return them a second time and recovery
+ // re-applies the owed payout instead.
+ PayStakeCover(amount int, stakeIDs []string) 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()
+ // ResolveStake settles ONE outstanding stake by wallet handle (a
+ // single hand paid or lost while a sibling hand's payout is still in
+ // flight): its return is proven, so it must never be refunded.
+ ResolveStake(stakeID string)
// 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