aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-07-31 17:44:10 -0400
committerhistoria <[not public]>2026-07-31 17:44:10 -0400
commitf51424c90dbce7191a31b6e675818c985f0f4539 (patch)
treea7845a2e09bd6e83d2d034f5e702a4940f38126a
parent58758d41488a777d2bc0e787be655363bdd9eb60 (diff)
downloadthehouseoficarus-f51424c90dbce7191a31b6e675818c985f0f4539.tar.gz
feat: casino framework and basic slot machine
-rw-r--r--building_guide/rooms.md33
-rw-r--r--building_guide/tick_order.md47
-rw-r--r--building_guide/triggers.md6
-rw-r--r--config.yaml8
-rw-r--r--data/.admin_history.json1
-rw-r--r--data/rooms/intro/2001.yaml30
-rw-r--r--internal/behavior/behavior.go3
-rw-r--r--internal/casino/machine.go364
-rw-r--r--internal/casino/manager.go238
-rw-r--r--internal/casino/manager_test.go103
-rw-r--r--internal/casino/slots.go278
-rw-r--r--internal/casino/slots_test.go44
-rw-r--r--internal/casino/table.go217
-rw-r--r--internal/casino/types.go121
-rw-r--r--internal/game/act_effects.go7
-rw-r--r--internal/game/casino.go319
-rw-r--r--internal/game/casino_test.go29
-rw-r--r--internal/game/cmd_move.go4
-rw-r--r--internal/game/cmd_quit.go4
-rw-r--r--internal/game/cmd_registry.go5
-rw-r--r--internal/game/cmd_stop.go12
-rw-r--r--internal/game/core_login_char.go1
-rw-r--r--internal/game/game.go27
-rw-r--r--internal/game/look_entities.go37
-rw-r--r--internal/game/render_help.go4
-rw-r--r--internal/game/tick_systems.go3
-rw-r--r--internal/game/tick_systems_test.go3
-rw-r--r--internal/net/server.go22
-rw-r--r--internal/world/room.go27
29 files changed, 1931 insertions, 66 deletions
diff --git a/building_guide/rooms.md b/building_guide/rooms.md
index 602e449..7641c75 100644
--- a/building_guide/rooms.md
+++ b/building_guide/rooms.md
@@ -218,6 +218,39 @@ Mobs stop wandering while in combat. Dead mobs respawn at their home room.
---
+### Casino Tables
+
+Rooms may host one or more casino tables. Players join by typing `play <game>`;
+the table ID is an internal room-local identifier and does not need to be typed.
+
+```yaml
+casino_tables:
+ - id: slots
+ game: slots
+ max_players: 8
+ betting_ticks: 20
+ min_bet: 1
+ max_bet: 1000
+```
+
+`max_players`, `betting_ticks`, `min_bet`, and `max_bet` use defaults when omitted.
+Game-specific rule configuration will be added alongside the table definition as
+each game is implemented.
+
+Single-player machines use `casino_machines` and are separate from multiplayer
+tables:
+
+```yaml
+casino_machines:
+ - id: huff_puff_01
+ game: slots
+ variant: huff_and_even_more_puff
+ min_bet: 1
+ max_bet: 1000
+ spin_ticks: 1
+ payout: 0.97
+```
+
### Objects — interactive fixtures
Simple placement:
diff --git a/building_guide/tick_order.md b/building_guide/tick_order.md
index 6138fbe..b4d1f1e 100644
--- a/building_guide/tick_order.md
+++ b/building_guide/tick_order.md
@@ -34,27 +34,28 @@ duplicates so any one of them drifting fails the test loudly.
| # | System | What it does |
| --- | ----------------------- | ------------------------------------------------------------------------------------------------------------------ |
| 1 | `ProcessQueuedCommands` | Drains each player's free-queued commands and the globally timestamp-sorted active queue; advances walk sequences. |
-| 2 | `MoveTick` | Counts down `p.MoveTicks`; on reach-zero, completes a pending run/walk and re-prompts. |
-| 3 | `SequenceTick` | Advances every active trigger `sequence` (per-player and global) one step. |
-| 4 | `TransientMobTick` | Despawns owned / `DespawnOnLeave` mobs whose owner has left their room. |
-| 5 | `World.Tick` | World-internal: ground-item respawn/despawn/reserve timers and shared-object deplete timers. |
-| 6 | `MobStore.Tick` | Mob shop restock and mob HP regenerate. Order: by `InstanceID` so processing is stable across ticks. |
-| 7 | `RegenTick` | Player HP regeneration, accelerated by tech HP-regen multipliers. |
-| 8 | `EnergyRegenTick` | Run-energy regeneration for every online player. |
-| 9 | `DisconnectTick` | Counts down pending disconnects and hard-removes finished sessions (skipping players still in combat). |
-| 10 | `WanderTick` | Mob wandering (exit-based) and object wandering (fishing spots), with enter/leave messages to observers. |
-| 11 | `SharedDepletionTick` | Ticks shared-object deplete timers up (in use) or down (recovering). |
-| 12 | `FireTick` | Decays fire objects; on burn-out, spawns the removal item and cancels stokers. |
-| 13 | `AdvanceActions` | Advances every active player action by one tick; fires completion handlers per action type. |
-| 14 | `TechTick` | Drains battery for active tech, applies equipment / PreserveDrain reductions, and deactivates all on empty. |
-| 15 | `ConsumeTick` | Counts down `ConsumeCooldown` and `HomeTransportCooldown`. |
-| 16 | `BroadcastRespawns` | Flushes respawned world objects and broadcasts their `respawn_broadcast` lines to players in those rooms. |
-| 17 | `VisualTick` | Optional per-player "visual tick" line/counter for AFK feedback. |
-| 18 | `SneakTick` | Advances guard watch timers and sneaking players' tick counters. |
-| 19 | `FarmTick` | Crops grow, drain water, and yield on harvest (fires every `FarmTickInterval` ticks). |
-| 20 | `BuffTick` | Potion buff duration countdown; expires and removes effects. |
-| 21 | `SafespotTick` | Safespot unsafe-chance and decay-chance rolls per player. |
-| 22 | `HazardTick` | Room hazard damage to non-GodMode players. |
+| 2 | `CasinoTick` | Advances casino betting deadlines and resolves completed casino rounds. |
+| 3 | `MoveTick` | Counts down `p.MoveTicks`; on reach-zero, completes a pending run/walk and re-prompts. |
+| 4 | `SequenceTick` | Advances every active trigger `sequence` (per-player and global) one step. |
+| 5 | `TransientMobTick` | Despawns owned / `DespawnOnLeave` mobs whose owner has left their room. |
+| 6 | `World.Tick` | World-internal: ground-item respawn/despawn/reserve timers and shared-object deplete timers. |
+| 7 | `MobStore.Tick` | Mob shop restock and mob HP regenerate. Order: by `InstanceID` so processing is stable across ticks. |
+| 8 | `RegenTick` | Player HP regeneration, accelerated by tech HP-regen multipliers. |
+| 9 | `EnergyRegenTick` | Run-energy regeneration for every online player. |
+| 10 | `DisconnectTick` | Counts down pending disconnects and hard-removes finished sessions (skipping players still in combat). |
+| 11 | `WanderTick` | Mob wandering (exit-based) and object wandering (fishing spots), with enter/leave messages to observers. |
+| 12 | `SharedDepletionTick` | Ticks shared-object deplete timers up (in use) or down (recovering). |
+| 13 | `FireTick` | Decays fire objects; on burn-out, spawns the removal item and cancels stokers. |
+| 14 | `AdvanceActions` | Advances every active player action by one tick; fires completion handlers per action type. |
+| 15 | `TechTick` | Drains battery for active tech, applies equipment / PreserveDrain reductions, and deactivates all on empty. |
+| 16 | `ConsumeTick` | Counts down `ConsumeCooldown` and `HomeTransportCooldown`. |
+| 17 | `BroadcastRespawns` | Flushes respawned world objects and broadcasts their `respawn_broadcast` lines to players in those rooms. |
+| 18 | `VisualTick` | Optional per-player "visual tick" line/counter for AFK feedback. |
+| 19 | `SneakTick` | Advances guard watch timers and sneaking players' tick counters. |
+| 20 | `FarmTick` | Crops grow, drain water, and yield on harvest (fires every `FarmTickInterval` ticks). |
+| 21 | `BuffTick` | Potion buff duration countdown; expires and removes effects. |
+| 22 | `SafespotTick` | Safespot unsafe-chance and decay-chance rolls per player. |
+| 23 | `HazardTick` | Room hazard damage to non-GodMode players. |
### Determinism contract
@@ -65,7 +66,7 @@ Three layers of ordering decide what happens on a given tick, in priority:
`cmd/thoi/main.go`) always fires first; every system in
`RunTickSystems` then runs in the table order above. Dynamic combat /
aggro / respawn subscribers registered later get higher IDs and fire
- after the canonical 22 systems. This is asserted by
+ after the canonical 23 systems. This is asserted by
`internal/engine/tick_test.go::TestProcessTickFiresInSubscriptionOrder`.
2. **Per-system iteration order** — deterministic, as of the deterministic
@@ -138,4 +139,4 @@ should fire *after* regen, a sequence whose step messages should appear
already advanced.
When in doubt, the test in `tick_systems_test.go` is the executable
-specification. \ No newline at end of file
+specification.
diff --git a/building_guide/triggers.md b/building_guide/triggers.md
index 60b1c82..f7720bb 100644
--- a/building_guide/triggers.md
+++ b/building_guide/triggers.md
@@ -47,7 +47,8 @@ its steps run as a scripted sequence.
take_item: ""
teleport: 0
heal: 0
- credits: 0
+ credits: 0
+ start_game: "" # joins a casino table for the current room
spawn_mob: null # SpawnMobConfig (or bare string)
despawn_mob: ""
aps_node: false
@@ -87,6 +88,7 @@ wait elapses.
| `teleport` | Moves the player to a room ID. |
| `heal` | Restores hitpoints (clamped to MaxHP). |
| `credits` | Positive = award, negative = deduct credits. |
+| `start_game` | Joins the named casino game configured in the player's current room. |
| `aps_node` | Marks this room's APS node as unlocked. |
| `condition` | Per-step gate (evaluated once when the sequence reaches this step). |
@@ -726,4 +728,4 @@ definition, `give_item`/`take_item` reference existing items, `teleport` and
Validation does NOT check that the watched flags (`on_player_flag` / `on_global_flag`)
are ever set — those are dynamic, set by runtime gameplay, and can't be statically
-verified. \ No newline at end of file
+verified.
diff --git a/config.yaml b/config.yaml
index 120043d..3d10c6e 100644
--- a/config.yaml
+++ b/config.yaml
@@ -62,6 +62,14 @@ default_colors:
say: "FF"
global: "49"
broadcast: "89"
+ casino_action: "6E"
+ casino_menu: "FA"
+ casino_win: "g:FA,FB,FD"
+ casino_payout: "bold FA"
+ casino_big_win: "g:220,226 bold"
+ casino_jackpot_win: "g:220,226,46,51,129,201 bold"
+ casino_chips: "g:220,226"
+ casino_credits: "g:39,45"
dialog: "6E"
dialog_options: "bold"
drop_message: "89"
diff --git a/data/.admin_history.json b/data/.admin_history.json
new file mode 100644
index 0000000..e856da6
--- /dev/null
+++ b/data/.admin_history.json
@@ -0,0 +1 @@
+{"history":[{"time":"2026-07-31T13:48:03-04:00","description":"update room 2001","file_path":"data/rooms/intro/2001.yaml","old_content":"id: 2001\nname: Test Room\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 1005\n south:\n room: 2006\n west:\n room: 1001\nobjects:\n - name: hidden guy\n hidden: true\n description:\n - text: YOU FOUND HIM\n - id: tree\nitem_spawns:\n - id: bronze_axe\n quantity: 1\n respawn_ticks: 1\n - id: pot_of_flour\n quantity: 1\n respawn_ticks: 1\n - id: bucket_of_water\n quantity: 1\n respawn_ticks: 1\nmobs:\n - id: man\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\ncasino_tables:\n - id: slots\n game: slots\n max_players: 8\n betting_ticks: 20\n min_bet: 1\n max_bet: 1000\n\n","new_content":"block_transport: false\ncasino_tables:\n - betting_ticks: 20\n game: slots\n id: slots\n max_bet: 1000\n max_players: 8\n min_bet: 1\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 1005\n south:\n room: 2006\n west:\n room: 1001\nhazard: \"\"\nid: 2001\nitem_spawns:\n - id: bronze_axe\n quantity: 1\n respawn_ticks: 1\n - id: pot_of_flour\n quantity: 1\n respawn_ticks: 1\n - id: bucket_of_water\n quantity: 1\n respawn_ticks: 1\n - id: chips\n quantity: 1\n respawn_ticks: 0\nmobs:\n - id: man\nname: Test Room\nobjects:\n - description:\n - text: YOU FOUND HIM\n hidden: true\n name: hidden guy\n - id: tree\non_enter: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-07-31T13:48:06-04:00","description":"update room 2001","file_path":"data/rooms/intro/2001.yaml","old_content":"block_transport: false\ncasino_tables:\n - betting_ticks: 20\n game: slots\n id: slots\n max_bet: 1000\n max_players: 8\n min_bet: 1\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 1005\n south:\n room: 2006\n west:\n room: 1001\nhazard: \"\"\nid: 2001\nitem_spawns:\n - id: bronze_axe\n quantity: 1\n respawn_ticks: 1\n - id: pot_of_flour\n quantity: 1\n respawn_ticks: 1\n - id: bucket_of_water\n quantity: 1\n respawn_ticks: 1\n - id: chips\n quantity: 1\n respawn_ticks: 0\nmobs:\n - id: man\nname: Test Room\nobjects:\n - description:\n - text: YOU FOUND HIM\n hidden: true\n name: hidden guy\n - id: tree\non_enter: []\ntriggers: []\n","new_content":"block_transport: false\ncasino_tables:\n - betting_ticks: 20\n game: slots\n id: slots\n max_bet: 1000\n max_players: 8\n min_bet: 1\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 1005\n south:\n room: 2006\n west:\n room: 1001\nhazard: \"\"\nid: 2001\nitem_spawns:\n - id: bronze_axe\n quantity: 1\n respawn_ticks: 1\n - id: pot_of_flour\n quantity: 1\n respawn_ticks: 1\n - id: bucket_of_water\n quantity: 1\n respawn_ticks: 1\n - id: chips\n quantity: 5000\n respawn_ticks: 0\nmobs:\n - id: man\nname: Test Room\nobjects:\n - description:\n - text: YOU FOUND HIM\n hidden: true\n name: hidden guy\n - id: tree\non_enter: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-07-31T13:48:07-04:00","description":"update room 2001","file_path":"data/rooms/intro/2001.yaml","old_content":"block_transport: false\ncasino_tables:\n - betting_ticks: 20\n game: slots\n id: slots\n max_bet: 1000\n max_players: 8\n min_bet: 1\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 1005\n south:\n room: 2006\n west:\n room: 1001\nhazard: \"\"\nid: 2001\nitem_spawns:\n - id: bronze_axe\n quantity: 1\n respawn_ticks: 1\n - id: pot_of_flour\n quantity: 1\n respawn_ticks: 1\n - id: bucket_of_water\n quantity: 1\n respawn_ticks: 1\n - id: chips\n quantity: 5000\n respawn_ticks: 0\nmobs:\n - id: man\nname: Test Room\nobjects:\n - description:\n - text: YOU FOUND HIM\n hidden: true\n name: hidden guy\n - id: tree\non_enter: []\ntriggers: []\n","new_content":"block_transport: false\ncasino_tables:\n - betting_ticks: 20\n game: slots\n id: slots\n max_bet: 1000\n max_players: 8\n min_bet: 1\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 1005\n south:\n room: 2006\n west:\n room: 1001\nhazard: \"\"\nid: 2001\nitem_spawns:\n - id: bronze_axe\n quantity: 1\n respawn_ticks: 1\n - id: pot_of_flour\n quantity: 1\n respawn_ticks: 1\n - id: bucket_of_water\n quantity: 1\n respawn_ticks: 1\n - id: chips\n quantity: 5000\n respawn_ticks: 1\nmobs:\n - id: man\nname: Test Room\nobjects:\n - description:\n - text: YOU FOUND HIM\n hidden: true\n name: hidden guy\n - id: tree\non_enter: []\ntriggers: []\n","is_delete":false,"is_create":false}],"redo":null} \ No newline at end of file
diff --git a/data/rooms/intro/2001.yaml b/data/rooms/intro/2001.yaml
index 8f1db38..ed10efa 100644
--- a/data/rooms/intro/2001.yaml
+++ b/data/rooms/intro/2001.yaml
@@ -1,5 +1,11 @@
-id: 2001
-name: Test Room
+block_transport: false
+casino_machines:
+ - id: huff_puff_01
+ game: slots
+ variant: huff_and_even_more_puff
+ min_bet: 1
+ max_bet: 1000
+ payout: 0.97
color: ""
description:
- text: It's a test room
@@ -10,12 +16,8 @@ exits:
room: 2006
west:
room: 1001
-objects:
- - name: hidden guy
- hidden: true
- description:
- - text: YOU FOUND HIM
- - id: tree
+hazard: ""
+id: 2001
item_spawns:
- id: bronze_axe
quantity: 1
@@ -26,9 +28,17 @@ item_spawns:
- id: bucket_of_water
quantity: 1
respawn_ticks: 1
+ - id: chips
+ quantity: 5000
+ respawn_ticks: 1
mobs:
- id: man
+name: Test Room
+objects:
+ - description:
+ - text: YOU FOUND HIM
+ hidden: true
+ name: hidden guy
+ - id: tree
on_enter: []
-hazard: ""
-block_transport: false
triggers: []
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 {