package casino import ( "fmt" "math/rand" "sync" ) // tableGame is a multiplayer table game implementation. Table routes every // player interaction through this interface so new games plug in without // changing the dispatch layer. type tableGame interface { join(name string) []Event leave(name string) []Event bet(name, spot string, amount int, wallet Wallet) []Event tick() []Event action(name, action string) []Event rebet(name string) (spot string, amount int) markDisconnected(name string) []Event markConnected(name string) hasParticipant(name string) bool } // tableBase carries the identity every table game needs to build events. type tableBase struct { RoomID int Config TableConfig } func (b tableBase) event(message string, public bool) Event { return Event{RoomID: b.RoomID, TableID: b.Config.ID, Type: EventResult, Message: message, Public: public, Color: "casino_action"} } func (b tableBase) private(name, message string) Event { e := b.event(message, false) e.PrivateTo = name return e } func (b tableBase) playerAction(name, publicMessage, privateMessage string) Event { e := b.event(publicMessage, true) e.PrivateTo = name e.PrivateMessage = privateMessage return e } // Table is the manager-facing handle for a multiplayer table. It only holds // routing state; all game logic lives in the tableGame implementation. type Table struct { RoomID int Config TableConfig game tableGame } func newTable(roomID int, cfg TableConfig, rng *rand.Rand) *Table { cfg = cfg.Normalize() t := &Table{RoomID: roomID, Config: cfg} switch cfg.Game { case GameBlackjack: t.game = newBlackjackTable(roomID, cfg, rng) case GameBaccarat: t.game = newBaccaratTable(roomID, cfg, rng) default: t.game = newGenericTable(roomID, cfg, rng) } return t } func (t *Table) join(name string) []Event { return t.game.join(name) } func (t *Table) leave(name string) []Event { return t.game.leave(name) } func (t *Table) bet(name, spot string, amount int, wallet Wallet) []Event { return t.game.bet(name, spot, amount, wallet) } func (t *Table) tick() []Event { return t.game.tick() } func (t *Table) action(name, action string) []Event { return t.game.action(name, action) } func (t *Table) rebet(name string) (string, int) { return t.game.rebet(name) } func (t *Table) markDisconnected(name string) []Event { return t.game.markDisconnected(name) } func (t *Table) markConnected(name string) { t.game.markConnected(name) } func (t *Table) hasParticipant(name string) bool { return t.game.hasParticipant(name) } type tablePhase string const ( phaseWaiting tablePhase = "waiting" phaseBetting tablePhase = "betting" ) // genericTable is the fallback table game: every bettor's wager resolves // immediately against a simple spin once all connected players have bet. type genericTable struct { mu sync.Mutex RoomID int Config TableConfig participants map[string]*Participant order []string phase tablePhase timer countdown rng *rand.Rand } func newGenericTable(roomID int, cfg TableConfig, rng *rand.Rand) *genericTable { return &genericTable{ RoomID: roomID, Config: cfg, phase: phaseWaiting, participants: make(map[string]*Participant), rng: rng, } } func (t *genericTable) seats() []*Participant { seats := make([]*Participant, 0, len(t.order)) for _, name := range t.order { if p := t.participants[name]; p != nil { seats = append(seats, p) } } return seats } func (t *genericTable) 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 *genericTable) 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 *genericTable) bet(name, spot 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 spot != "" { return []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventResult, Player: name, PrivateTo: name, Message: "That table doesn't take a bet target."}} } 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 p.HasBet { return []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventResult, Player: name, PrivateTo: name, Message: "You have already bet this round."}} } 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)}} } 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.timer.start(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.timer.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.Config.BettingTicks), Public: true}) } if len(t.participants) == 1 || allBet(t.seats()) { events = append(events, t.resolveRound()...) } return events } func (t *genericTable) tick() []Event { t.mu.Lock() defer t.mu.Unlock() if t.phase != phaseBetting { return nil } t.timer.tick() if allBet(t.seats()) { return t.resolveRound() } if !t.timer.expired() { return nil } var events []Event for _, name := range sitOutNames(t.seats()) { 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 anyBet(t.seats()) { events = append(events, t.resolveRound()...) } else { t.resetRound() } return events } func (t *genericTable) 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 *genericTable) resetRound() { t.phase = phaseWaiting t.timer.stop() clearBets(t.seats()) } func (t *genericTable) 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 *genericTable) markConnected(name string) { t.mu.Lock() defer t.mu.Unlock() if p := t.participants[name]; p != nil { p.Connected = true } } func (t *genericTable) hasParticipant(name string) bool { t.mu.Lock() defer t.mu.Unlock() return t.participants[name] != nil } func (t *genericTable) action(name, action string) []Event { return []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventResult, PrivateTo: name, Message: "That game has no player actions."}} } func (t *genericTable) rebet(name string) (string, int) { return "", t.Config.MinBet }