package casino import ( crand "crypto/rand" "encoding/binary" "fmt" "math/rand" "reflect" "sort" "strconv" "strings" "sync" ) type Manager struct { mu sync.Mutex tables map[string]*Table machines map[string]*Machine rng *rand.Rand // seed is the seed the manager's stream was created with, so a test can // assert that the entropy path really drew a fresh one. seed int64 } // NewManager returns a Manager with DETERMINISTIC per-game RNG streams // derived from seed. It is the test seam: a fixed seed must give fixed // shoes, reels, and spins. Production uses NewManagerEntropy. func NewManager(seed int64) *Manager { return newManager(seed) } // NewManagerEntropy returns a Manager whose per-game RNG streams are seeded // from crypto/rand. Casino outcomes must not be reconstructible from the // clock: a wall-clock seed lets anyone who knows (or brute-forces) the // startup nanosecond replay every shoe and reel stream, so a certified // 0.97-RTP machine becomes an unlimited-credit one. The streams themselves // stay math/rand (unbiased Intn/Shuffle) and each game keeps its own. func NewManagerEntropy() *Manager { return newManager(entropySeed()) } // entropySeed draws a fresh 63-bit seed from crypto/rand. A broken entropy // source is fatal instead of silently degrading to a reconstructible clock // seed: the casino's game integrity depends on it. func entropySeed() int64 { var b [8]byte if _, err := crand.Read(b[:]); err != nil { panic(fmt.Sprintf("casino entropy seed: %v", err)) } // A non-negative 63-bit value: math/rand accepts any int64 seed, and a // non-negative seed prints cleanly in diagnostics. return int64(binary.LittleEndian.Uint64(b[:]) >> 1) } func newManager(seed int64) *Manager { return &Manager{ tables: make(map[string]*Table), machines: make(map[string]*Machine), rng: rand.New(rand.NewSource(seed)), seed: seed, } } func tableKey(roomID int, tableID string) string { return fmt.Sprintf("%d:%s", roomID, tableID) } // 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 } cfg = cfg.Normalize() key := tableKey(roomID, cfg.ID) m.mu.Lock() defer m.mu.Unlock() if machine := m.machines[key]; machine != nil { // DeepEqual (not ==) so the *bool rule fields compare by value; // == would treat every freshly parsed config as different and // discard an idle machine on every room load. if reflect.DeepEqual(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 // independent stream protected by that game's lock. machine := newMachine(roomID, cfg, rand.New(rand.NewSource(m.rng.Int63()))) m.machines[key] = machine return 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 } cfg = cfg.Normalize() key := tableKey(roomID, cfg.ID) m.mu.Lock() defer m.mu.Unlock() if table := m.tables[key]; table != nil { if reflect.DeepEqual(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()))) if table == nil { return nil } 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)] } // snapshotTables copies the live table list under the lock so callers can // iterate without holding it (table methods take their own locks). func (m *Manager) snapshotTables() []*Table { m.mu.Lock() tables := make([]*Table, 0, len(m.tables)) for _, table := range m.tables { tables = append(tables, table) } m.mu.Unlock() return tables } func (m *Manager) snapshotMachines() []*Machine { m.mu.Lock() machines := make([]*Machine, 0, len(m.machines)) for _, machine := range m.machines { machines = append(machines, machine) } m.mu.Unlock() return machines } func (m *Manager) Tick() []Event { tables := m.snapshotTables() 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()...) } machines := m.snapshotMachines() 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 } // withMachine runs fn against the named machine when it exists. func (m *Manager) withMachine(roomID int, machineID string, fn func(*Machine) []Event) []Event { m.mu.Lock() machine := m.machines[tableKey(roomID, machineID)] m.mu.Unlock() if machine == nil { return nil } return fn(machine) } func (m *Manager) JoinMachine(roomID int, cfg MachineConfig, name string) []Event { machine := m.EnsureMachine(roomID, cfg) if machine == nil { return []Event{{RoomID: roomID, TableID: cfg.ID, Type: EventResult, PrivateTo: name, Message: "That game is not available here."}} } return machine.join(name) } func (m *Manager) SetMachineBet(roomID int, machineID, name string, amount int, wallet Wallet) []Event { return m.withMachine(roomID, machineID, func(machine *Machine) []Event { return machine.setBet(name, amount, wallet) }) } func (m *Manager) StartMachine(roomID int, machineID, name string, wallet Wallet) []Event { return m.withMachine(roomID, machineID, func(machine *Machine) []Event { return machine.start(name, wallet) }) } func (m *Manager) EnableMachineAutoBet(roomID int, machineID, name string, wallet Wallet) []Event { return m.withMachine(roomID, machineID, func(machine *Machine) []Event { return machine.enableAutoBet(name, wallet) }) } func (m *Manager) StopMachineAutoBet(roomID int, machineID, name string) ([]Event, bool) { stopped := false events := m.withMachine(roomID, machineID, func(machine *Machine) []Event { events, ok := machine.stopAutoBet(name) stopped = ok return events }) return events, stopped } func (m *Manager) LeaveMachine(roomID int, machineID, name string) []Event { return m.withMachine(roomID, machineID, func(machine *Machine) []Event { return machine.leave(name) }) } func (m *Manager) Join(roomID int, cfg TableConfig, name string) []Event { table := m.EnsureTable(roomID, cfg) if table == nil { return []Event{{RoomID: roomID, TableID: cfg.ID, Type: EventResult, PrivateTo: name, Message: "That game is not available here."}} } return table.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, spot string, amount int, wallet Wallet) []Event { if table := m.Table(roomID, tableID); table != nil { return table.bet(name, spot, amount, wallet) } return []Event{{RoomID: roomID, TableID: tableID, Type: EventResult, Player: name, PrivateTo: name, Message: "That game is not available here."}} } func (m *Manager) Action(roomID int, tableID, name, action string) []Event { if table := m.Table(roomID, tableID); table != nil { return table.action(name, action) } return []Event{{RoomID: roomID, TableID: tableID, Type: EventResult, PrivateTo: name, Message: "That game is not available here."}} } func (m *Manager) Rebet(roomID int, tableID, name string) (string, int) { if table := m.Table(roomID, tableID); table != nil { return table.rebet(name) } return "", 0 } // MarkDisconnected removes the player from every table and machine, // forfeiting any wager they had committed, so seats free up immediately. func (m *Manager) MarkDisconnected(name string) []Event { var events []Event for _, table := range m.snapshotTables() { events = append(events, table.markDisconnected(name)...) } for _, machine := range m.snapshotMachines() { events = append(events, machine.markDisconnected(name)...) } return events } 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 } // RefundAll resolves every outstanding wager by refunding committed stakes // to their players and then closing each game's round. Graceful shutdown and // room eviction use this: rounds cannot settle, so no player may lose a // stake to the shutdown itself. Refunds go through the wallet (durable, // journaled) and the refund events are returned for delivery. // // It is deliberately NOT a way to clear live tables: every game discards // the round it refunded (seats keep their last bet only for "rebet", but // phases, hands, spins, and timers are reset), so a refunded round cannot // settle later and a second call refunds nothing. A caller that wants the // games to keep playing must not use this. 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. // // Callers MUST run on the owner goroutine: each game's RoomID is mutated // without taking that game's own lock, which is safe only because Tick and // every command are owner-serialized too. func (m *Manager) RekeyRooms(idMap map[int]int) { if len(idMap) == 0 { return } m.mu.Lock() defer m.mu.Unlock() // rekey parses a room:table key and returns the remapped key and room ID. // strings.Cut/strconv is used instead of fmt.Sscanf because a table or // machine ID may legally contain whitespace (validation rejects only // surrounding whitespace) and %s would stop at the first space, rebuilding // a corrupted key and orphaning the game. rekey := func(key string) (string, int, bool) { roomStr, tableID, found := strings.Cut(key, ":") if !found { return "", 0, false } roomID, err := strconv.Atoi(roomStr) if err != nil { return "", 0, false } newID, ok := idMap[roomID] if !ok || newID == roomID { return "", 0, false } return fmt.Sprintf("%d:%s", newID, tableID), newID, true } movedTables := make(map[string]*Table, len(m.tables)) for key, table := range m.tables { if newKey, newRoomID, ok := rekey(key); ok { // The Table handle and the game share one *tableBase, so this // single assignment updates event routing for both. table.RoomID = newRoomID 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, newRoomID, ok := rekey(key); ok { machine.RoomID = newRoomID 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) // Snapshot the games under the manager lock, then refund OUTSIDE it: // refundAll appends to the money journal and saves character files, and // holding m.mu across that I/O blocks every casino command and Tick. type evictable interface{ refundAll() []Event } var evicted []evictable m.mu.Lock() for key, table := range m.tables { if strings.HasPrefix(key, prefix) { evicted = append(evicted, table) delete(m.tables, key) } } for key, machine := range m.machines { if strings.HasPrefix(key, prefix) { evicted = append(evicted, machine) delete(m.machines, key) } } m.mu.Unlock() var events []Event for _, game := range evicted { events = append(events, game.refundAll()...) } return events }