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/machine.go | 118 +++++++++++++++++++++------------------------ 1 file changed, 55 insertions(+), 63 deletions(-) (limited to 'internal/casino/machine.go') 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, -- cgit v1.2.3