package casino import ( "fmt" "math" "math/rand" "sync" ) type machinePhase string const ( machineReady machinePhase = "ready" machineSpinning machinePhase = "spinning" machinePayout machinePhase = "payout" payoutDelay = 2 ) type Machine struct { mu sync.Mutex RoomID int Config MachineConfig player *Participant bet int phase machinePhase spin *SlotSpin reel int clock int freeSpins int inFreeSpins bool spinBet int payoutRemainder 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. stakeFailed bool } 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} } func (m *Machine) join(name string) []Event { m.mu.Lock() defer m.mu.Unlock() if m.player != nil { return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, PrivateTo: name, Message: "That slot machine is occupied."}} } m.player = &Participant{Name: name, Connected: true} events := []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventJoined, Player: name, Message: fmt.Sprintf("%s sits down at the slots machine.", name), PrivateMessage: "You sit down at the slot machine.", PrivateTo: name, Color: "casino_action", Public: true}} return append(events, m.privateMenu("You sit down at the slot machine.")...) } func (m *Machine) setBet(name string, amount int, wallet Wallet) []Event { m.mu.Lock() defer m.mu.Unlock() if !m.owns(name) { return m.privateTo(name, "You aren't playing at a slot machine.") } if m.autoBet { return m.private("Autobet is active. Type \"stop\" to stop playing.") } if m.phase != machineReady { return m.private("You're in the middle of a spin!") } if amount < m.Config.MinBet || amount > m.Config.MaxBet { return m.private(fmt.Sprintf("Slot machine bets must be %s.", betRangeText(m.Config.MinBet, m.Config.MaxBet))) } return m.beginSpinLocked(amount, wallet, fmt.Sprintf("%s sets the bet to %d and pulls the lever.", name, amount), fmt.Sprintf("Bet set to %d. You pull the lever on the slots machine.", amount)) } func (m *Machine) start(name string, wallet Wallet) []Event { m.mu.Lock() defer m.mu.Unlock() if !m.owns(name) { return m.privateTo(name, "You aren't playing at a slot machine.") } if m.autoBet { return m.private("Autobet is active. Type \"stop\" to stop playing.") } if m.phase != machineReady { return m.private("You're in the middle of a spin!") } if m.bet <= 0 { return m.private("Set a wager first with 'bet '.") } return m.beginSpinLocked(m.bet, wallet, fmt.Sprintf("%s pulls the lever on the slots machine.", name), "You pull the lever on the slots machine.") } func (m *Machine) beginSpinLocked(amount int, wallet Wallet, publicMessage, privateMessage string) []Event { if _, err := wallet.Wager(amount); err != nil { return m.private(walletErrorMessage(err, "You don't have enough chips and credits for that bet.")) } m.bet = amount m.spinBet = amount m.player.Wallet = wallet m.stakeFailed = false m.spin = generateSlotSpinWithProfile(m.rng, m.spinBet, m.profile) m.reel, m.clock, m.suspense = 0, 0, false m.phase = machineSpinning return m.playerAction(publicMessage, privateMessage) } func (m *Machine) enableAutoBet(name string, wallet Wallet) []Event { m.mu.Lock() defer m.mu.Unlock() if !m.owns(name) { return m.privateTo(name, "You aren't playing at a slot machine.") } if m.autoBet { return m.private("Autobet is already active.") } if m.phase != machineReady { return m.private("You're in the middle of a spin!") } if m.bet <= 0 { return m.private("Set a wager first with 'bet '.") } m.player.Wallet = wallet m.autoBet = true m.autoCountdown = 0 return nil } func (m *Machine) stopAutoBet(name string) ([]Event, bool) { m.mu.Lock() defer m.mu.Unlock() if !m.owns(name) || !m.autoBet { return nil, false } m.autoBet = false m.autoCountdown = 0 return m.private("Autobet stopped. You remain at the slot machine."), true } func (m *Machine) tick() []Event { m.mu.Lock() defer m.mu.Unlock() if m.autoBet && m.player == nil { // Defensive: autobet can never run without a seated player. m.autoBet, m.autoCountdown = false, 0 } if m.phase == machineReady && m.autoBet { return m.autoBetTick() } if (m.phase != machineSpinning && m.phase != machinePayout) || m.spin == nil { return nil } if m.phase == machinePayout { m.clock++ if m.clock < payoutDelay { return nil } m.clock = 0 m.phase = machineReady return m.finishSpin() } m.clock++ delay := m.Config.SpinTicks if m.suspense && delay < 4 { delay = 4 } if m.clock < delay { return nil } m.clock = 0 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 { m.suspense = true events = append(events, m.public("Two scatters! The remaining reels slow down...")...) } if m.reel < 5 { return events } m.phase = machinePayout m.clock = 0 return events } func (m *Machine) autoBetTick() []Event { m.autoCountdown++ switch m.autoCountdown { case 1: return m.private("Autobet enabled!") case 2: return m.private(`Type "stop" to stop playing`) case 3: m.autoCountdown = 0 if m.bet <= 0 || m.player.Wallet == nil { m.autoBet = false return m.private("Autobet stopped: no wager is set.") } if _, err := m.player.Wallet.Wager(m.bet); err != nil { m.autoBet = false return m.private(walletErrorMessage(err, "Autobet stopped: you don't have enough chips and credits for that bet.")) } m.spinBet = m.bet m.stakeFailed = false m.spin = generateSlotSpinWithProfile(m.rng, m.spinBet, m.profile) m.reel, m.clock, m.suspense = 0, 0, false m.phase = machineSpinning return m.playerAction(fmt.Sprintf("%s starts an automatic spin.", m.player.Name), fmt.Sprintf("Betting... %d credits.", m.bet)) default: return nil } } func (m *Machine) finishSpin() []Event { events := []Event{} result := m.spin.Payout + m.payoutRemainder // Checked conversion: a runaway profile or overflow must clamp at the // economy cap instead of wrapping through a machine-sized int. A NaN or // negative result is a corrupt computation, not a jackpot: clamp it to // zero (fail closed) rather than up to the economy ceiling (fail open). if math.IsNaN(result) || result < 0 { result = 0 } else if result > MaxCredits { result = MaxCredits } credits := int(math.Floor(result)) m.payoutRemainder = result - float64(credits) payFailed := false if credits > 0 && m.player.Wallet != nil { if err := m.player.Wallet.PayCredits(credits); 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. payFailed = true m.stakeFailed = true m.autoBet, m.autoCountdown = false, 0 failedEvents := m.private(payoutFailedMessage()) failedEvents[0].Color = "casino_payout" events = append(events, failedEvents...) } } if credits > 0 && !payFailed { winEvents := m.private(fmt.Sprintf("\nYou win %d credits.", credits)) winEvents[0].Color = "casino_payout" if credits > m.spinBet { winEvents[0].Color = "casino_big_win" } events = append(events, winEvents...) } if len(m.spin.Wins) > 0 { winEvents := m.private(formatWins(m.spin.Wins, m.profile.Ways)) winEvents[0].Color = "casino_win" events = append(events, winEvents...) } // A paid feature in progress never retriggers: scatters landing during a // 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 { 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))...) } 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. m.freeSpins-- m.phase = machineSpinning m.spin = generateSlotSpinWithProfile(m.rng, m.spinBet, m.profile) m.reel, m.clock, m.suspense = 0, 0, false events = append(events, m.public(fmt.Sprintf("%d free spins remain.", m.freeSpins))...) } else { 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): the // stake is no longer outstanding. m.player.Wallet.ResolveStakes() m.stakeFailed = false } if !m.autoBet { events = append(events, m.menuEvents("")...) } } return events } func (m *Machine) leave(name string) []Event { m.mu.Lock() defer m.mu.Unlock() if !m.owns(name) { return nil } if m.phase != machineReady { return m.private("You're in the middle of a spin!") } m.resetSeat() return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventLeft, Player: name, Message: fmt.Sprintf("%s stands up from the slots machine.", name), PrivateMessage: "You stand up from the slot machine.", PrivateTo: name, Color: "casino_action", Public: true}} } // 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. func (m *Machine) resetSeat() { if m.player != nil && m.player.Wallet != nil && !m.stakeFailed { m.player.Wallet.ResolveStakes() } m.player = nil m.bet = 0 m.spin = nil m.spinBet = 0 m.phase = machineReady m.reel, m.clock, m.suspense = 0, 0, false m.freeSpins = 0 m.inFreeSpins = false m.payoutRemainder = 0 m.autoBet, m.autoCountdown = false, 0 m.stakeFailed = false } // markDisconnected removes the player from the machine, forfeiting any // in-flight wager, so the seat is immediately free for someone else. func (m *Machine) markDisconnected(name string) []Event { m.mu.Lock() defer m.mu.Unlock() if !m.owns(name) { return nil } m.resetSeat() return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventLeft, Player: name, Message: fmt.Sprintf("%s stands up from the slots machine.", name), Color: "casino_action", Public: true}} } func (m *Machine) hasParticipant(name string) bool { m.mu.Lock() defer m.mu.Unlock() return m.owns(name) } func (m *Machine) isBusy(name string) bool { m.mu.Lock() defer m.mu.Unlock() return m.owns(name) && (m.autoBet || m.phase == machineSpinning || m.phase == machinePayout) } func (m *Machine) owns(name string) bool { return m.player != nil && m.player.Name == name } func (m *Machine) private(message string) []Event { return m.privateTo(m.player.Name, message) } func (m *Machine) privateTo(name, message string) []Event { return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, PrivateTo: name, Message: message, Color: "casino_action"}} } func (m *Machine) public(message string) []Event { return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, Player: m.player.Name, Message: message, Color: "casino_action", Public: true}} } func (m *Machine) playerAction(publicMessage, privateMessage string) []Event { return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, Player: m.player.Name, Message: publicMessage, PrivateMessage: privateMessage, PrivateTo: m.player.Name, Color: "casino_action", Public: true}} } func (m *Machine) publicSlot(message string, stopped int) []Event { return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, Player: m.player.Name, 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 { count := 0 for row := 0; row < 3; row++ { for reel := 0; reel < reels && reel < 5; reel++ { if grid[row][reel] == trigger { count++ } } } return count } func formatWins(wins []SlotWin, ways int) string { message := fmt.Sprintf("Winning lines/ways (%d-way evaluation):\n", ways) for _, win := range wins { message += fmt.Sprintf(" %s x%d: %d ways x %.4gx bet / %d = %.4f credits\n", win.Symbol, win.Length, win.Ways, win.Multiplier, ways, win.Payout) } return message[:len(message)-1] } var slotCommands = []MenuCommand{ {Command: "bet ", Desc: "Set your wager"}, {Command: "bet max", Desc: "Set the maximum wager"}, {Command: "spin", Desc: "Spin using your current wager"}, {Command: "autospin", Desc: "Start automatic betting"}, {Command: "stop", Desc: "Leave the slot machine"}, } func (m *Machine) privateMenu(prefix string) []Event { events := m.private(prefix) events[0].Color = "casino_menu" if m.phase == machineReady { events[0].Menu = &MenuView{Kind: MenuSlotCommands, Commands: slotCommands} } return events } 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. func (m *Machine) refundAll() []Event { m.mu.Lock() defer m.mu.Unlock() if m.player == nil || m.player.Wallet == nil { return nil } name, wallet := m.player.Name, m.player.Wallet 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}} } 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}} } // idle reports whether no player is seated: only an idle machine may have // its live rules replaced by new room configuration. func (m *Machine) idle() bool { m.mu.Lock() defer m.mu.Unlock() return m.player == nil }