aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/manager.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-15 15:35:52 -0400
committerhistoria <[not public]>2026-09-15 15:35:52 -0400
commit0b0b25222b3131ada79ff78c28eb8bf07d977deb (patch)
tree2ceaef5203595044f66a8f00064cd8e017f8f48d /internal/casino/manager.go
parent396c3facb8287b9a34076cae8f3d7b300d366d99 (diff)
downloadthehouseoficarus-0b0b25222b3131ada79ff78c28eb8bf07d977deb.tar.gz
feat: single-owner game state, transactional lifecycle, durable money on server stop
Diffstat (limited to 'internal/casino/manager.go')
-rw-r--r--internal/casino/manager.go122
1 files changed, 122 insertions, 0 deletions
diff --git a/internal/casino/manager.go b/internal/casino/manager.go
index 38b754f..78e26b0 100644
--- a/internal/casino/manager.go
+++ b/internal/casino/manager.go
@@ -4,6 +4,7 @@ import (
"fmt"
"math/rand"
"sort"
+ "strings"
"sync"
)
@@ -27,6 +28,10 @@ func tableKey(roomID int, tableID string) string {
}
// EnsureMachine returns nil for an invalid or unsupported configuration.
+// When a machine already exists for the room/ID, its live rules are
+// replaced by the new configuration ONLY while it is idle (no seated
+// player, so no wager can be discarded); an occupied machine keeps its
+// live rules until it becomes idle again.
func (m *Manager) EnsureMachine(roomID int, cfg MachineConfig) *Machine {
if cfg.Validate() != nil {
return nil
@@ -36,6 +41,17 @@ func (m *Manager) EnsureMachine(roomID int, cfg MachineConfig) *Machine {
m.mu.Lock()
defer m.mu.Unlock()
if machine := m.machines[key]; machine != nil {
+ if machine.Config == cfg {
+ return machine
+ }
+ if machine.idle() {
+ if next := newMachine(roomID, cfg, rand.New(rand.NewSource(m.rng.Int63()))); next != nil {
+ m.machines[key] = next
+ return next
+ }
+ // Fall through to keep the live machine rather than drop a
+ // functioning game for an invalid replacement.
+ }
return machine
}
// Only the manager consumes its RNG, under m.mu. Each game owns an
@@ -46,6 +62,9 @@ func (m *Manager) EnsureMachine(roomID int, cfg MachineConfig) *Machine {
}
// EnsureTable returns nil for an invalid or unsupported configuration.
+// When a table already exists for the room/ID, its live rules are replaced
+// only while the table is idle (no seated players, so no wagers can be
+// silently discarded); an occupied table keeps its live rules until idle.
func (m *Manager) EnsureTable(roomID int, cfg TableConfig) *Table {
if cfg.Validate() != nil {
return nil
@@ -55,6 +74,18 @@ func (m *Manager) EnsureTable(roomID int, cfg TableConfig) *Table {
m.mu.Lock()
defer m.mu.Unlock()
if table := m.tables[key]; table != nil {
+ if table.Config == cfg {
+ return table
+ }
+ if table.idle() {
+ next := newTable(roomID, cfg, rand.New(rand.NewSource(m.rng.Int63())))
+ if next != nil {
+ m.tables[key] = next
+ return next
+ }
+ // Fall through to keep the live table rather than drop a
+ // functioning game for an invalid replacement.
+ }
return table
}
table := newTable(roomID, cfg, rand.New(rand.NewSource(m.rng.Int63())))
@@ -257,3 +288,94 @@ func (m *Manager) MachineBusy(name string) bool {
}
return false
}
+
+// RefundAll resolves every outstanding wager by refunding committed stakes
+// to their players. Graceful shutdown uses this: rounds cannot settle, so
+// no player may lose a stake to the shutdown itself. Refunds go through the
+// wallet (durable, journaled) and return the refund events for delivery.
+func (m *Manager) RefundAll() []Event {
+ var events []Event
+ for _, table := range m.snapshotTables() {
+ events = append(events, table.refundAll()...)
+ }
+ for _, machine := range m.snapshotMachines() {
+ events = append(events, machine.refundAll()...)
+ }
+ return events
+}
+
+// RekeyRooms remaps every room-keyed table and machine per idMap
+// (oldID -> newID): both the manager's map keys and each game's RoomID.
+// Game state (wagers, rounds) is preserved — the games continue in the
+// renamed room. Uses delete-all-then-set-all so a swap cannot clobber.
+func (m *Manager) RekeyRooms(idMap map[int]int) {
+ if len(idMap) == 0 {
+ return
+ }
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ rekey := func(key string) (string, bool) {
+ var roomID int
+ var tableID string
+ if _, err := fmt.Sscanf(key, "%d:%s", &roomID, &tableID); err != nil {
+ return "", false
+ }
+ newID, ok := idMap[roomID]
+ if !ok || newID == roomID {
+ return "", false
+ }
+ return fmt.Sprintf("%d:%s", newID, tableID), true
+ }
+ movedTables := make(map[string]*Table, len(m.tables))
+ for key, table := range m.tables {
+ if newKey, ok := rekey(key); ok {
+ var roomID int
+ var tableID string
+ fmt.Sscanf(key, "%d:%s", &roomID, &tableID)
+ table.RoomID = idMap[roomID]
+ table.game.setRoom(table.RoomID)
+ movedTables[newKey] = table
+ delete(m.tables, key)
+ }
+ }
+ for newKey, table := range movedTables {
+ m.tables[newKey] = table
+ }
+ movedMachines := make(map[string]*Machine, len(m.machines))
+ for key, machine := range m.machines {
+ if newKey, ok := rekey(key); ok {
+ var roomID int
+ var tableID string
+ fmt.Sscanf(key, "%d:%s", &roomID, &tableID)
+ machine.RoomID = idMap[roomID]
+ movedMachines[newKey] = machine
+ delete(m.machines, key)
+ }
+ }
+ for newKey, machine := range movedMachines {
+ m.machines[newKey] = machine
+ }
+}
+
+// EvictRoom removes every cached table and machine in roomID (graceful
+// shutdown semantics for deleted/renumbered rooms): outstanding wagers are
+// refunded through each game's refundAll before the entries drop.
+func (m *Manager) EvictRoom(roomID int) []Event {
+ prefix := fmt.Sprintf("%d:", roomID)
+ var events []Event
+ m.mu.Lock()
+ for key, table := range m.tables {
+ if strings.HasPrefix(key, prefix) {
+ events = append(events, table.refundAll()...)
+ delete(m.tables, key)
+ }
+ }
+ for key, machine := range m.machines {
+ if strings.HasPrefix(key, prefix) {
+ events = append(events, machine.refundAll()...)
+ delete(m.machines, key)
+ }
+ }
+ m.mu.Unlock()
+ return events
+}