aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/machine.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino/machine.go')
-rw-r--r--internal/casino/machine.go364
1 files changed, 364 insertions, 0 deletions
diff --git a/internal/casino/machine.go b/internal/casino/machine.go
new file mode 100644
index 0000000..d9fa590
--- /dev/null
+++ b/internal/casino/machine.go
@@ -0,0 +1,364 @@
+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 {
+ profile := DefaultHuffAndPuffProfile()
+ if cfg.Payout > 0 {
+ profile = ScaleToRTP(profile, cfg.Payout)
+ }
+ 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 && m.player.Name != name {
+ return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, PrivateTo: name, Message: "That slot machine is occupied."}}
+ }
+ if m.player == nil {
+ 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.")...)
+ }
+ m.player.Connected = true
+ if m.autoBet {
+ return m.private("Autobet is active. Type \"stop\" to stop playing.")
+ }
+ return m.menuEvents("You resume your place at the slot machine.")
+}
+
+func (m *Machine) setBet(name string, amount int) []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 between %d and %d.", m.Config.MinBet, m.Config.MaxBet))
+ }
+ m.bet = amount
+ return m.menuEvents(fmt.Sprintf("Your slot machine bet is set to %d.", 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 <amount>'.")
+ }
+ wager := m.bet
+ if m.freeSpins > 0 {
+ m.spinBet = wager
+ } else {
+ if _, ok := wallet.Wager(wager); !ok {
+ return m.private("You don't have enough chips and credits for that bet.")
+ }
+ m.spinBet = wager
+ }
+ 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
+ if m.freeSpins > 0 {
+ m.freeSpins--
+ return m.playerAction(fmt.Sprintf("%s begins a free spin.", name), "You begin a free spin.")
+ }
+ return m.playerAction(fmt.Sprintf("%s pulls the lever on the slots machine.", name), "You pull the lever on the slots machine.")
+}
+
+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 <amount>'.")
+ }
+ 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.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) >= 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"
+ }
+ if credits >= 2*m.spinBet {
+ winEvents[0].Color = "casino_jackpot_win"
+ }
+ events = append(events, winEvents...)
+ }
+ if len(m.spin.Wins) > 0 {
+ winEvents := m.private(formatWins(m.spin.Wins))
+ winEvents[0].Color = "casino_win"
+ events = append(events, winEvents...)
+ }
+ if m.spin.ScatterCount >= 3 && m.freeSpins == 0 {
+ m.freeSpins = 8
+ events = append(events, m.public("Three scatter symbols trigger 8 free spins!")...)
+ }
+ 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.player = nil
+ m.bet = 0
+ 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}}
+}
+
+func (m *Machine) markDisconnected(name string) []Event {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.owns(name) {
+ m.player.Connected = false
+ }
+ return nil
+}
+
+func (m *Machine) markConnected(name string) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.owns(name) {
+ m.player.Connected = 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) int {
+ count := 0
+ for row := 0; row < 3; row++ {
+ for reel := 0; reel < reels && reel < 5; reel++ {
+ if grid[row][reel] == "scatter" {
+ count++
+ }
+ }
+ }
+ return count
+}
+
+func formatWins(wins []SlotWin) string {
+ message := "Winning lines/ways (243-way evaluation):\n"
+ for _, win := range wins {
+ message += fmt.Sprintf(" %s x%d: %d ways x %.4gx bet / 243 = %.4f credits\n", win.Symbol, win.Length, win.Ways, win.Multiplier, win.Payout)
+ }
+ return message[:len(message)-1]
+}
+
+func (m *Machine) privateMenu(prefix string) []Event {
+ if m.phase != machineReady {
+ events := m.private(prefix)
+ events[0].Color = "casino_menu"
+ return events
+ }
+ menu := "Slot machine commands:\n bet <amount> Set your wager\n bet max Set the maximum wager\n spin Spin using your current wager\n autospin Start automatic betting\n stop Leave the slot machine"
+ if prefix == "" {
+ prefix = "\n" + menu
+ } else {
+ prefix += "\n\n" + menu
+ }
+ events := m.private(prefix)
+ events[0].Color = "casino_menu"
+ return events
+}
+
+func (m *Machine) menuEvents(prefix string) []Event {
+ return m.privateMenu(prefix)
+}