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 // 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 — 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 the triggering stake stays pending until // then. 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 stays pending, so the seat must not consume // (forfeit) it when the player leaves — a refund or startup recovery // returns it. stakeFailed bool } func newMachine(roomID int, cfg MachineConfig, rng *rand.Rand) *Machine { cfg = cfg.Normalize() return &Machine{RoomID: roomID, Config: cfg, phase: machineReady, profile: machineSlotProfile(cfg), 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 { wager, err := wallet.Wager(amount) if 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.stakeID = wager.ID 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 < 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 < slotGridReels { 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 wager, 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.")) } else { m.stakeID = wager.ID } 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 } } // 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 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{} if len(m.spin.Wins) > 0 { matches := m.private(formatWins(m.spin.Wins, m.profile.Ways)) matches[0].Color = "casino_win" events = append(events, matches...) } 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) // 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) } payFailed := false if (!inFeature || featureEnds) && m.player.Wallet != nil { // Credit the return (including zero) and consume the triggering stake // in ONE durable save before announcing any completed settlement. if err := m.player.Wallet.PayCredits(payNow, m.stakeID); err != nil { // 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 failedEvents := m.private(payoutFailedMessage()) failedEvents[0].Color = "casino_payout" events = append(events, failedEvents...) if featureEnds && int(m.featureBank) > 0 { bankEvents := m.private("The house could not credit your banked free-spin winnings either; they stay banked and are paid with your next completed free-spin feature, or when the casino closes.") bankEvents[0].Color = "casino_action" events = append(events, bankEvents...) } } } if featureEnds && !payFailed { // The bank is now part of the settled (or absent-wallet) payout. It is // cleared only once the save succeeded, so a failed settlement leaves // the banked winnings on the machine instead of destroying them: the // next free-spin feature pays them again, and shutdown/refund paths // (refundAll, markDisconnected) still settle or report them. m.featureBank = 0 } if payNow > 0 && !payFailed { message := fmt.Sprintf("\nYou win %d credits.", payNow) if featureEnds { message = fmt.Sprintf("\nThe free spins pay %d credits.", payNow) } winEvents := m.private(message) label := "Spin" if featureEnds { label = "Spin + free spins" } winEvents[0].Settlement = []SettlementLine{{Label: label, Odds: "Varies", Stake: m.spinBet, Returned: payNow, Result: "win"}} winEvents[0].Color = "casino_result_win" if payNow > m.spinBet { winEvents[0].Color = "casino_big_win" } events = append(events, winEvents...) } if !payFailed && payNow == 0 { line := SettlementLine{Label: "Spin", Odds: "Varies", Stake: m.spinBet, Result: "loss"} if inFeature { line.Label = "Spin + free spins" } line.Unresolved = inFeature && !featureEnds receipt := m.private(settlementText([]settlementLine{line})) receipt[0].Settlement = []SettlementLine{line} if line.Unresolved { receipt[0].SettlementNote = fmt.Sprintf("%d credits banked; paid when free spins end.", int(m.featureBank)) } events = append(events, receipt...) } // 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 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))...) } if m.freeSpins > 0 { // 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) 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 { // PayCredits already consumed this round's stake, even on a loss. // Never resolve older stakes retained after failed settlements. m.stakeID = "" m.stakeFailed = false } if !m.autoBet { events = append(events, m.privateMenu("")...) } } 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 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) } m.player = nil m.bet = 0 m.spin = nil m.spinBet = 0 m.stakeID = "" m.phase = machineReady m.reel, m.clock, m.suspense = 0, 0, false m.freeSpins = 0 m.inFreeSpins = false m.payoutRemainder = 0 m.featureBank = 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. // // Already-landed free-spin winnings (featureBank) are NOT part of the // 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() if !m.owns(name) { return nil } // A spin whose reels have all stopped has already been revealed to the // player, so its result is final. Settle it through the normal path // before the seat is reset: otherwise a disconnect during the cosmetic // payout delay forfeits an already-shown win and consumes the stake for a // spin the player watched land. finishSpin consumes the stake itself on a // resolved round (or banks a feature win), so resetSeat below neither // double-pays nor double-consumes. var events []Event if m.phase == machinePayout && m.spin != nil { events = append(events, m.finishSpin()...) } bank := int(m.featureBank) wallet := m.player.Wallet stakeFailed := m.stakeFailed m.resetSeat() events = append(events, 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}) if bank <= 0 || wallet == nil { return events } switch { case stakeFailed: 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 round could not be persisted.", bank), Color: "casino_action", Public: false}) 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 were forfeited.", bank), Color: "casino_action", Public: false}) default: events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, PrivateTo: name, Player: name, Message: fmt.Sprintf("%s's %d banked free-spin credits pay out on disconnect.", name, bank), PrivateMessage: fmt.Sprintf("The house settles your %d banked free-spin winnings.", bank), Color: "casino_action", Public: true}) } return events } 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, Snapshot: &SlotSnapshot{Grid: m.spin.Grid, StoppedReels: stopped}}} } func stoppedScatters(grid [slotGridRows][slotGridReels]string, reels int, trigger string) int { count := 0 for row := 0; row < slotGridRows; row++ { for reel := 0; reel < reels && reel < slotGridReels; reel++ { if grid[row][reel] == trigger { count++ } } } return count } func formatWins(wins []SlotWin, ways int) string { message := fmt.Sprintf("Matches (%d-way evaluation):\n", ways) for _, win := range wins { message += fmt.Sprintf(" %s x%d: %d ways\n", win.Symbol, win.Length, win.Ways) } return message[:len(message)-1] } func (m *Machine) commands() []MenuCommand { if m.autoBet { return []MenuCommand{{Command: "stop", Desc: "Stop automatic betting"}} } commands := []MenuCommand{ {Command: "bet ", Desc: "Set your wager and spin"}, {Command: "bet max", Desc: "Bet the maximum and spin"}, } if m.bet > 0 { commands = append(commands, MenuCommand{Command: "spin", Desc: "Spin using your current wager", Cost: m.bet}, MenuCommand{Command: "autospin", Desc: "Start automatic betting", Cost: m.bet}) } return append(commands, MenuCommand{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{Heading: prefix, ShowBalance: true, Commands: m.commands()} } return events } // 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 // RefundAll nor a stray tick can pay the same round twice. 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 // 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 // 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 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 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 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 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, 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 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, 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 // its live rules replaced by new room configuration. func (m *Machine) idle() bool { m.mu.Lock() defer m.mu.Unlock() return m.player == nil }