From a8a67482bbff8297a96adff65d2fd7029ed998cc Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 18 Sep 2026 21:36:36 -0400 Subject: refactor: replace economy journal with single-file pending-wager wallet --- internal/casino/baccarat.go | 6 +- internal/casino/blackjack.go | 14 +-- internal/casino/core.go | 13 +-- internal/casino/correct_casino_refund_bank_test.go | 2 +- internal/casino/credits.go | 11 +- internal/casino/machine.go | 118 ++++++++++----------- internal/casino/manager.go | 8 +- internal/casino/types.go | 49 ++++----- 8 files changed, 103 insertions(+), 118 deletions(-) (limited to 'internal/casino') diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go index cf8a289..2b2b060 100644 --- a/internal/casino/baccarat.go +++ b/internal/casino/baccarat.go @@ -168,9 +168,9 @@ func (t *baccaratTable) deal() []Event { continue } result, payout, settleErr := settleBaccaratBet(p.lastSpot, p.Wager.Total, playerTotal, bankerTotal, t.Rules) - // The shared settlement path pays before announcing, leaves the - // stake open on any failure (recovery re-applies the owed payout), - // and resolves it only once durable. + // The shared settlement path pays before announcing and leaves the + // stake pending on any failure, so a refund can never be paid on top + // of a payout that was not durable. payoutFailed := settleStakeOutcome(p.Wallet, payout, []string{p.stakeID}, settleErr) publicResult, privateResult := baccaratResultMessages(p.Name, result, payout) if payoutFailed { diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go index 55ee702..3b08ba5 100644 --- a/internal/casino/blackjack.go +++ b/internal/casino/blackjack.go @@ -596,8 +596,8 @@ func (t *blackjackTable) settle() []Event { for _, hand := range p.hands { result, payout, settleErr := settleHand(hand, dealerTotal, dealerBlackjack, t.Rules) // One shared settlement path for every game: pay before - // announcing, leave the stakes open on any failure, and resolve - // them only once the payout is durable. + // announcing, and leave the stakes pending on any failure so a + // refund can never be paid on top of a payout that was not durable. if settleStakeOutcome(p.Wallet, payout, hand.stakes, settleErr) { payoutFailed = true publicResult, _ := blackjackResultMessages(p.Name, result, payout) @@ -627,8 +627,8 @@ func (t *blackjackTable) settle() []Event { } } if p.Wallet != nil && !payoutFailed { - // The round resolved; the stakes are no longer outstanding and - // startup recovery must not refund them. + // The round resolved; the stakes are no longer pending and must + // never be refunded. p.Wallet.ResolveStakes() } } @@ -781,8 +781,8 @@ func (t *blackjackTable) markDisconnectedLocked(name string) []Event { } if p := t.players.get(name); p != nil && p.HasBet && p.Wallet != nil { if t.phase == blackjackPlaying || t.phase == blackjackInsurance { - // A disconnected player forfeits the round in flight: resolve the - // stakes now so startup recovery does not refund them later. + // A disconnected player forfeits the round in flight: consume the + // stakes now so a later refund cannot return them. p.Wallet.ResolveStakes() } else { // No card has been dealt for this stake yet, so refund it: nothing @@ -812,7 +812,7 @@ func (t *blackjackTable) markDisconnectedLocked(name string) []Event { } // refundAll refunds every committed wager at this table (graceful -// shutdown/eviction): the wallet returns every outstanding stake — opening +// shutdown/eviction): the wallet returns every pending stake — opening // bet, doubles, splits, and insurance — exactly once. It then RESETS the // round, so a refunded round can never settle (and pay) afterwards: calling // it twice, or letting the table keep running, cannot pay the same round diff --git a/internal/casino/core.go b/internal/casino/core.go index a0ac1c8..4177094 100644 --- a/internal/casino/core.go +++ b/internal/casino/core.go @@ -116,7 +116,7 @@ func (c *tableCore[P]) refundAllLocked() []Event { refund := chips + credits if err != nil || refund <= 0 { if err != nil { - events = append(events, c.private(name, "Shutdown refund could not be persisted; the stake is recorded for recovery.")) + events = append(events, c.private(name, "Shutdown refund could not be persisted; the stake stays pending for the next startup.")) } continue } @@ -131,11 +131,12 @@ func (c *tableCore[P]) refundAllLocked() []Event { // reports whether the settlement could NOT be made durable. It carries the // money-critical ordering shared by every table game (documented in // building_guide/concurrency.md): compute the payout, pay it via -// PayStakeCover BEFORE announcing the win, and only then resolve the stakes -// the settlement returned. On a persistence failure the stakes are left open -// so recovery re-applies the owed payout and a shutdown refund never returns -// them a second time; the caller must not announce a paid win and must not -// run the player-wide ResolveStakes. +// PayStakeCover BEFORE announcing the win (that one save both credits the +// payout and removes the stakes it returns), and only then stand the hand +// down. On a persistence failure the stakes stay pending, so a later shutdown +// refund or startup recovery returns them exactly once — the payout was never +// durable, so it can never be paid on top of the refund. The caller must not +// announce a paid win and must not run the player-wide ResolveStakes. // // payoutErr is a payout-computation error (for example an overflowed // multiplier): it is treated exactly like a persistence failure so a diff --git a/internal/casino/correct_casino_refund_bank_test.go b/internal/casino/correct_casino_refund_bank_test.go index 4262997..497b5c2 100644 --- a/internal/casino/correct_casino_refund_bank_test.go +++ b/internal/casino/correct_casino_refund_bank_test.go @@ -95,7 +95,7 @@ func TestMachineRefundAllFailedRefundKeepsStakeOpenForRecovery(t *testing.T) { if wallet.paid != 0 { t.Fatalf("a failed refund paid %d; want 0", wallet.paid) } - if !hasMessage(events, "recorded for recovery") { + if !hasMessage(events, "stake stays pending") { t.Fatalf("the failed refund was not reported: %+v", events) } } diff --git a/internal/casino/credits.go b/internal/casino/credits.go index 19364da..8ef8868 100644 --- a/internal/casino/credits.go +++ b/internal/casino/credits.go @@ -73,13 +73,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. // payoutFailedMessage explains a settlement the wallet could not apply right -// now. This covers both a persistence failure and a credit-cap block (the -// journal record stays open either way), so it must not assert a persistence -// failure as the cause. Recovery re-applies the owed amount. +// now: a persistence failure or a credit-cap block. Games must NOT announce +// such a payout as paid. The stake stays pending, so it is returned by a +// shutdown refund or at the next startup rather than paid as winnings. func payoutFailedMessage() string { - return "The house could not process your payout right now. Your settlement is recorded and will be restored when you return." + return "The house could not process your payout right now. Your stake is safe and will be returned if the settlement cannot be completed." } diff --git a/internal/casino/machine.go b/internal/casino/machine.go index 3e1e0af..5c73b6f 100644 --- a/internal/casino/machine.go +++ b/internal/casino/machine.go @@ -32,13 +32,13 @@ type Machine struct { payoutRemainder float64 // stakeID is the wallet handle of the wager committed to the current // round (the triggering wager for a free-spin feature). Resolving by - // handle — not the player-wide ResolveStakes — keeps an older - // failed-payout stake open as recovery evidence when a new round starts. + // handle — not the player-wide ResolveStakes — consumes only this + // round's stake, leaving an older failed-payout stake pending. stakeID string // featureBank holds the winnings of a paid round whose free-spin feature // is still running. They are paid as ONE settlement when the feature - // ends: see finishSpin for why no payout may be journaled while the - // triggering stake is outstanding. + // ends: see finishSpin for why the triggering stake stays pending until + // then. featureBank float64 profile SlotProfile rng *rand.Rand @@ -46,8 +46,9 @@ type Machine struct { autoBet bool autoCountdown int // stakeFailed marks the in-flight stake whose payout could not be - // persisted: the stake record stays OPEN as recovery evidence, so the - // seat must not resolve (forfeit) it when the player leaves. + // persisted: the stake stays pending, so the seat must not consume + // (forfeit) it when the player leaves — a refund or startup recovery + // returns it. stakeFailed bool } @@ -238,13 +239,11 @@ func (m *Machine) autoBetTick() []Event { // triggering stake once the whole round (feature included) is over. // // A free-spin feature pays out as ONE settlement when it ends. While the -// feature runs its winnings are banked in memory and nothing is journaled: -// the triggering stake stays OUTSTANDING until the feature completes, and -// recovery reads ANY payout record for the player as proof that the round -// resolved (a PayCredits record carries no stake linkage, see the Wallet -// contract). Paying a free spin as it landed would therefore make a crash -// mid-feature settle the stake instead of refunding it, contradicting the -// refund-on-crash contract that a graceful shutdown honors. +// feature runs its winnings are banked in memory and the triggering stake +// stays PENDING (nothing is resolved): paying a free spin as it landed would +// consume the stake early, so a crash mid-feature would eat it instead of +// refunding it. Banking the winnings keeps the refund-on-lost-round behavior +// intact until the feature completes. func (m *Machine) finishSpin() []Event { events := []Event{} result := m.spin.Payout + m.payoutRemainder @@ -279,11 +278,10 @@ func (m *Machine) finishSpin() []Event { payFailed := false if payNow > 0 && m.player.Wallet != nil { if err := m.player.Wallet.PayCredits(payNow); 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 keep the stake - // OPEN (stakeFailed): resolving it here would erase the refund - // evidence for the owed settlement. + // The payout could not be made durable. Do not announce the win as + // paid, stop any autobet chain, and keep the stake PENDING + // (stakeFailed): the settlement was never durable, so the stake is + // refunded rather than paid. payFailed = true m.stakeFailed = true m.autoBet, m.autoCountdown = false, 0 @@ -324,10 +322,10 @@ func (m *Machine) finishSpin() []Event { events = append(events, m.public(fmt.Sprintf("Three scatter symbols trigger %d free spins!", m.profile.FreeSpinCount))...) } if m.freeSpins > 0 { - // A feature is in progress: the triggering stake stays OUTSTANDING - // until the bonus completes, so a graceful shutdown (or crash) while - // free spins remain refunds the stake through RefundOutstanding / - // startup recovery instead of silently eating the feature. + // A feature is in progress: the triggering stake stays PENDING until + // the bonus completes, so a graceful shutdown (or crash) while free + // spins remain refunds the stake through RefundOutstanding / startup + // recovery instead of silently eating the feature. m.freeSpins-- m.phase = machineSpinning m.spin = generateSlotSpinWithProfile(m.rng, m.spinBet, m.profile) @@ -337,9 +335,9 @@ func (m *Machine) finishSpin() []Event { m.inFreeSpins = false if !payFailed && m.player.Wallet != nil { // The paid round fully resolved (the feature either never - // started or just completed; free spins wager nothing): resolve - // ONLY this round's stake, so an older failed-payout stake stays - // open as recovery evidence. + // started or just completed; free spins wager nothing): consume + // ONLY this round's stake, leaving an older failed-payout stake + // pending. m.player.Wallet.ResolveStake(m.stakeID) m.stakeID = "" m.stakeFailed = false @@ -367,10 +365,9 @@ 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: its -// stake is resolved so startup recovery does not refund it. The exception -// is a stake whose payout could not be persisted (stakeFailed): the open -// stake record is the only evidence of the owed settlement, so it is left -// unresolved for recovery (or the shutdown refund) to complete. +// stake is consumed so it is not refunded. The exception is a stake whose +// payout could not be persisted (stakeFailed): it is left pending for a +// shutdown refund or startup recovery. func (m *Machine) resetSeat() { if m.player != nil && m.player.Wallet != nil && !m.stakeFailed { m.player.Wallet.ResolveStake(m.stakeID) @@ -394,11 +391,10 @@ func (m *Machine) resetSeat() { // in-flight wager, so the seat is immediately free for someone else. // // Already-landed free-spin winnings (featureBank) are NOT part of the -// in-flight wager: they are settled after resetSeat resolves the triggering -// stake, so the unlinked settlement cannot be read at recovery as covering -// that stake. If the round's payout could not be persisted (stakeFailed), the -// stake is left open for recovery and the bank is reported as forfeited -// rather than silently destroyed. +// in-flight wager: they are settled after resetSeat consumes the triggering +// stake. If the round's payout could not be persisted (stakeFailed), the +// stake is left pending and the bank is reported as forfeited rather than +// silently destroyed. func (m *Machine) markDisconnected(name string) []Event { m.mu.Lock() defer m.mu.Unlock() @@ -423,7 +419,7 @@ func (m *Machine) markDisconnected(name string) []Event { case wallet.PayCredits(bank) != nil: events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, PrivateTo: name, Player: name, - PrivateMessage: fmt.Sprintf("Your %d credits of free-spin winnings could not be persisted; they are recorded for recovery.", bank), + PrivateMessage: fmt.Sprintf("Your %d credits of free-spin winnings could not be persisted; they were forfeited.", bank), Color: "casino_action", Public: false}) default: events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, @@ -508,19 +504,16 @@ func (m *Machine) privateMenu(prefix string) []Event { return events } -// refundAll refunds any outstanding stake at graceful shutdown/eviction and -// then DISCARDS the round (the machine closes with the process). 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 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 (only the still-unplayed free spins' EV is forfeit); the -// winnings that have already landed are banked in featureBank and are paid -// through the wallet as a settlement, so a graceful shutdown preserves them -// exactly as the pre-banking code did. Locked. +// refundAll refunds any pending stake at graceful shutdown/eviction and then +// DISCARDS the round (the machine closes with the process). RefundOutstanding +// returns every pending stake exactly once; a completed spin has none and is +// not refunded again. A stake whose payout could not be persisted +// (stakeFailed) is left pending by RefundOutstanding and returned here, since +// the settlement never became durable. A bonus in progress keeps its +// triggering stake pending, so a shutdown during the feature returns the +// stake (only the still-unplayed free spins' EV is forfeit); winnings that +// have already landed are banked in featureBank and paid through the wallet, +// so a graceful shutdown preserves them. Locked. // // Discarding the round is what makes the call safe to repeat: a refunded // round can never settle (and pay its spin) afterwards, so neither a second @@ -535,28 +528,27 @@ func (m *Machine) refundAll() []Event { // Free-spin winnings that have already landed live only in featureBank // until the feature ends (see finishSpin), so discarding the round must // settle them or a graceful shutdown silently destroys them. Refund the - // outstanding triggering stake FIRST: the settlement that pays the bank - // below carries no stake linkage, so a journaled settlement with the - // stake still open would read at recovery as covering (and consuming) - // that stake, suppressing its refund. + // pending triggering stake FIRST, then pay the bank: the two are separate + // (the stake is returned because the feature never completed, the bank is + // winnings already won). chips, credits, err := wallet.RefundOutstanding() refund := chips + credits bank := int(m.featureBank) bankFailed := false if err == nil && bank > 0 { - // Only settle the bank once the refund is settled: on a failed refund - // the stake stays open for recovery, and a settlement payout would - // make recovery treat it as consumed instead of refunded. + // Only settle the bank once the refund is durable: on a failed refund + // the stake stays pending, and paying the bank first would leave the + // two obligations tangled. if payErr := wallet.PayCredits(bank); payErr != nil { bankFailed = true } } - // A refund that could not be made durable leaves the stakes open for - // recovery to complete; the round is still discarded, and marking the - // stake as failed keeps resetSeat from resolving (forfeiting) it. This is - // LOAD-BEARING, not dead code: on the RefundOutstanding error path the - // stakes are left open, and resetSeat's guard is the only thing that - // stops it from resolving them and erasing the recovery evidence. + // A refund that could not be made durable leaves the stakes pending; the + // round is still discarded, and marking the stake as failed keeps + // resetSeat from consuming (forfeiting) it. This is LOAD-BEARING, not + // dead code: on the RefundOutstanding error path the stakes are left + // pending, and resetSeat's guard is the only thing that stops it from + // consuming them. m.stakeFailed = err != nil m.resetSeat() var events []Event @@ -564,7 +556,7 @@ func (m *Machine) refundAll() []Event { case err != nil: events = append(events, 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.", + PrivateMessage: "Shutdown refund could not be persisted; the stake stays pending for the next startup.", Color: "casino_action", Public: false}) case refund > 0: events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, @@ -583,7 +575,7 @@ func (m *Machine) refundAll() []Event { case bankFailed: events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, PrivateTo: name, Player: name, - PrivateMessage: fmt.Sprintf("Your %d credits of free-spin winnings could not be persisted; they are recorded for recovery.", bank), + PrivateMessage: fmt.Sprintf("Your %d credits of free-spin winnings could not be persisted; they were forfeited.", bank), Color: "casino_action", Public: false}) case err != nil && bank > 0: events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, diff --git a/internal/casino/manager.go b/internal/casino/manager.go index d9b2c3e..f880463 100644 --- a/internal/casino/manager.go +++ b/internal/casino/manager.go @@ -329,8 +329,8 @@ func (m *Manager) MachineBusy(name string) bool { // RefundAll resolves every outstanding wager by refunding committed stakes // to their players and then closing each game's round. Graceful shutdown and // room eviction use this: rounds cannot settle, so no player may lose a -// stake to the shutdown itself. Refunds go through the wallet (durable, -// journaled) and the refund events are returned for delivery. +// stake to the shutdown itself. Refunds go through the wallet (durable +// character saves) and the refund events are returned for delivery. // // It is deliberately NOT a way to clear live tables: every game discards // the round it refunded (seats keep their last bet only for "rebet", but @@ -414,8 +414,8 @@ func (m *Manager) RekeyRooms(idMap map[int]int) { func (m *Manager) EvictRoom(roomID int) []Event { prefix := fmt.Sprintf("%d:", roomID) // Snapshot the games under the manager lock, then refund OUTSIDE it: - // refundAll appends to the money journal and saves character files, and - // holding m.mu across that I/O blocks every casino command and Tick. + // refundAll saves character files, and holding m.mu across that I/O + // blocks every casino command and Tick. type evictable interface{ refundAll() []Event } var evicted []evictable m.mu.Lock() diff --git a/internal/casino/types.go b/internal/casino/types.go index 980769c..a5e1415 100644 --- a/internal/casino/types.go +++ b/internal/casino/types.go @@ -191,44 +191,39 @@ 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 is the wallet's opaque handle for the committed stake. Table + // settlements pass the hand's stake IDs to PayStakeCover so the wallet + // removes exactly the stakes the payout returns. ID string } type Wallet interface { - // Wager debits the stake durably. ErrInsufficientFunds is a normal - // rejected bet; ErrWalletPersistence means the debit could not be made - // durable and the game must not proceed as if the bet succeeded. + // Wager debits the stake and records it as pending. ErrInsufficientFunds + // is a normal rejected bet; ErrWalletPersistence means the debit could + // not be made durable and the game must not proceed as if it succeeded. 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). 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 pays winnings durably, returning an error when the credit + // could not be persisted (the game must not report a win that is not + // durable). The payout carries no stake linkage: the stake was consumed + // by playing the round, so the game resolves it separately. 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. + // the payout). The credit and the removal of those stakes are one + // durable step, so a failed settlement leaves the stakes pending — + // refunded at the next startup, never paid on top of the refund. 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 consumes every pending stake for this player: the round + // resolved as a loss, a forfeit, or an unlinked payout, so the stakes + // must not be refunded. 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 consumes ONE pending stake by wallet handle (a single + // hand paid or lost while a sibling hand's round is still in flight). 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 - // means the refund could not be made durable: the stakes remain open - // and startup recovery completes the refund. + // RefundOutstanding returns every pending stake to the player (chips as + // chips, credits as credits) and clears the ledger. An error means the + // refund could not be made durable: nothing was returned and the stakes + // remain pending for a later attempt or startup recovery. RefundOutstanding() (chips, credits int, err error) } -- cgit v1.2.3