From 32a3c59cebf2a16277e54d715a67adfdbe00b456 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Thu, 17 Sep 2026 22:00:33 -0400 Subject: fix: exact casino settlement/refund coverage, journal damage handling, craft output ordering, aggregate ingredient planning --- internal/casino/baccarat.go | 10 ++++-- internal/casino/blackjack.go | 62 ++++++++++++++++++++++++-------- internal/casino/machine.go | 8 +++-- internal/casino/manager_test.go | 20 ++++++++++- internal/casino/money_regression_test.go | 2 ++ internal/casino/types.go | 19 +++++++++- 6 files changed, 99 insertions(+), 22 deletions(-) (limited to 'internal/casino') 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 -- cgit v1.2.3