aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/machine.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino/machine.go')
-rw-r--r--internal/casino/machine.go118
1 files changed, 55 insertions, 63 deletions
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,