package casino import ( "fmt" "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 spinBet int payoutRemainder float64 profile SlotProfile rng *rand.Rand suspense bool autoBet bool autoCountdown int } 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.private("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(name, 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.private("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(name, 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(name string, amount int, wallet Wallet, publicMessage, privateMessage string) []Event { if _, ok := wallet.Wager(amount); !ok { return m.private("You don't have enough chips and credits for that bet.") } m.bet = amount m.spinBet = amount m.player.Wallet = wallet 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.private("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 _, ok := m.player.Wallet.Wager(m.bet); !ok { m.autoBet = false return m.private("Autobet stopped: you don't have enough chips and credits for that bet.") } m.spinBet = m.bet 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 credits := int(result) m.payoutRemainder = result - float64(credits) if credits > 0 && m.player.Wallet != nil { m.player.Wallet.PayCredits(credits) } if credits > 0 { 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...) } if m.spin.ScatterCount >= 3 && m.freeSpins == 0 { m.freeSpins = m.profile.FreeSpinCount events = append(events, m.public(fmt.Sprintf("Three scatter symbols trigger %d free spins!", m.profile.FreeSpinCount))...) } if m.freeSpins > 0 { 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 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. func (m *Machine) resetSeat() { 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.payoutRemainder = 0 m.autoBet, m.autoCountdown = false, 0 } // 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 []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, PrivateTo: m.player.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) }