diff options
| author | historia <[not public]> | 2026-07-31 17:44:10 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-07-31 17:44:10 -0400 |
| commit | f51424c90dbce7191a31b6e675818c985f0f4539 (patch) | |
| tree | a7845a2e09bd6e83d2d034f5e702a4940f38126a /internal/casino/machine.go | |
| parent | 58758d41488a777d2bc0e787be655363bdd9eb60 (diff) | |
| download | thehouseoficarus-f51424c90dbce7191a31b6e675818c985f0f4539.tar.gz | |
feat: casino framework and basic slot machine
Diffstat (limited to 'internal/casino/machine.go')
| -rw-r--r-- | internal/casino/machine.go | 364 |
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) +} |
