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 | |
| parent | 58758d41488a777d2bc0e787be655363bdd9eb60 (diff) | |
| download | thehouseoficarus-f51424c90dbce7191a31b6e675818c985f0f4539.tar.gz | |
feat: casino framework and basic slot machine
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/behavior/behavior.go | 3 | ||||
| -rw-r--r-- | internal/casino/machine.go | 364 | ||||
| -rw-r--r-- | internal/casino/manager.go | 238 | ||||
| -rw-r--r-- | internal/casino/manager_test.go | 103 | ||||
| -rw-r--r-- | internal/casino/slots.go | 278 | ||||
| -rw-r--r-- | internal/casino/slots_test.go | 44 | ||||
| -rw-r--r-- | internal/casino/table.go | 217 | ||||
| -rw-r--r-- | internal/casino/types.go | 121 | ||||
| -rw-r--r-- | internal/game/act_effects.go | 7 | ||||
| -rw-r--r-- | internal/game/casino.go | 319 | ||||
| -rw-r--r-- | internal/game/casino_test.go | 29 | ||||
| -rw-r--r-- | internal/game/cmd_move.go | 4 | ||||
| -rw-r--r-- | internal/game/cmd_quit.go | 4 | ||||
| -rw-r--r-- | internal/game/cmd_registry.go | 5 | ||||
| -rw-r--r-- | internal/game/cmd_stop.go | 12 | ||||
| -rw-r--r-- | internal/game/core_login_char.go | 1 | ||||
| -rw-r--r-- | internal/game/game.go | 27 | ||||
| -rw-r--r-- | internal/game/look_entities.go | 37 | ||||
| -rw-r--r-- | internal/game/render_help.go | 4 | ||||
| -rw-r--r-- | internal/game/tick_systems.go | 3 | ||||
| -rw-r--r-- | internal/game/tick_systems_test.go | 3 | ||||
| -rw-r--r-- | internal/net/server.go | 22 | ||||
| -rw-r--r-- | internal/world/room.go | 27 |
23 files changed, 1841 insertions, 31 deletions
diff --git a/internal/behavior/behavior.go b/internal/behavior/behavior.go index 7dab514..116918e 100644 --- a/internal/behavior/behavior.go +++ b/internal/behavior/behavior.go @@ -69,6 +69,7 @@ type Step struct { Teleport int `yaml:"teleport,omitempty" json:"teleport,omitempty"` Heal int `yaml:"heal,omitempty" json:"heal,omitempty"` Credits int `yaml:"credits,omitempty" json:"credits,omitempty"` + StartGame string `yaml:"start_game,omitempty" json:"start_game,omitempty"` ApsNode bool `yaml:"aps_node,omitempty" json:"aps_node,omitempty"` DropTable []DropEntry `yaml:"drop_table,omitempty" json:"drop_table,omitempty"` } @@ -79,7 +80,7 @@ func (s Step) HasEffects() bool { s.SpawnMob != nil || s.DespawnMob != "" || len(s.SetGlobalFlags) > 0 || len(s.SetPlayerFlags) > 0 || s.GiveItem != "" || s.TakeItem != "" || s.Teleport != 0 || s.Heal != 0 || - s.Credits != 0 || s.ApsNode || len(s.DropTable) > 0 + s.Credits != 0 || s.StartGame != "" || s.ApsNode || len(s.DropTable) > 0 } // Trigger is the one conditional wrapper used by every event block in the 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) +} diff --git a/internal/casino/manager.go b/internal/casino/manager.go new file mode 100644 index 0000000..d6a3825 --- /dev/null +++ b/internal/casino/manager.go @@ -0,0 +1,238 @@ +package casino + +import ( + "fmt" + "math/rand" + "sort" + "sync" +) + +type Manager struct { + mu sync.Mutex + tables map[string]*Table + machines map[string]*Machine + rng *rand.Rand +} + +func NewManager(seed int64) *Manager { + return &Manager{ + tables: make(map[string]*Table), + machines: make(map[string]*Machine), + rng: rand.New(rand.NewSource(seed)), + } +} + +func (m *Manager) EnsureMachine(roomID int, cfg MachineConfig) *Machine { + cfg = cfg.Normalize() + key := tableKey(roomID, cfg.ID) + m.mu.Lock() + defer m.mu.Unlock() + if machine := m.machines[key]; machine != nil { + return machine + } + machine := newMachine(roomID, cfg, m.rng) + m.machines[key] = machine + return machine +} + +func tableKey(roomID int, tableID string) string { + return fmt.Sprintf("%d:%s", roomID, tableID) +} + +func (m *Manager) EnsureTable(roomID int, cfg TableConfig) *Table { + cfg = cfg.Normalize() + key := tableKey(roomID, cfg.ID) + m.mu.Lock() + defer m.mu.Unlock() + if table := m.tables[key]; table != nil { + return table + } + table := newTable(roomID, cfg, m.rng) + m.tables[key] = table + return table +} + +func (m *Manager) Table(roomID int, tableID string) *Table { + m.mu.Lock() + defer m.mu.Unlock() + return m.tables[tableKey(roomID, tableID)] +} + +func (m *Manager) Tick() []Event { + m.mu.Lock() + tables := make([]*Table, 0, len(m.tables)) + for _, table := range m.tables { + tables = append(tables, table) + } + m.mu.Unlock() + sort.Slice(tables, func(i, j int) bool { + if tables[i].RoomID != tables[j].RoomID { + return tables[i].RoomID < tables[j].RoomID + } + return tables[i].Config.ID < tables[j].Config.ID + }) + var events []Event + for _, table := range tables { + events = append(events, table.tick()...) + } + m.mu.Lock() + machines := make([]*Machine, 0, len(m.machines)) + for _, machine := range m.machines { + machines = append(machines, machine) + } + m.mu.Unlock() + sort.Slice(machines, func(i, j int) bool { + if machines[i].RoomID != machines[j].RoomID { + return machines[i].RoomID < machines[j].RoomID + } + return machines[i].Config.ID < machines[j].Config.ID + }) + for _, machine := range machines { + events = append(events, machine.tick()...) + } + return events +} + +func (m *Manager) JoinMachine(roomID int, cfg MachineConfig, name string) []Event { + return m.EnsureMachine(roomID, cfg).join(name) +} + +func (m *Manager) SetMachineBet(roomID int, machineID, name string, amount int) []Event { + m.mu.Lock() + machine := m.machines[tableKey(roomID, machineID)] + m.mu.Unlock() + if machine == nil { + return nil + } + return machine.setBet(name, amount) +} + +func (m *Manager) StartMachine(roomID int, machineID, name string, wallet Wallet) []Event { + m.mu.Lock() + machine := m.machines[tableKey(roomID, machineID)] + m.mu.Unlock() + if machine == nil { + return nil + } + return machine.start(name, wallet) +} + +func (m *Manager) EnableMachineAutoBet(roomID int, machineID, name string, wallet Wallet) []Event { + m.mu.Lock() + machine := m.machines[tableKey(roomID, machineID)] + m.mu.Unlock() + if machine == nil { + return nil + } + return machine.enableAutoBet(name, wallet) +} + +func (m *Manager) StopMachineAutoBet(roomID int, machineID, name string) ([]Event, bool) { + m.mu.Lock() + machine := m.machines[tableKey(roomID, machineID)] + m.mu.Unlock() + if machine == nil { + return nil, false + } + return machine.stopAutoBet(name) +} + +func (m *Manager) LeaveMachine(roomID int, machineID, name string) []Event { + m.mu.Lock() + machine := m.machines[tableKey(roomID, machineID)] + m.mu.Unlock() + if machine == nil { + return nil + } + return machine.leave(name) +} + +func (m *Manager) Join(roomID int, cfg TableConfig, name string) []Event { + return m.EnsureTable(roomID, cfg).join(name) +} + +func (m *Manager) Leave(roomID int, tableID, name string) []Event { + if table := m.Table(roomID, tableID); table != nil { + return table.leave(name) + } + return nil +} + +func (m *Manager) Bet(roomID int, tableID, name string, amount int, wallet Wallet) []Event { + if table := m.Table(roomID, tableID); table != nil { + return table.bet(name, amount, wallet) + } + return []Event{{RoomID: roomID, TableID: tableID, Type: EventResult, Player: name, PrivateTo: name, Message: "That game is not available here."}} +} + +func (m *Manager) MarkDisconnected(name string) []Event { + m.mu.Lock() + tables := make([]*Table, 0, len(m.tables)) + for _, table := range m.tables { + tables = append(tables, table) + } + m.mu.Unlock() + var events []Event + for _, table := range tables { + events = append(events, table.markDisconnected(name)...) + } + m.mu.Lock() + machines := make([]*Machine, 0, len(m.machines)) + for _, machine := range m.machines { + machines = append(machines, machine) + } + m.mu.Unlock() + for _, machine := range machines { + events = append(events, machine.markDisconnected(name)...) + } + return events +} + +func (m *Manager) MarkConnected(name string) { + m.mu.Lock() + defer m.mu.Unlock() + for _, table := range m.tables { + table.markConnected(name) + } + for _, machine := range m.machines { + machine.markConnected(name) + } +} + +func (m *Manager) Participation(name string) (*Table, bool) { + m.mu.Lock() + defer m.mu.Unlock() + for _, table := range m.tables { + if table.hasParticipant(name) { + return table, true + } + } + for _, machine := range m.machines { + if machine.hasParticipant(name) { + return nil, true + } + } + return nil, false +} + +func (m *Manager) MachineFor(name string) (*Machine, bool) { + m.mu.Lock() + defer m.mu.Unlock() + for _, machine := range m.machines { + if machine.hasParticipant(name) { + return machine, true + } + } + return nil, false +} + +func (m *Manager) MachineBusy(name string) bool { + m.mu.Lock() + defer m.mu.Unlock() + for _, machine := range m.machines { + if machine.isBusy(name) { + return true + } + } + return false +} diff --git a/internal/casino/manager_test.go b/internal/casino/manager_test.go new file mode 100644 index 0000000..245133d --- /dev/null +++ b/internal/casino/manager_test.go @@ -0,0 +1,103 @@ +package casino + +import "testing" + +type testWallet struct { + chips, credits, paid int +} + +func (w *testWallet) Wager(amount int) (Wager, bool) { + if w.chips+w.credits < amount { + return Wager{}, false + } + chips := amount + if chips > w.chips { + chips = w.chips + } + w.chips -= chips + credits := amount - chips + w.credits -= credits + return Wager{Total: amount, Chips: chips, Credits: credits}, true +} + +func (w *testWallet) PayCredits(amount int) { + w.paid += amount +} + +func TestMixedWagerUsesChipsFirst(t *testing.T) { + w := &testWallet{chips: 7, credits: 50} + wager, ok := w.Wager(12) + if !ok || wager.Chips != 7 || wager.Credits != 5 || w.chips != 0 || w.credits != 45 { + t.Fatalf("unexpected wager: %+v wallet=%+v", wager, w) + } +} + +func TestSoloBetResolvesImmediately(t *testing.T) { + m := NewManager(1) + cfg := TableConfig{ID: "slots", Game: GameSlots} + m.Join(1, cfg, "alice") + w := &testWallet{credits: 100} + events := m.Bet(1, "slots", "alice", 10, w) + found := false + for _, event := range events { + if event.Type == EventRoundStarted { + found = true + } + } + if !found { + t.Fatal("solo bet did not resolve immediately") + } +} + +func TestMultiplePlayersWaitForBets(t *testing.T) { + m := NewManager(1) + cfg := TableConfig{ID: "slots", Game: GameSlots, BettingTicks: 2} + m.Join(1, cfg, "alice") + m.Join(1, cfg, "bob") + wa, wb := &testWallet{credits: 100}, &testWallet{credits: 100} + events := m.Bet(1, "slots", "alice", 10, wa) + for _, event := range events { + if event.Type == EventRoundStarted { + t.Fatal("round started before all players bet or timeout") + } + } + m.Bet(1, "slots", "bob", 10, wb) +} + +func TestMachineAutobetCountdownAndStop(t *testing.T) { + m := NewManager(1) + cfg := MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 1} + m.JoinMachine(1, cfg, "alice") + m.SetMachineBet(1, "slots", "alice", 10) + w := &testWallet{credits: 100} + if events := m.EnableMachineAutoBet(1, "slots", "alice", w); len(events) != 0 { + t.Fatalf("enabling autobet emitted events before countdown: %+v", events) + } + for i, want := range []string{"Autobet enabled!", `Type "stop" to stop playing`, "Betting... 10 credits."} { + events := m.Tick() + if len(events) == 0 || eventText(events) != want { + t.Fatalf("tick %d message=%q events=%+v, want %q", i+1, eventText(events), events, want) + } + } + if !m.MachineBusy("alice") { + t.Fatal("autobet did not start a spin after countdown") + } + if events, stopped := m.StopMachineAutoBet(1, "slots", "alice"); !stopped || len(events) != 1 { + t.Fatalf("stop autobet events=%+v stopped=%v", events, stopped) + } + if _, playing := m.MachineFor("alice"); !playing { + t.Fatal("stopping autobet removed the player from the machine") + } +} + +func eventText(events []Event) string { + for _, event := range events { + if event.PrivateMessage != "" { + return event.PrivateMessage + } + if event.Message != "" { + return event.Message + } + } + return "" +} diff --git a/internal/casino/slots.go b/internal/casino/slots.go new file mode 100644 index 0000000..9d53bb1 --- /dev/null +++ b/internal/casino/slots.go @@ -0,0 +1,278 @@ +package casino + +import ( + "fmt" + "math" + "math/rand" +) + +type SlotSymbol struct { + ID string `yaml:"id"` + Weight int `yaml:"weight"` + Payout map[int]float64 `yaml:"payout"` +} + +type SlotProfile struct { + Symbols []SlotSymbol `yaml:"symbols"` + Rows int `yaml:"rows"` + Reels int `yaml:"reels"` + FreeSpinTrigger string `yaml:"free_spin_trigger"` + FreeSpinCount int `yaml:"free_spin_count"` + Ways int `yaml:"ways"` + TargetRTP float64 `yaml:"payout"` +} + +type SlotSpin struct { + Grid [3][5]string + Payout float64 + ScatterCount int + Wins []SlotWin +} + +type SlotWin struct { + Symbol string + Length int + Ways int + Multiplier float64 + Payout float64 +} + +// slotResult preserves the small rules-engine hook used by the unfinished +// generic table implementation while slots move onto SlotMachine. +type slotSpin struct { + Display string + Payout int +} + +func slotResult(rng *rand.Rand, bet int) slotSpin { + spin := generateSlotSpin(rng, bet) + return slotSpin{Display: spin.render(5), Payout: int(math.Floor(spin.Payout + 0.5))} +} + +func DefaultHuffAndPuffProfile() SlotProfile { + return SlotProfile{ + Rows: 3, Reels: 5, Ways: 243, + FreeSpinTrigger: "scatter", FreeSpinCount: 8, + Symbols: []SlotSymbol{ + {ID: "straw", Weight: 22, Payout: map[int]float64{3: .5, 4: 1, 5: 2}}, + {ID: "stick", Weight: 12, Payout: map[int]float64{3: 1, 4: 2, 5: 5}}, + {ID: "brick", Weight: 7, Payout: map[int]float64{3: 2, 4: 5, 5: 15}}, + {ID: "hat", Weight: 4, Payout: map[int]float64{3: 5, 4: 15, 5: 50}}, + {ID: "wolf", Weight: 2, Payout: map[int]float64{3: 10, 4: 50, 5: 250}}, + {ID: "scatter", Weight: 3}, + }, + } +} + +func (p SlotProfile) Normalize() SlotProfile { + if p.Rows <= 0 { + p.Rows = 3 + } + if p.Reels <= 0 { + p.Reels = 5 + } + if p.Ways <= 0 { + p.Ways = 243 + } + if p.FreeSpinCount <= 0 { + p.FreeSpinCount = 8 + } + if len(p.Symbols) == 0 { + return DefaultHuffAndPuffProfile() + } + return p +} + +func (p SlotProfile) symbol(id string) *SlotSymbol { + for i := range p.Symbols { + if p.Symbols[i].ID == id { + return &p.Symbols[i] + } + } + return nil +} + +func generateSlotSpin(rng *rand.Rand, bet int) *SlotSpin { + return generateSlotSpinWithProfile(rng, bet, DefaultHuffAndPuffProfile()) +} + +func generateSlotSpinWithProfile(rng *rand.Rand, bet int, profile SlotProfile) *SlotSpin { + p := profile.Normalize() + spin := &SlotSpin{} + for reel := 0; reel < p.Reels && reel < 5; reel++ { + for row := 0; row < p.Rows && row < 3; row++ { + symbol := weightedSymbol(rng, p.Symbols) + spin.Grid[row][reel] = symbol + if symbol == p.FreeSpinTrigger { + spin.ScatterCount++ + } + } + } + spin.Payout, spin.Wins = evaluateSlot(spin.Grid, bet, p) + return spin +} + +func weightedSymbol(rng *rand.Rand, symbols []SlotSymbol) string { + total := 0 + for _, symbol := range symbols { + if symbol.Weight > 0 { + total += symbol.Weight + } + } + if total <= 0 { + return "" + } + n := rng.Intn(total) + for _, symbol := range symbols { + if symbol.Weight <= 0 { + continue + } + if n < symbol.Weight { + return symbol.ID + } + n -= symbol.Weight + } + return symbols[len(symbols)-1].ID +} + +func evaluateSlot(grid [3][5]string, bet int, profile SlotProfile) (float64, []SlotWin) { + p := profile.Normalize() + if bet <= 0 { + return 0, nil + } + total := 0.0 + var wins []SlotWin + for _, symbol := range p.Symbols { + length, ways := matchingLength(grid, symbol.ID) + if length >= 3 && symbol.Payout[length] > 0 { + payout := float64(bet) * symbol.Payout[length] * float64(ways) / 243.0 + total += payout + wins = append(wins, SlotWin{Symbol: symbol.ID, Length: length, Ways: ways, Multiplier: symbol.Payout[length], Payout: payout}) + } + } + return total, wins +} + +func matchingLength(grid [3][5]string, symbol string) (int, int) { + ways := 1 + length := 0 + for reel := 0; reel < 5; reel++ { + count := 0 + for row := 0; row < 3; row++ { + if grid[row][reel] == symbol { + count++ + } + } + if count == 0 { + break + } + ways *= count + length = reel + 1 + } + if length < 3 { + return length, 0 + } + return length, ways +} + +func (s *SlotSpin) render(stoppedReels int) string { + var out string + out += "+---------+---------+---------+---------+---------+\n" + for row := 0; row < 3; row++ { + out += "|" + for reel := 0; reel < 5; reel++ { + value := "..." + if reel < stoppedReels { + value = s.Grid[row][reel] + } + out += fmt.Sprintf(" %-7s |", value) + } + out += "\n" + } + out += "+---------+---------+---------+---------+---------+" + return out +} + +// CalculateRTP returns the exact mathematical RTP for the independent-cell +// model used by the baseline machine, before integer-credit rounding. +func CalculateRTP(profile SlotProfile) float64 { + p := profile.Normalize() + totalWeight := 0 + for _, symbol := range p.Symbols { + if symbol.Weight > 0 { + totalWeight += symbol.Weight + } + } + if totalWeight == 0 { + return 0 + } + baseRTP := 0.0 + scatterProbability := 0.0 + for _, symbol := range p.Symbols { + probability := float64(symbol.Weight) / float64(totalWeight) + if symbol.ID == p.FreeSpinTrigger { + scatterProbability = probability + } + if symbol.ID == p.FreeSpinTrigger { + continue + } + for length := 3; length <= 5; length++ { + ways := math.Pow(float64(p.Rows), float64(length)) + nextReelNoMatch := 1.0 + if length < 5 { + nextReelNoMatch = math.Pow(1-probability, float64(p.Rows)) + } + baseRTP += symbol.Payout[length] * ways * math.Pow(probability, float64(length)) * nextReelNoMatch / float64(p.Ways) + } + } + // The baseline feature awards a fixed number of automatic spins and does + // not retrigger during those spins. Its expected value is therefore the + // trigger probability multiplied by the free-spin count and base RTP. + triggerProbability := 0.0 + for scatters := 3; scatters <= p.Rows*p.Reels; scatters++ { + triggerProbability += binomial(p.Rows*p.Reels, scatters) * math.Pow(scatterProbability, float64(scatters)) * math.Pow(1-scatterProbability, float64(p.Rows*p.Reels-scatters)) + } + return baseRTP * (1 + triggerProbability*float64(p.FreeSpinCount)) +} + +func binomial(n, k int) float64 { + if k < 0 || k > n { + return 0 + } + result := 1.0 + for i := 1; i <= k; i++ { + result *= float64(n-k+i) / float64(i) + } + return result +} + +func ValidateRTP(profile SlotProfile, tolerance float64) error { + if profile.TargetRTP <= 0 { + return nil + } + actual := CalculateRTP(profile) + if math.Abs(actual-profile.TargetRTP) > tolerance { + return fmt.Errorf("slot profile RTP %.6f does not match target %.6f", actual, profile.TargetRTP) + } + return nil +} + +// ScaleToRTP adjusts the configured paytable, without changing reel weights or +// feature probabilities, so the profile's exact mathematical RTP reaches the +// requested target. Runtime payouts retain fractional credits internally until +// they can be paid as whole credits. +func ScaleToRTP(profile SlotProfile, target float64) SlotProfile { + profile = profile.Normalize() + actual := CalculateRTP(profile) + if actual <= 0 || target <= 0 { + return profile + } + scale := target / actual + for i := range profile.Symbols { + for length, payout := range profile.Symbols[i].Payout { + profile.Symbols[i].Payout[length] = payout * scale + } + } + profile.TargetRTP = target + return profile +} diff --git a/internal/casino/slots_test.go b/internal/casino/slots_test.go new file mode 100644 index 0000000..22e39bf --- /dev/null +++ b/internal/casino/slots_test.go @@ -0,0 +1,44 @@ +package casino + +import "testing" + +func TestCalculateRTPUsesLongestWayOnly(t *testing.T) { + profile := SlotProfile{ + Rows: 3, Reels: 5, Ways: 243, + Symbols: []SlotSymbol{{ID: "x", Weight: 1, Payout: map[int]float64{3: 1, 4: 2, 5: 3}}}, + FreeSpinTrigger: "scatter", FreeSpinCount: 8, + } + if got := CalculateRTP(profile); got != 3 { + t.Fatalf("RTP = %v, want 3", got) + } +} + +func TestValidateRTP(t *testing.T) { + profile := SlotProfile{Rows: 3, Reels: 5, Ways: 243, TargetRTP: 3, + Symbols: []SlotSymbol{{ID: "x", Weight: 1, Payout: map[int]float64{5: 3}}}} + if err := ValidateRTP(profile, 0.000001); err != nil { + t.Fatal(err) + } +} + +func TestScaleToRTP(t *testing.T) { + profile := ScaleToRTP(DefaultHuffAndPuffProfile(), 0.97) + if err := ValidateRTP(profile, 0.000000001); err != nil { + t.Fatal(err) + } +} + +func TestEvaluateSlotReportsWinningWays(t *testing.T) { + profile := SlotProfile{Rows: 3, Reels: 5, Ways: 243, + Symbols: []SlotSymbol{{ID: "x", Weight: 1, Payout: map[int]float64{5: 2}}}} + grid := [3][5]string{} + for row := range grid { + for reel := range grid[row] { + grid[row][reel] = "x" + } + } + payout, wins := evaluateSlot(grid, 10, profile) + if payout != 20 || len(wins) != 1 || wins[0].Ways != 243 || wins[0].Length != 5 { + t.Fatalf("payout=%v wins=%+v", payout, wins) + } +} diff --git a/internal/casino/table.go b/internal/casino/table.go new file mode 100644 index 0000000..1b9d0b5 --- /dev/null +++ b/internal/casino/table.go @@ -0,0 +1,217 @@ +package casino + +import ( + "fmt" + "math/rand" + "sync" +) + +type tablePhase string + +const ( + phaseWaiting tablePhase = "waiting" + phaseBetting tablePhase = "betting" +) + +type Table struct { + mu sync.Mutex + RoomID int + Config TableConfig + + participants map[string]*Participant + order []string + phase tablePhase + deadline int + clock int + rng *rand.Rand +} + +func newTable(roomID int, cfg TableConfig, rng *rand.Rand) *Table { + return &Table{ + RoomID: roomID, + Config: cfg, + phase: phaseWaiting, + participants: make(map[string]*Participant), + rng: rng, + } +} + +func (t *Table) join(name string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if p := t.participants[name]; p != nil { + p.Connected = true + return []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventJoined, Player: name, PrivateTo: name, Message: fmt.Sprintf("You resume your place at the %s table.", t.Config.ID)}} + } + if len(t.participants) >= t.Config.MaxPlayers { + return []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventResult, Player: name, PrivateTo: name, Message: "That table is full."}} + } + t.participants[name] = &Participant{Name: name, Connected: true} + t.order = append(t.order, name) + return []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventJoined, Player: name, Message: fmt.Sprintf("%s joins the %s table.", name, t.Config.ID), Public: true}} +} + +func (t *Table) leave(name string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + p := t.participants[name] + if p == nil { + return nil + } + if t.phase != phaseWaiting { + return []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventResult, Player: name, PrivateTo: name, Message: "You're in the middle of a game!"}} + } + delete(t.participants, name) + for i, n := range t.order { + if n == name { + t.order = append(t.order[:i], t.order[i+1:]...) + break + } + } + events := []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventLeft, Player: name, Message: fmt.Sprintf("%s stops playing at the %s table.", name, t.Config.ID), Public: true}} + if len(t.participants) == 0 { + events = append(events, Event{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventClosed, Message: fmt.Sprintf("The %s table closes.", t.Config.ID), Public: true}) + } + return events +} + +func (t *Table) bet(name string, amount int, wallet Wallet) []Event { + t.mu.Lock() + defer t.mu.Unlock() + p := t.participants[name] + if p == nil { + return []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventResult, Player: name, PrivateTo: name, Message: "You aren't playing at that table."}} + } + if t.phase != phaseWaiting && t.phase != phaseBetting { + return []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventResult, Player: name, PrivateTo: name, Message: "That table isn't accepting bets."}} + } + if amount < t.Config.MinBet || amount > t.Config.MaxBet { + return []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventResult, Player: name, PrivateTo: name, Message: fmt.Sprintf("Bets must be between %d and %d.", t.Config.MinBet, t.Config.MaxBet)}} + } + if p.HasBet { + return []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventResult, Player: name, PrivateTo: name, Message: "You have already bet this round."}} + } + wager, ok := wallet.Wager(amount) + if !ok { + return []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventResult, Player: name, PrivateTo: name, Message: "You don't have enough chips and credits for that bet."}} + } + p.HasBet = true + p.Wager = wager + p.Wallet = wallet + if t.phase == phaseWaiting && len(t.participants) > 1 { + t.phase = phaseBetting + t.clock = 0 + t.deadline = t.Config.BettingTicks + } + events := []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventBetPlaced, Player: name, Message: fmt.Sprintf("%s bets %d credits at the %s table.", name, amount, t.Config.ID), Public: true}} + if t.phase == phaseBetting && t.clock == 0 { + events = append(events, Event{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventBetting, Message: fmt.Sprintf("Betting is open at the %s table for %d ticks.", t.Config.ID, t.deadline), Public: true}) + } + if len(t.participants) == 1 || t.allBet() { + events = append(events, t.resolveRound()...) + } + return events +} + +func (t *Table) tick() []Event { + t.mu.Lock() + defer t.mu.Unlock() + if t.phase != phaseBetting { + return nil + } + t.clock++ + if t.allBet() { + return t.resolveRound() + } + if t.clock < t.deadline { + return nil + } + var events []Event + for _, name := range t.order { + p := t.participants[name] + if p != nil && !p.HasBet { + events = append(events, Event{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventTimedOut, Player: name, Message: fmt.Sprintf("%s sits out this round.", name), Public: true}) + } + } + if t.anyBet() { + events = append(events, t.resolveRound()...) + } else { + t.resetRound() + } + return events +} + +func (t *Table) resolveRound() []Event { + var events []Event + for _, name := range t.order { + p := t.participants[name] + if p == nil || !p.HasBet { + continue + } + result := slotResult(t.rng, p.Wager.Total) + message := fmt.Sprintf("%s spins: %s", name, result.Display) + events = append(events, Event{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventRoundStarted, Player: name, Message: message, Public: true}) + if result.Payout > 0 { + if p.Wallet != nil { + p.Wallet.PayCredits(result.Payout) + } + events = append(events, Event{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventPayout, Player: name, PrivateTo: name, Message: fmt.Sprintf("You win %d credits!", result.Payout)}) + } + } + t.resetRound() + return events +} + +func (t *Table) resetRound() { + t.phase = phaseWaiting + t.clock = 0 + t.deadline = 0 + for _, p := range t.participants { + p.HasBet = false + p.Wager = Wager{} + } +} + +func (t *Table) allBet() bool { + if len(t.participants) == 0 { + return false + } + for _, p := range t.participants { + if !p.HasBet { + return false + } + } + return true +} + +func (t *Table) anyBet() bool { + for _, p := range t.participants { + if p.HasBet { + return true + } + } + return false +} + +func (t *Table) markDisconnected(name string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if p := t.participants[name]; p != nil { + p.Connected = false + } + return nil +} + +func (t *Table) markConnected(name string) { + t.mu.Lock() + defer t.mu.Unlock() + if p := t.participants[name]; p != nil { + p.Connected = true + } +} + +func (t *Table) hasParticipant(name string) bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.participants[name] != nil +} diff --git a/internal/casino/types.go b/internal/casino/types.go new file mode 100644 index 0000000..f284869 --- /dev/null +++ b/internal/casino/types.go @@ -0,0 +1,121 @@ +package casino + +import "fmt" + +type GameName string + +const ( + GameSlots GameName = "slots" +) + +type TableConfig struct { + ID string `yaml:"id"` + Game GameName `yaml:"game"` + MaxPlayers int `yaml:"max_players,omitempty"` + BettingTicks int `yaml:"betting_ticks,omitempty"` + MinBet int `yaml:"min_bet,omitempty"` + MaxBet int `yaml:"max_bet,omitempty"` +} + +type MachineConfig struct { + ID string `yaml:"id"` + Game GameName `yaml:"game"` + Variant string `yaml:"variant,omitempty"` + MinBet int `yaml:"min_bet,omitempty"` + MaxBet int `yaml:"max_bet,omitempty"` + SpinTicks int `yaml:"spin_ticks,omitempty"` + Payout float64 `yaml:"payout,omitempty"` +} + +func (c MachineConfig) Normalize() MachineConfig { + if c.MinBet <= 0 { + c.MinBet = 1 + } + if c.MaxBet <= 0 { + c.MaxBet = int(^uint(0) >> 1) + } + if c.SpinTicks <= 0 { + c.SpinTicks = 2 + } + if c.Variant == "" { + c.Variant = "huff_and_even_more_puff" + } + if c.Payout <= 0 { + c.Payout = 0.97 + } + return c +} + +func (c TableConfig) Normalize() TableConfig { + if c.MaxPlayers <= 0 { + c.MaxPlayers = 8 + } + if c.BettingTicks <= 0 { + c.BettingTicks = 20 + } + if c.MinBet <= 0 { + c.MinBet = 1 + } + if c.MaxBet <= 0 { + c.MaxBet = int(^uint(0) >> 1) + } + return c +} + +type Wager struct { + Total int + Chips int + Credits int +} + +type Wallet interface { + Wager(amount int) (Wager, bool) + PayCredits(amount int) +} + +type EventType string + +const ( + EventJoined EventType = "joined" + EventBetting EventType = "betting" + EventBetPlaced EventType = "bet_placed" + EventRoundStarted EventType = "round_started" + EventResult EventType = "result" + EventPayout EventType = "payout" + EventTimedOut EventType = "timed_out" + EventLeft EventType = "left" + EventClosed EventType = "closed" +) + +type Event struct { + RoomID int + TableID string + Type EventType + Player string + Message string + PrivateMessage string + PrivateTo string + Color string + Public bool + Slot *SlotSnapshot +} + +type SlotSnapshot struct { + Grid [3][5]string + StoppedReels int +} + +func (e Event) String() string { + if e.Message != "" { + return e.Message + } + return fmt.Sprintf("%s at %s", e.Type, e.TableID) +} + +type Participant struct { + Name string + Connected bool + HasBet bool + Wager Wager + Wallet Wallet +} diff --git a/internal/game/act_effects.go b/internal/game/act_effects.go index cadd05e..d1effac 100644 --- a/internal/game/act_effects.go +++ b/internal/game/act_effects.go @@ -177,7 +177,12 @@ func (g *Game) applyStep(sess *net.Session, p *player.Player, step *behavior.Ste } } - // 11/12. spawn_mob / despawn_mob (player-owned / owner-filtered). + // 11. casino game join. The table is resolved from the player's current room. + if step.StartGame != "" { + g.startCasinoGame(sess, p, step.StartGame) + } + + // 12/13. spawn_mob / despawn_mob (player-owned / owner-filtered). if step.SpawnMob != nil { g.spawnTriggerMob(sess, p, step.SpawnMob, roomID) } diff --git a/internal/game/casino.go b/internal/game/casino.go new file mode 100644 index 0000000..c6fb63b --- /dev/null +++ b/internal/game/casino.go @@ -0,0 +1,319 @@ +package game + +import ( + "fmt" + "strconv" + "strings" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/color" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +type casinoWallet struct { + g *Game + p *player.Player +} + +func (w casinoWallet) Wager(amount int) (casino.Wager, bool) { + chips := countChips(w.p) + if chips > amount { + chips = amount + } + credits := amount - chips + if w.p.Credits < credits { + return casino.Wager{}, false + } + if chips > 0 { + w.p.RemoveItem("chips", chips) + } + w.p.Credits -= credits + w.g.AccountStore.SaveCharacter(w.p) + return casino.Wager{Total: amount, Chips: chips, Credits: credits}, true +} + +func (w casinoWallet) PayCredits(amount int) { + w.p.Credits += amount + w.g.AccountStore.SaveCharacter(w.p) +} + +func (g *Game) casinoTable(roomID int, gameName string) (*casino.Table, bool) { + room, err := g.World.LoadRoom(roomID) + if err != nil { + return nil, false + } + for _, cfg := range room.CasinoTables { + if strings.EqualFold(string(cfg.Game), gameName) { + return g.Casino.EnsureTable(roomID, cfg), true + } + } + return nil, false +} + +func (g *Game) casinoMachine(roomID int, gameName string) (*casino.Machine, bool) { + room, err := g.World.LoadRoom(roomID) + if err != nil { + return nil, false + } + for _, cfg := range room.CasinoMachines { + if strings.EqualFold(string(cfg.Game), gameName) || strings.EqualFold(cfg.Variant, gameName) { + return g.Casino.EnsureMachine(roomID, cfg), true + } + } + // Migrate older room definitions that declared slots under casino_tables; + // slots are still always instantiated as one-player machines. + if strings.EqualFold(gameName, "slots") { + for _, cfg := range room.CasinoTables { + if strings.EqualFold(string(cfg.Game), "slots") { + return g.Casino.EnsureMachine(roomID, casino.MachineConfig{ + ID: cfg.ID, Game: casino.GameSlots, MinBet: cfg.MinBet, MaxBet: cfg.MaxBet, + }), true + } + } + } + return nil, false +} + +func (g *Game) executePlay(sess *net.Session, args []string, rawInput string) { + p := sess.Player + if p == nil || len(args) == 0 { + sess.WriteLine("Play what?") + return + } + if _, playing := g.Casino.Participation(p.Name); playing { + sess.WriteLine("You're already playing a game. Type \"stop\" between rounds to leave it.") + return + } + g.startCasinoGame(sess, p, args[0]) +} + +func (g *Game) startCasinoGame(sess *net.Session, p *player.Player, gameName string) { + if p == nil || sess == nil { + return + } + if _, playing := g.Casino.Participation(p.Name); playing { + sess.WriteLine("You're already playing a game. Type \"stop\" between rounds to leave it.") + return + } + if machine, ok := g.casinoMachine(p.RoomID, gameName); ok { + g.emitCasinoEvents(g.Casino.JoinMachine(machine.RoomID, machine.Config, p.Name)) + return + } + table, ok := g.casinoTable(p.RoomID, gameName) + if !ok { + sess.WriteLine(fmt.Sprintf("There is no %s table here.", gameName)) + return + } + g.emitCasinoEvents(g.Casino.Join(table.RoomID, table.Config, p.Name)) +} + +func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) { + p := sess.Player + if p == nil { + sess.WriteLine("Bet how much?") + return + } + if machine, ok := g.Casino.MachineFor(p.Name); ok { + if len(args) == 0 { + g.emitCasinoEvents(g.Casino.StartMachine(machine.RoomID, machine.Config.ID, p.Name, casinoWallet{g: g, p: p})) + return + } + if len(args) == 1 && args[0] == "max" { + g.emitCasinoEvents(g.Casino.SetMachineBet(machine.RoomID, machine.Config.ID, p.Name, machine.Config.MaxBet)) + return + } + if len(args) == 1 { + if amount, err := strconv.Atoi(args[0]); err == nil && amount > 0 { + g.emitCasinoEvents(g.Casino.SetMachineBet(machine.RoomID, machine.Config.ID, p.Name, amount)) + return + } + } + g.emitCasinoEvents(g.Casino.StartMachine(machine.RoomID, machine.Config.ID, p.Name, casinoWallet{g: g, p: p})) + return + } + if len(args) == 0 { + sess.WriteLine("Bet how much?") + return + } + amount, err := strconv.Atoi(args[0]) + if err != nil || amount <= 0 { + sess.WriteLine("Your bet must be a positive whole number.") + return + } + table, playing := g.Casino.Participation(p.Name) + if !playing { + sess.WriteLine("You're not playing a game.") + return + } + g.emitCasinoEvents(g.Casino.Bet(table.RoomID, table.Config.ID, p.Name, amount, casinoWallet{g: g, p: p})) +} + +func (g *Game) executeAutoBet(sess *net.Session, args []string, rawInput string) { + p := sess.Player + if p == nil { + return + } + if machine, ok := g.Casino.MachineFor(p.Name); ok { + g.emitCasinoEvents(g.Casino.EnableMachineAutoBet(machine.RoomID, machine.Config.ID, p.Name, casinoWallet{g: g, p: p})) + return + } + sess.WriteLine("Autobet is only available at games without player choices.") +} + +func (g *Game) CasinoTick() { + if g.Casino != nil { + g.emitCasinoEvents(g.Casino.Tick()) + } +} + +func (g *Game) emitCasinoEvents(events []casino.Event) { + for _, event := range events { + if event.Message == "" { + continue + } + category := event.Color + if category == "" { + category = casinoEventColor(event.Type) + } + if event.Public && g.Hub != nil { + for _, sess := range g.Hub.PlayersInRoom(event.RoomID) { + if event.PrivateTo != "" && sess.Player != nil && sess.Player.Name == event.PrivateTo { + continue + } + message := event.Message + if category == "casino_menu" && sess.Player != nil && sess.Player.Name == event.PrivateTo { + message = g.casinoMenuBalance(sess, message) + } + if event.Slot != nil && sess.Player != nil { + message += "\n" + renderSlotSnapshot(event.Slot, g.colorMode(sess), sess.Player.OptionBool("unicode")) + } + sess.WriteLine(g.colorize(sess, category, message)) + } + } + if event.PrivateMessage != "" && event.PrivateTo != "" { + g.charsMu.Lock() + sess := g.loggedInChars[event.PrivateTo] + g.charsMu.Unlock() + if sess != nil { + privateMessage := event.PrivateMessage + if category == "casino_menu" { + privateMessage = g.casinoMenuBalance(sess, privateMessage) + } + sess.WriteLine(g.colorize(sess, category, privateMessage)) + if category == "casino_menu" { + g.writePrompt(sess) + } + } + } + if event.PrivateTo != "" { + g.charsMu.Lock() + sess := g.loggedInChars[event.PrivateTo] + g.charsMu.Unlock() + if sess != nil && event.PrivateMessage == "" { + privateMessage := event.Message + if category == "casino_menu" { + privateMessage = g.casinoMenuBalance(sess, privateMessage) + } + sess.WriteLine(g.colorize(sess, category, privateMessage)) + if category == "casino_menu" { + g.writePrompt(sess) + } + } + } + } +} + +func casinoEventColor(eventType casino.EventType) string { + if eventType == casino.EventPayout { + return "casino_win" + } + return "casino_action" +} + +func (g *Game) casinoMenuBalance(sess *net.Session, message string) string { + if sess == nil || sess.Player == nil { + return message + } + chips := g.colorize(sess, "casino_chips", fmt.Sprintf("%d", countChips(sess.Player))) + credits := g.colorize(sess, "casino_credits", fmt.Sprintf("%d", sess.Player.Credits)) + line := fmt.Sprintf("You have %s chips and %s credits.", chips, credits) + return strings.Replace(message, "Slot machine commands:", line+"\n\nSlot machine commands:", 1) +} + +func renderSlotSnapshot(snapshot *casino.SlotSnapshot, mode string, unicode bool) string { + if snapshot == nil { + return "" + } + left, junction, right, horizontal, vertical := '+', '+', '+', '-', '|' + bottomLeft, bottomJunction, bottomRight := '+', '+', '+' + if unicode { + left, junction, right, horizontal, vertical = '\u250c', '\u252c', '\u2510', '\u2500', '\u2502' + bottomLeft, bottomJunction, bottomRight = '\u2514', '\u2534', '\u2518' + } + separator := string(left) + for reel := 0; reel < 5; reel++ { + if reel > 0 { + separator += string(junction) + } + separator += strings.Repeat(string(horizontal), 9) + } + separator += string(right) + lines := []string{casinoFrame(mode, separator)} + frameVertical := casinoFrame(mode, string(vertical)) + for row := 0; row < 3; row++ { + line := frameVertical + for reel := 0; reel < 5; reel++ { + value := "..." + if reel < snapshot.StoppedReels { + value = snapshot.Grid[row][reel] + } + line += " " + colorSlotSymbol(mode, value) + strings.Repeat(" ", 7-len(value)) + " " + frameVertical + } + lines = append(lines, line) + } + bottom := string(bottomLeft) + for reel := 0; reel < 5; reel++ { + if reel > 0 { + bottom += string(bottomJunction) + } + bottom += strings.Repeat(string(horizontal), 9) + } + bottom += string(bottomRight) + lines = append(lines, casinoFrame(mode, bottom)) + return strings.Join(lines, "\n") +} + +func casinoFrame(mode, text string) string { + spec := color.NoColor() + spec.Fg = 244 + return color.Render(mode, spec, text) +} + +func colorSlotSymbol(mode, symbol string) string { + spec := color.NoColor() + spec.Fg = 250 + switch symbol { + case "straw": + spec = color.NoColor() + spec.Gradient = []int{220, 226} + case "stick": + spec = color.NoColor() + spec.Gradient = []int{34, 46} + case "brick": + spec = color.NoColor() + spec.Gradient = []int{160, 196} + case "hat": + spec = color.NoColor() + spec.Gradient = []int{129, 201} + case "wolf": + spec = color.NoColor() + spec.Gradient = []int{27, 39} + case "scatter": + spec = color.NoColor() + spec.Gradient = []int{196, 220, 46, 51, 129, 201} + default: + spec.Dim = true + } + return color.Render(mode, spec, symbol) +} diff --git a/internal/game/casino_test.go b/internal/game/casino_test.go new file mode 100644 index 0000000..b2b37c4 --- /dev/null +++ b/internal/game/casino_test.go @@ -0,0 +1,29 @@ +package game + +import ( + "strings" + "testing" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/color" +) + +func TestRenderSlotSnapshotColorsWordSymbols(t *testing.T) { + snapshot := &casino.SlotSnapshot{StoppedReels: 1} + snapshot.Grid[0][0] = "scatter" + if !strings.Contains(renderSlotSnapshot(snapshot, "none", false), "scatter") { + t.Fatal("expected word-based slot symbols") + } + output := renderSlotSnapshot(snapshot, "xterm256", true) + if !strings.Contains(output, "\033[38;5;") { + t.Fatalf("expected colored slot symbols, got %q", output) + } + for _, line := range strings.Split(output, "\n") { + if color.VisibleLen(line) != 51 { + t.Fatalf("line visible width = %d, want 51: %q", color.VisibleLen(line), line) + } + } + if !strings.Contains(output, "┌") || !strings.Contains(output, "└") { + t.Fatalf("slot output lacks the Unicode frame: %q", output) + } +} diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go index 761db2f..4772a2c 100644 --- a/internal/game/cmd_move.go +++ b/internal/game/cmd_move.go @@ -13,6 +13,10 @@ import ( func (g *Game) doMove(sess *net.Session, dir string, isWalk bool) { p := sess.Player + if _, playing := g.Casino.Participation(p.Name); playing { + sess.WriteLine("You're playing a game. Type \"stop\" between rounds before moving.") + return + } exitDir := g.World.ResolveExit(dir) if exitDir == "" { sess.WriteLine("Go where?") diff --git a/internal/game/cmd_quit.go b/internal/game/cmd_quit.go index 12d3879..b7ec972 100644 --- a/internal/game/cmd_quit.go +++ b/internal/game/cmd_quit.go @@ -11,6 +11,10 @@ func (g *Game) executeQuit(sess *net.Session, args []string, rawInput string) { func (g *Game) doQuit(sess *net.Session) { p := sess.Player + if _, playing := g.Casino.Participation(p.Name); playing { + sess.WriteLine(g.colorize(sess, "warning", "WARNING: You're in the middle of a game!")) + g.emitCasinoEvents(g.Casino.MarkDisconnected(p.Name)) + } if g.Combat.Get(p.Name) != nil { sess.WriteLine("You can't rest during combat!") diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go index d03993a..4c41216 100644 --- a/internal/game/cmd_registry.go +++ b/internal/game/cmd_registry.go @@ -23,6 +23,11 @@ var commandRegistry = map[string]commandDef{ "attack": {(*Game).executeAttack, ClassActive}, "kill": {(*Game).executeAttack, ClassActive}, "work": {(*Game).executeWork, ClassActive}, + "play": {(*Game).executePlay, ClassActive}, + "bet": {(*Game).executeBet, ClassActive}, + "spin": {(*Game).executeBet, ClassActive}, + "autobet": {(*Game).executeAutoBet, ClassActive}, + "autospin": {(*Game).executeAutoBet, ClassActive}, "style": {(*Game).executeStyle, ClassInstant}, "look": {(*Game).executeLook, ClassInstant}, "l": {(*Game).executeLook, ClassInstant}, diff --git a/internal/game/cmd_stop.go b/internal/game/cmd_stop.go index d1b8faa..5c347e6 100644 --- a/internal/game/cmd_stop.go +++ b/internal/game/cmd_stop.go @@ -6,6 +6,18 @@ import ( func (g *Game) executeStop(sess *net.Session, args []string, rawInput string) { p := sess.Player + if table, playing := g.Casino.Participation(p.Name); playing { + if table != nil { + g.emitCasinoEvents(g.Casino.Leave(table.RoomID, table.Config.ID, p.Name)) + } else if machine, ok := g.Casino.MachineFor(p.Name); ok { + if events, stopped := g.Casino.StopMachineAutoBet(machine.RoomID, machine.Config.ID, p.Name); stopped { + g.emitCasinoEvents(events) + return + } + g.emitCasinoEvents(g.Casino.LeaveMachine(machine.RoomID, machine.Config.ID, p.Name)) + } + return + } ss, _ := g.safespot.Get(p.Name) if p.Action == nil && p.BackgroundAction == nil && g.Combat.Get(p.Name) == nil && p.MoveTicks == 0 && len(p.WalkSequence) == 0 && !ss.Active { sess.WriteLine("You're not doing anything.") diff --git a/internal/game/core_login_char.go b/internal/game/core_login_char.go index 38499e2..4f59f1b 100644 --- a/internal/game/core_login_char.go +++ b/internal/game/core_login_char.go @@ -108,6 +108,7 @@ func (g *Game) connectCharacter(sess *net.Session, name string) { if g.Hub != nil { g.Hub.EnterRoom(sess, p.RoomID) } + g.Casino.MarkConnected(name) g.doLook(sess) g.checkAggro(sess) diff --git a/internal/game/game.go b/internal/game/game.go index 700877b..cac0ec9 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -7,6 +7,7 @@ import ( "sync" "time" + "thehouseoficarus/internal/casino" "thehouseoficarus/internal/combat" "thehouseoficarus/internal/config" "thehouseoficarus/internal/engine" @@ -31,13 +32,13 @@ const ( // constructed once at startup and never replaced. Deps is embedded in Game, so // game code accesses them directly (e.g. g.World, g.ItemStore). type Deps struct { - World *world.World - ObjectStore *object.ObjectStore - ItemStore *item.ItemStore - AccountStore *player.AccountStore - MobStore *world.MobStore - CraftIndex *CraftIndex - CourseStore *CourseStore + World *world.World + ObjectStore *object.ObjectStore + ItemStore *item.ItemStore + AccountStore *player.AccountStore + MobStore *world.MobStore + CraftIndex *CraftIndex + CourseStore *CourseStore Ticks *engine.Engine ColorConfig *config.ColorsConfig ConstantColorConfig *config.ColorsConfig @@ -53,6 +54,7 @@ type Game struct { Hub *net.Hub GlobalFlags *GlobalFlagStore Combat *combat.Tracker + Casino *casino.Manager flagIndex *flagTriggerIndex queue *CommandQueue safespot *SafespotManager @@ -83,7 +85,7 @@ func New(dataDir string, colorConfig *config.ColorsConfig, constantColorConfig * ObjectStore: object.NewObjectStore(dataDir), ItemStore: item.NewItemStore(dataDir), AccountStore: player.NewAccountStore(dataDir), - MobStore: world.NewMobStore(dataDir), + MobStore: world.NewMobStore(dataDir), CraftIndex: NewCraftIndex(), CourseStore: NewCourseStore(dataDir), Ticks: engine.New(), @@ -93,6 +95,7 @@ func New(dataDir string, colorConfig *config.ColorsConfig, constantColorConfig * }, GlobalFlags: NewGlobalFlagStore(), Combat: combat.NewTracker(), + Casino: casino.NewManager(time.Now().UnixNano()), flagIndex: newFlagTriggerIndex(), queue: NewCommandQueue(), safespot: NewSafespotManager(), @@ -131,6 +134,11 @@ func (g *Game) SetHub(hub *net.Hub) { } } }) + hub.OnDisconnect(func(sess *net.Session) { + if sess.Player != nil { + g.emitCasinoEvents(g.Casino.MarkDisconnected(sess.Player.Name)) + } + }) g.GlobalFlags.OnChange(func(name string, value any) { g.fireGlobalFlagTriggers(name, value) }) @@ -339,8 +347,9 @@ func (g *Game) ProcessQueuedCommands() { ss, _ := g.safespot.Get(p.Name) isHiding := ss.Active isBusy := p.Action != nil || len(p.WalkSequence) > 0 || g.Combat.Get(p.Name) != nil || p.MoveTicks > 0 || isHiding + isCasinoSpin := g.Casino != nil && g.Casino.MachineBusy(p.Name) _, isResting := g.restTimers[p.Name] - if !isResting && !isBusy && qc.Session.State == net.StateGame { + if !isResting && !isBusy && !isCasinoSpin && qc.Session.State == net.StateGame { g.writePrompt(qc.Session) } } diff --git a/internal/game/look_entities.go b/internal/game/look_entities.go index dd2cdf0..4ed47c9 100644 --- a/internal/game/look_entities.go +++ b/internal/game/look_entities.go @@ -5,6 +5,7 @@ import ( "sort" "strings" + "thehouseoficarus/internal/casino" "thehouseoficarus/internal/color" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" @@ -73,7 +74,7 @@ func (g *Game) showRoomMobs(sess *net.Session, p *player.Player, room *world.Roo func (g *Game) showRoomObjects(sess *net.Session, p *player.Player, room *world.Room) []string { objs := g.World.AllObjInstances(p.RoomID) if len(objs) == 0 { - return g.showFarmPatches(sess, p) + return appendRoomCasinos(g.showFarmPatches(sess, p), g.showRoomCasinos(sess, room)) } var lines []string lines = append(lines, "") @@ -220,9 +221,43 @@ func (g *Game) showRoomObjects(sess *net.Session, p *player.Player, room *world. } lines = append(lines, g.showFarmPatches(sess, p)...) + return appendRoomCasinos(lines, g.showRoomCasinos(sess, room)) +} + +func (g *Game) showRoomCasinos(sess *net.Session, room *world.Room) []string { + if room == nil || (len(room.CasinoTables) == 0 && len(room.CasinoMachines) == 0) { + return nil + } + var lines []string + for _, table := range room.CasinoTables { + name := casinoGameLabel(table.Game) + " table" + lines = append(lines, fmt.Sprintf("A %s is here.", g.colorize(sess, "casino_action", name))) + } + for _, machine := range room.CasinoMachines { + name := casinoGameLabel(machine.Game) + " machine" + lines = append(lines, fmt.Sprintf("A %s is here.", g.colorize(sess, "casino_action", name))) + } return lines } +func appendRoomCasinos(lines, casinos []string) []string { + if len(casinos) == 0 { + return lines + } + if len(lines) == 0 || lines[len(lines)-1] != "" { + lines = append(lines, "") + } + return append(lines, casinos...) +} + +func casinoGameLabel(game casino.GameName) string { + label := strings.ReplaceAll(string(game), "_", " ") + if label == "" { + return "casino" + } + return label +} + func (g *Game) showGroundItems(sess *net.Session, p *player.Player, room *world.Room) []string { ground := g.World.GroundItemsDetailed(p.RoomID) if len(ground) == 0 { diff --git a/internal/game/render_help.go b/internal/game/render_help.go index 7b7efa2..8af3096 100644 --- a/internal/game/render_help.go +++ b/internal/game/render_help.go @@ -53,6 +53,10 @@ var commandList = []cmdEntry{ {"jack / jackin", "Active", "Jack into a terminal (Hacking)"}, {"look / l", "Instant", "Look around or examine things"}, {"map", "Instant", "Display an ASCII map of the area"}, + {"play", "Active", "Join a casino game in the current room"}, + {"bet", "Active", "Place a casino wager"}, + {"autobet / autospin", "Active", "Automatically repeat a casino wager"}, + {"spin", "Active", "Spin the current casino wager"}, {"mine", "Active", "Mine rocks (Mining)"}, {"mix", "Active", "Mix potions (Pharmacy)"}, {"mods / modlist", "Instant", "List available science modules"}, diff --git a/internal/game/tick_systems.go b/internal/game/tick_systems.go index 4d8d4b6..b819454 100644 --- a/internal/game/tick_systems.go +++ b/internal/game/tick_systems.go @@ -27,6 +27,7 @@ func TickSystemOrder() []struct{ Name string } { var tickSystemOrder = []tickSystem{ {"ProcessQueuedCommands", (*Game).ProcessQueuedCommands}, + {"CasinoTick", (*Game).CasinoTick}, {"MoveTick", (*Game).MoveTick}, {"SequenceTick", (*Game).SequenceTick}, {"TransientMobTick", (*Game).TransientMobTick}, @@ -57,4 +58,4 @@ func (g *Game) RunTickSystems() { for _, s := range tickSystemOrder { s.Run(g) } -}
\ No newline at end of file +} diff --git a/internal/game/tick_systems_test.go b/internal/game/tick_systems_test.go index 6b44e91..2fa67ac 100644 --- a/internal/game/tick_systems_test.go +++ b/internal/game/tick_systems_test.go @@ -22,6 +22,7 @@ import ( // contract. var expectedTickSystemOrder = []string{ "ProcessQueuedCommands", + "CasinoTick", "MoveTick", "SequenceTick", "TransientMobTick", @@ -144,4 +145,4 @@ func newRunTickSystemsTestGame() *Game { hackingStates: map[string]*hacking.Session{}, sequences: map[string]*sequence{}, } -}
\ No newline at end of file +} diff --git a/internal/net/server.go b/internal/net/server.go index 9da90ea..da1c01b 100644 --- a/internal/net/server.go +++ b/internal/net/server.go @@ -100,10 +100,11 @@ type Server struct { } type Hub struct { - mu sync.Mutex - sessions map[*Session]bool - rooms map[int]map[*Session]bool - onRemove func(*Session) + mu sync.Mutex + sessions map[*Session]bool + rooms map[int]map[*Session]bool + onRemove func(*Session) + onDisconnect func(*Session) } func NewHub() *Hub { @@ -119,6 +120,12 @@ func (h *Hub) OnRemove(cb func(*Session)) { h.onRemove = cb } +func (h *Hub) OnDisconnect(cb func(*Session)) { + h.mu.Lock() + defer h.mu.Unlock() + h.onDisconnect = cb +} + func (h *Hub) Add(s *Session) { h.mu.Lock() defer h.mu.Unlock() @@ -128,13 +135,18 @@ func (h *Hub) Add(s *Session) { func (h *Hub) Remove(s *Session) { h.mu.Lock() - defer h.mu.Unlock() if s.Player != nil && s.State == StateGame && !s.Disconnecting { s.Disconnecting = true s.DisconnectTicks = 10 + onDisconnect := h.onDisconnect + h.mu.Unlock() + if onDisconnect != nil { + onDisconnect(s) + } return } h.hardRemoveLocked(s) + h.mu.Unlock() } func (h *Hub) HardRemove(s *Session) { diff --git a/internal/world/room.go b/internal/world/room.go index c46c363..f4390da 100644 --- a/internal/world/room.go +++ b/internal/world/room.go @@ -5,6 +5,7 @@ import ( "gopkg.in/yaml.v3" "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/casino" "thehouseoficarus/internal/object" ) @@ -84,18 +85,20 @@ type ExitDef struct { } type Room struct { - ID int `yaml:"id"` - Name string `yaml:"name"` - Color string `yaml:"color"` - Description behavior.DescList `yaml:"description"` - Exits map[ExitDir]ExitDef `yaml:"exits"` - Objects []RoomObject `yaml:"objects"` - ItemSpawns []SpawnDef `yaml:"item_spawns"` - Mobs []RoomMob `yaml:"mobs"` - OnEnter []behavior.Trigger `yaml:"on_enter,omitempty"` - OnExit []behavior.Trigger `yaml:"on_exit,omitempty"` - Hazard string `yaml:"hazard"` - BlockTransport bool `yaml:"block_transport"` + ID int `yaml:"id"` + Name string `yaml:"name"` + Color string `yaml:"color"` + Description behavior.DescList `yaml:"description"` + Exits map[ExitDir]ExitDef `yaml:"exits"` + Objects []RoomObject `yaml:"objects"` + ItemSpawns []SpawnDef `yaml:"item_spawns"` + Mobs []RoomMob `yaml:"mobs"` + OnEnter []behavior.Trigger `yaml:"on_enter,omitempty"` + OnExit []behavior.Trigger `yaml:"on_exit,omitempty"` + Hazard string `yaml:"hazard"` + BlockTransport bool `yaml:"block_transport"` + CasinoTables []casino.TableConfig `yaml:"casino_tables,omitempty"` + CasinoMachines []casino.MachineConfig `yaml:"casino_machines,omitempty"` } type RoomMob struct { |
