aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/machine.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-18 05:31:09 -0400
committerhistoria <[not public]>2026-09-18 05:31:09 -0400
commit153acb7f644d755c45533b7c5311e3ffa47e8973 (patch)
tree7645e6f7b65bc3830b078e3b9336d2e6b199d584 /internal/casino/machine.go
parent87d08f0151e643951769e323995f1c5ed89ee200 (diff)
downloadthehouseoficarus-153acb7f644d755c45533b7c5311e3ffa47e8973.tar.gz
fix: harden engine across casino, economy, concurrency, security, and content
Diffstat (limited to 'internal/casino/machine.go')
-rw-r--r--internal/casino/machine.go184
1 files changed, 140 insertions, 44 deletions
diff --git a/internal/casino/machine.go b/internal/casino/machine.go
index 844c5e9..cf0ccb9 100644
--- a/internal/casino/machine.go
+++ b/internal/casino/machine.go
@@ -30,11 +30,16 @@ type Machine struct {
inFreeSpins bool
spinBet int
payoutRemainder float64
- profile SlotProfile
- rng *rand.Rand
- suspense bool
- autoBet bool
- autoCountdown int
+ // 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.
+ featureBank float64
+ profile SlotProfile
+ rng *rand.Rand
+ suspense bool
+ 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.
@@ -43,12 +48,7 @@ type Machine struct {
func newMachine(roomID int, cfg MachineConfig, rng *rand.Rand) *Machine {
cfg = cfg.Normalize()
- profile := DefaultHuffAndPuffProfile()
- if cfg.Payout > 0 {
- profile = ScaleToRTP(profile, cfg.Payout)
- }
- profile = profile.Normalize()
- return &Machine{RoomID: roomID, Config: cfg, phase: machineReady, profile: profile, rng: rng}
+ return &Machine{RoomID: roomID, Config: cfg, phase: machineReady, profile: machineSlotProfile(cfg), rng: rng}
}
func (m *Machine) join(name string) []Event {
@@ -184,11 +184,11 @@ func (m *Machine) tick() []Event {
m.reel++
message := fmt.Sprintf("Reel %d stops...", m.reel)
events := m.publicSlot(message, m.reel)
- if m.reel < 5 && stoppedScatters(m.spin.Grid, m.reel, m.profile.FreeSpinTrigger) >= 2 && !m.suspense {
+ if m.reel < slotGridReels && stoppedScatters(m.spin.Grid, m.reel, m.profile.FreeSpinTrigger) >= 2 && !m.suspense {
m.suspense = true
events = append(events, m.public("Two scatters! The remaining reels slow down...")...)
}
- if m.reel < 5 {
+ if m.reel < slotGridReels {
return events
}
m.phase = machinePayout
@@ -224,6 +224,18 @@ func (m *Machine) autoBetTick() []Event {
}
}
+// finishSpin resolves the spin that just stopped: it pays that spin's
+// winnings, advances or starts the free-spin feature, and resolves the
+// 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.
func (m *Machine) finishSpin() []Event {
events := []Event{}
result := m.spin.Payout + m.payoutRemainder
@@ -238,9 +250,26 @@ func (m *Machine) finishSpin() []Event {
}
credits := int(math.Floor(result))
m.payoutRemainder = result - float64(credits)
+
+ // A spin belongs to a feature when it is a free spin or when it just
+ // triggered one; the feature (and with it the round) resolves on the
+ // last free spin.
+ triggered := m.spin.ScatterCount >= 3 && m.freeSpins == 0 && !m.inFreeSpins
+ inFeature := m.inFreeSpins || triggered
+ featureEnds := m.inFreeSpins && m.freeSpins == 0
+ payNow := credits
+ switch {
+ case inFeature && !featureEnds:
+ m.featureBank += float64(credits)
+ payNow = 0
+ case featureEnds:
+ payNow = credits + int(m.featureBank)
+ m.featureBank = 0
+ }
+
payFailed := false
- if credits > 0 && m.player.Wallet != nil {
- if err := m.player.Wallet.PayCredits(credits); err != nil {
+ 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
@@ -254,10 +283,19 @@ func (m *Machine) finishSpin() []Event {
events = append(events, failedEvents...)
}
}
- if credits > 0 && !payFailed {
- winEvents := m.private(fmt.Sprintf("\nYou win %d credits.", credits))
+ if payNow > 0 && !payFailed {
+ message := fmt.Sprintf("\nYou win %d credits.", payNow)
+ switch {
+ case inFeature && !featureEnds:
+ // Banked, not spent: say so, or the player would read the win
+ // as already in their balance.
+ message = fmt.Sprintf("\nYou win %d credits, paid when the free spins end.", payNow)
+ case featureEnds:
+ message = fmt.Sprintf("\nThe free spins pay %d credits.", payNow)
+ }
+ winEvents := m.private(message)
winEvents[0].Color = "casino_payout"
- if credits > m.spinBet {
+ if payNow > m.spinBet {
winEvents[0].Color = "casino_big_win"
}
events = append(events, winEvents...)
@@ -271,7 +309,7 @@ func (m *Machine) finishSpin() []Event {
// free spin are ignored, matching the CalculateRTP model (the feature is
// a fixed number of automatic spins). Retriggering only on a paid spin
// keeps the machine's actual return aligned with that model.
- if m.spin.ScatterCount >= 3 && m.freeSpins == 0 && !m.inFreeSpins {
+ if triggered {
m.freeSpins = m.profile.FreeSpinCount
m.inFreeSpins = true
events = append(events, m.public(fmt.Sprintf("Three scatter symbols trigger %d free spins!", m.profile.FreeSpinCount))...)
@@ -335,6 +373,7 @@ func (m *Machine) resetSeat() {
m.freeSpins = 0
m.inFreeSpins = false
m.payoutRemainder = 0
+ m.featureBank = 0
m.autoBet, m.autoCountdown = false, 0
m.stakeFailed = false
}
@@ -388,10 +427,10 @@ func (m *Machine) publicSlot(message string, stopped int) []Event {
Message: message, Color: "casino_action", Public: true, Slot: &SlotSnapshot{Grid: m.spin.Grid, StoppedReels: stopped}}}
}
-func stoppedScatters(grid [3][5]string, reels int, trigger string) int {
+func stoppedScatters(grid [slotGridRows][slotGridReels]string, reels int, trigger string) int {
count := 0
- for row := 0; row < 3; row++ {
- for reel := 0; reel < reels && reel < 5; reel++ {
+ for row := 0; row < slotGridRows; row++ {
+ for reel := 0; reel < reels && reel < slotGridReels; reel++ {
if grid[row][reel] == trigger {
count++
}
@@ -429,16 +468,23 @@ func (m *Machine) menuEvents(prefix string) []Event {
return m.privateMenu(prefix)
}
-// 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 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.
+// 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.
+//
+// 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
+// RefundAll nor a stray tick can pay the same round twice.
func (m *Machine) refundAll() []Event {
m.mu.Lock()
defer m.mu.Unlock()
@@ -446,22 +492,72 @@ func (m *Machine) refundAll() []Event {
return nil
}
name, wallet := m.player.Name, m.player.Wallet
+ // 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.
chips, credits, err := wallet.RefundOutstanding()
refund := chips + credits
- if err != nil || refund <= 0 {
- if err != nil {
- return []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.",
- Color: "casino_action", Public: false}}
+ 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.
+ if payErr := wallet.PayCredits(bank); payErr != nil {
+ bankFailed = true
}
- return nil
}
- return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult,
- PrivateTo: name, Player: name,
- Message: fmt.Sprintf("%s is refunded %d as the casino closes.", name, refund),
- PrivateMessage: fmt.Sprintf("The house refunds your %d stake for the shutdown.", refund),
- Color: "casino_action", Public: 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.
+ m.stakeFailed = err != nil
+ m.resetSeat()
+ var events []Event
+ switch {
+ 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.",
+ Color: "casino_action", Public: false})
+ case refund > 0:
+ events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult,
+ PrivateTo: name, Player: name,
+ Message: fmt.Sprintf("%s is refunded %d as the casino closes.", name, refund),
+ PrivateMessage: fmt.Sprintf("The house refunds your %d stake for the shutdown.", refund),
+ Color: "casino_action", Public: true})
+ }
+ // Announce the banked free-spin settlement, or its loss. resetSeat has
+ // already zeroed featureBank by this point, so a failed refund does NOT
+ // keep the bank in memory: the banked winnings are forfeited and only the
+ // trigger stake is recovered. Report that forfeiture explicitly (a
+ // stake-linked settlement that survives a failed shutdown refund is not
+ // implemented) instead of dropping it silently.
+ switch {
+ 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),
+ Color: "casino_action", Public: false})
+ case err != nil && bank > 0:
+ events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult,
+ PrivateTo: name, Player: name,
+ PrivateMessage: fmt.Sprintf("Your %d credits of banked free-spin winnings were forfeited because the shutdown refund could not be persisted.", bank),
+ Color: "casino_action", Public: false})
+ case err == nil && bank > 0:
+ events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult,
+ PrivateTo: name, Player: name,
+ Message: fmt.Sprintf("%s's %d free-spin credits pay out as the casino closes.", name, bank),
+ PrivateMessage: fmt.Sprintf("The house settles your %d banked free-spin winnings for the shutdown.", bank),
+ Color: "casino_action", Public: true})
+ }
+ return events
}
// idle reports whether no player is seated: only an idle machine may have