aboutsummaryrefslogtreecommitdiff
path: root/internal/casino
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-18 21:36:36 -0400
committerhistoria <[not public]>2026-09-18 21:36:36 -0400
commita8a67482bbff8297a96adff65d2fd7029ed998cc (patch)
treee84f8ba636c8f2edb4d2ff8b45d7cff7298fa7cc /internal/casino
parentff04d26ce90d6b411a04a2fb59f7c24ceefb161f (diff)
downloadthehouseoficarus-a8a67482bbff8297a96adff65d2fd7029ed998cc.tar.gz
refactor: replace economy journal with single-file pending-wager wallet
Diffstat (limited to 'internal/casino')
-rw-r--r--internal/casino/baccarat.go6
-rw-r--r--internal/casino/blackjack.go14
-rw-r--r--internal/casino/core.go13
-rw-r--r--internal/casino/correct_casino_refund_bank_test.go2
-rw-r--r--internal/casino/credits.go11
-rw-r--r--internal/casino/machine.go118
-rw-r--r--internal/casino/manager.go8
-rw-r--r--internal/casino/types.go49
8 files changed, 103 insertions, 118 deletions
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)
}