package poker import ( "context" "encoding/json" "errors" "fmt" "log" "sync" "time" ) // Phase represents the current state of a poker hand. type Phase string const ( PhaseWaiting Phase = "waiting" // waiting for enough players PhaseDealing Phase = "dealing" // cards being dealt (brief transition) PhasePreflop Phase = "preflop" PhaseFlop Phase = "flop" PhaseTurn Phase = "turn" PhaseRiver Phase = "river" PhaseShowdown Phase = "showdown" ) // Config holds immutable table configuration. type Config struct { ID string `json:"id"` Name string `json:"name"` SeatCount int `json:"seatCount"` SmallBlind int64 `json:"smallBlind"` BigBlind int64 `json:"bigBlind"` MinBuyin int64 `json:"minBuyin"` MaxBuyin int64 `json:"maxBuyin"` TurnTimer int `json:"turnTimer"` // seconds } // Seat holds per-seat state. type Seat struct { Username string `json:"username,omitempty"` Stack int64 `json:"stack"` Bet int64 `json:"bet"` // current street bet Folded bool `json:"folded"` SitOut bool `json:"sitOut"` Revealed bool `json:"revealed"` // hole cards shown to all } // HandState holds the mutable state for a single hand. type HandState struct { Phase Phase `json:"phase"` Community []string `json:"community"` Pot int64 `json:"pot"` ActionOn int `json:"actionOn"` // seat index ActionDeadline int64 `json:"actionDeadline"` // unix timestamp DealerSeat int `json:"dealerSeat"` LastRaise int64 `json:"lastRaise"` MinRaise int64 `json:"minRaise"` } // Table is the central struct — holds config, seats, and hand state. type Table struct { mu sync.RWMutex Config Config Seats []*Seat Hand HandState deck []string hands map[string][]string // username -> hole cards, never sent to other players folded map[string]bool acted map[string]bool cancelTurn context.CancelFunc // cancels the current turn timer // Broadcast is set by the manager — called whenever state changes // that all connected clients should know about. Broadcast func(msg interface{}) // SendPrivate sends a message to a single username only. SendPrivate func(username string, msg interface{}) } // NewTable creates a Table from a Config. func NewTable(cfg Config) *Table { seats := make([]*Seat, cfg.SeatCount) for i := range seats { seats[i] = &Seat{} } return &Table{ Config: cfg, Seats: seats, Hand: HandState{Phase: PhaseWaiting, Community: []string{}}, hands: make(map[string][]string), folded: make(map[string]bool), acted: make(map[string]bool), } } // ── Seat management ────────────────────────────────────────────── // Sit places a player in a seat and deducts the buy-in from their stack. // The caller is responsible for deducting from the player's wallet first. func (t *Table) Sit(username string, seatIndex int, buyin int64) error { t.mu.Lock() if seatIndex < 0 || seatIndex >= len(t.Seats) { t.mu.Unlock() return errors.New("invalid seat index") } if t.Seats[seatIndex].Username != "" { t.mu.Unlock() return errors.New("seat taken") } for _, s := range t.Seats { if s.Username == username { t.mu.Unlock() return errors.New("already seated") } } if buyin < t.Config.MinBuyin || buyin > t.Config.MaxBuyin { t.mu.Unlock() return fmt.Errorf("buy-in must be between %d and %d", t.Config.MinBuyin, t.Config.MaxBuyin) } t.Seats[seatIndex].Username = username t.Seats[seatIndex].Stack = buyin t.Seats[seatIndex].Folded = false t.Seats[seatIndex].SitOut = false t.mu.Unlock() t.broadcastState() t.maybeStartHand() return nil } // Stand removes a player from their seat and returns their remaining stack. // Cannot stand mid-hand unless folded. func (t *Table) Stand(username string) (int64, error) { t.mu.Lock() seat := t.findSeat(username) if seat == nil { t.mu.Unlock() return 0, errors.New("not seated") } // Allow standing if folded OR if marked as disconnected (SitOut mid-hand) if t.Hand.Phase != PhaseWaiting && !seat.Folded && !seat.SitOut { t.mu.Unlock() return 0, errors.New("cannot stand mid-hand while active") } stack := seat.Stack *seat = Seat{} t.mu.Unlock() t.broadcastState() return stack, nil } func (t *Table) Disconnect(username string) (int64, bool) { t.mu.Lock() seat := t.findSeat(username) if seat == nil { t.mu.Unlock() return 0, false } if t.Hand.Phase == PhaseWaiting { stack := seat.Stack *seat = Seat{} t.mu.Unlock() t.broadcastState() return stack, true // safe to refund immediately } // Mid-hand — mark sitout and let the hand clean up seat.SitOut = true if t.Seats[t.Hand.ActionOn].Username == username { if t.cancelTurn != nil { t.cancelTurn() } } t.mu.Unlock() t.broadcastState() return 0, false // refund will happen via player:disconnect:stand after hand ends } // TopUp adds chips to a seated player's stack (between hands only). func (t *Table) TopUp(username string, amount int64) error { t.mu.Lock() if t.Hand.Phase != PhaseWaiting { t.mu.Unlock() return errors.New("can only top up between hands") } seat := t.findSeat(username) if seat == nil { t.mu.Unlock() return errors.New("not seated") } if seat.Stack+amount > t.Config.MaxBuyin { t.mu.Unlock() return fmt.Errorf("would exceed max buy-in of %d", t.Config.MaxBuyin) } seat.Stack += amount t.mu.Unlock() t.broadcastState() return nil } // ── Hand flow ──────────────────────────────────────────────────── func (t *Table) activeSeatCount() int { n := 0 for _, s := range t.Seats { if s.Username != "" && !s.SitOut && s.Stack > 0 { n++ } } return n } func (t *Table) maybeStartHand() { if t.Hand.Phase == PhaseWaiting && t.activeSeatCount() >= 2 { go t.startHand() } } func (t *Table) startHand() { t.mu.Lock() // Reset per-hand state t.deck = NewDeck() t.hands = make(map[string][]string) t.folded = make(map[string]bool) t.acted = make(map[string]bool) for _, s := range t.Seats { s.Bet = 0 s.Folded = false s.Revealed = false } // Advance dealer button t.Hand.DealerSeat = t.nextActiveSeat(t.Hand.DealerSeat) t.Hand.Community = []string{} t.Hand.Pot = 0 t.Hand.Phase = PhaseDealing // Deal hole cards active := t.activeSeats() for _, idx := range active { var cards []string cards, t.deck = Deal(t.deck, 2) t.hands[t.Seats[idx].Username] = cards } t.mu.Unlock() // Send private hole cards for username, cards := range t.hands { if t.SendPrivate != nil { t.SendPrivate(username, map[string]interface{}{ "type": "hand:dealt", "cards": cards, }) } } t.mu.Lock() // Post blinds sbSeat := t.nextActiveSeat(t.Hand.DealerSeat) bbSeat := t.nextActiveSeat(sbSeat) t.postBlind(sbSeat, t.Config.SmallBlind) t.postBlind(bbSeat, t.Config.BigBlind) t.Hand.LastRaise = t.Config.BigBlind t.Hand.MinRaise = t.Config.BigBlind * 2 t.Hand.Phase = PhasePreflop // Action starts left of big blind t.Hand.ActionOn = t.nextActiveSeat(bbSeat) t.mu.Unlock() t.broadcastState() t.startTurnTimer() } func (t *Table) postBlind(seatIdx int, amount int64) { seat := t.Seats[seatIdx] if seat.Stack < amount { amount = seat.Stack // all-in blind } seat.Stack -= amount seat.Bet += amount t.Hand.Pot += amount } // ── Player actions ─────────────────────────────────────────────── type ActionType string const ( ActionFold ActionType = "fold" ActionCheck ActionType = "check" ActionCall ActionType = "call" ActionRaise ActionType = "raise" ) // Action processes a player's action. Returns an error if invalid. func (t *Table) Action(username string, action ActionType, amount int64) error { t.mu.Lock() if t.Hand.Phase == PhaseWaiting || t.Hand.Phase == PhaseDealing || t.Hand.Phase == PhaseShowdown { t.mu.Unlock() return errors.New("no action in current phase") } seatIdx := t.findSeatIndex(username) if seatIdx < 0 { t.mu.Unlock() return errors.New("not seated") } if seatIdx != t.Hand.ActionOn { t.mu.Unlock() return errors.New("not your turn") } seat := t.Seats[seatIdx] currentBet := t.maxBet() switch action { case ActionFold: seat.Folded = true t.folded[username] = true case ActionCheck: if seat.Bet < currentBet { t.mu.Unlock() return errors.New("cannot check — must call or raise") } case ActionCall: toCall := currentBet - seat.Bet if toCall <= 0 { t.mu.Unlock() return errors.New("nothing to call — check instead") } if toCall > seat.Stack { toCall = seat.Stack // all-in } seat.Stack -= toCall seat.Bet += toCall t.Hand.Pot += toCall case ActionRaise: if amount < t.Hand.MinRaise { t.mu.Unlock() return fmt.Errorf("minimum raise is %d", t.Hand.MinRaise) } toCall := currentBet - seat.Bet total := toCall + amount if total > seat.Stack { total = seat.Stack // all-in } seat.Stack -= total seat.Bet += total t.Hand.Pot += total t.Hand.LastRaise = amount t.Hand.MinRaise = currentBet + amount t.acted = make(map[string]bool) // raise reopens action default: t.mu.Unlock() return fmt.Errorf("unknown action: %s", action) } t.acted[username] = true // Cancel the current turn timer if t.cancelTurn != nil { t.cancelTurn() t.cancelTurn = nil } t.mu.Unlock() t.broadcastAction(username, action, amount) t.advance() return nil } // Reveal marks a player's hole cards as public. func (t *Table) Reveal(username string) { t.mu.Lock() seat := t.findSeat(username) if seat == nil { t.mu.Unlock() return } seat.Revealed = true cards := t.hands[username] t.mu.Unlock() if t.Broadcast != nil { t.Broadcast(map[string]interface{}{ "type": "hand:reveal", "username": username, "cards": cards, }) } } // ── Street advancement ─────────────────────────────────────────── func (t *Table) advance() { t.mu.Lock() defer t.mu.Unlock() // Check if only one player remains if t.countActivePlayers() == 1 { t.awardPot() return } // Check if betting is complete for this street if !t.bettingComplete() { t.Hand.ActionOn = t.nextActiveSeat(t.Hand.ActionOn) t.mu.Unlock() t.broadcastState() t.startTurnTimer() t.mu.Lock() return } // Advance to next street switch t.Hand.Phase { case PhasePreflop: var cards []string cards, t.deck = Deal(t.deck, 3) t.Hand.Community = cards t.Hand.Phase = PhaseFlop case PhaseFlop: var cards []string cards, t.deck = Deal(t.deck, 1) t.Hand.Community = append(t.Hand.Community, cards...) t.Hand.Phase = PhaseTurn case PhaseTurn: var cards []string cards, t.deck = Deal(t.deck, 1) t.Hand.Community = append(t.Hand.Community, cards...) t.Hand.Phase = PhaseRiver case PhaseRiver: t.Hand.Phase = PhaseShowdown t.mu.Unlock() t.doShowdown() t.mu.Lock() return } // Reset bets for new street for _, s := range t.Seats { s.Bet = 0 } t.acted = make(map[string]bool) t.Hand.LastRaise = 0 t.Hand.MinRaise = t.Config.BigBlind // Action starts left of dealer post-flop t.Hand.ActionOn = t.nextActiveSeat(t.Hand.DealerSeat) t.mu.Unlock() t.broadcastState() t.startTurnTimer() t.mu.Lock() } func (t *Table) doShowdown() { remaining := make(map[string][]string) for username, cards := range t.hands { if !t.folded[username] { remaining[username] = cards } } winners := Winners(remaining, t.Hand.Community) revealedHands := make(map[string][]string) for _, w := range winners { revealedHands[w.Username] = remaining[w.Username] } t.mu.RLock() for _, seat := range t.Seats { if seat.Revealed && seat.Username != "" { revealedHands[seat.Username] = t.hands[seat.Username] } } t.mu.RUnlock() winnerNames := make([]string, len(winners)) winnerDescs := make(map[string]string) for i, w := range winners { winnerNames[i] = w.Username winnerDescs[w.Username] = w.Desc } share := t.Hand.Pot / int64(len(winners)) remainder := t.Hand.Pot % int64(len(winners)) t.mu.Lock() for i, w := range winners { seat := t.findSeat(w.Username) if seat != nil { extra := int64(0) if i == 0 { extra = remainder } seat.Stack += share + extra } } t.Hand.Pot = 0 t.mu.Unlock() if t.Broadcast != nil { t.Broadcast(map[string]interface{}{ "type": "hand:end", "winners": winnerNames, "descs": winnerDescs, "hands": revealedHands, }) } time.Sleep(5 * time.Second) t.mu.Lock() t.Hand.Phase = PhaseWaiting t.cleanupSeats() t.mu.Unlock() t.broadcastState() t.mu.Lock() t.maybeStartHand() t.mu.Unlock() } func (t *Table) awardPot() { for _, s := range t.Seats { if s.Username != "" && !s.Folded { s.Stack += t.Hand.Pot if t.Broadcast != nil { t.Broadcast(map[string]interface{}{ "type": "hand:end", "winners": []string{s.Username}, "descs": map[string]string{}, "hands": map[string][]string{}, }) } break } } t.Hand.Pot = 0 time.Sleep(2 * time.Second) t.mu.Lock() t.Hand.Phase = PhaseWaiting t.cleanupSeats() t.mu.Unlock() t.broadcastState() t.maybeStartHand() } // ── Turn timer ─────────────────────────────────────────────────── func (t *Table) startTurnTimer() { t.mu.Lock() if t.cancelTurn != nil { t.cancelTurn() } ctx, cancel := context.WithTimeout(context.Background(), time.Duration(t.Config.TurnTimer)*time.Second) t.cancelTurn = cancel username := t.Seats[t.Hand.ActionOn].Username t.Hand.ActionDeadline = time.Now().Add( time.Duration(t.Config.TurnTimer) * time.Second).Unix() t.mu.Unlock() go func() { <-ctx.Done() if ctx.Err() == context.DeadlineExceeded { log.Printf("[poker] turn timer expired for %s at table %s", username, t.Config.ID) t.Action(username, ActionFold, 0) } }() } // ── Helpers ────────────────────────────────────────────────────── func (t *Table) findSeat(username string) *Seat { for _, s := range t.Seats { if s.Username == username { return s } } return nil } func (t *Table) findSeatIndex(username string) int { for i, s := range t.Seats { if s.Username == username { return i } } return -1 } func (t *Table) activeSeats() []int { var idxs []int for i, s := range t.Seats { if s.Username != "" && !s.SitOut && s.Stack > 0 { idxs = append(idxs, i) } } return idxs } func (t *Table) nextActiveSeat(from int) int { n := len(t.Seats) for i := 1; i <= n; i++ { idx := (from + i) % n s := t.Seats[idx] if s.Username != "" && !s.SitOut && !s.Folded && s.Stack > 0 { return idx } } return from } func (t *Table) countActivePlayers() int { n := 0 for _, s := range t.Seats { if s.Username != "" && !s.Folded { n++ } } return n } func (t *Table) maxBet() int64 { var max int64 for _, s := range t.Seats { if s.Bet > max { max = s.Bet } } return max } func (t *Table) bettingComplete() bool { currentBet := t.maxBet() for _, s := range t.Seats { if s.Username == "" || s.Folded || s.SitOut { continue } if s.Stack == 0 { continue // all-in } if !t.acted[s.Username] { return false } if s.Bet < currentBet { return false } } return true } func (t *Table) cleanupSeats() { for _, s := range t.Seats { if s.Username == "" { continue } if s.Stack == 0 { log.Printf("[poker] %s busted", s.Username) if t.Broadcast != nil { t.Broadcast(map[string]interface{}{ "type": "player:bust", "username": s.Username, "tableId": t.Config.ID, }) } *s = Seat{} } else if s.SitOut { log.Printf("[poker] standing disconnected player %s with %d chips", s.Username, s.Stack) if t.Broadcast != nil { t.Broadcast(map[string]interface{}{ "type": "player:disconnect:stand", "username": s.Username, "stack": s.Stack, "tableId": t.Config.ID, }) } *s = Seat{} } } } // ── State broadcast ────────────────────────────────────────────── // PublicState returns the table state safe to send to all clients. // Hole cards are never included. func (t *Table) PublicState() map[string]interface{} { t.mu.RLock() defer t.mu.RUnlock() seats := make([]map[string]interface{}, len(t.Seats)) for i, s := range t.Seats { seats[i] = map[string]interface{}{ "username": s.Username, "stack": s.Stack, "bet": s.Bet, "folded": s.Folded, "sitOut": s.SitOut, "revealed": s.Revealed, } } return map[string]interface{}{ "type": "table:state", "tableId": t.Config.ID, "phase": t.Hand.Phase, "community": t.Hand.Community, "pot": t.Hand.Pot, "actionOn": t.Hand.ActionOn, "actionDeadline": t.Hand.ActionDeadline, "dealerSeat": t.Hand.DealerSeat, "seats": seats, "config": t.Config, } } func (t *Table) broadcastState() { if t.Broadcast != nil { t.Broadcast(t.PublicState()) } } func (t *Table) broadcastAction(username string, action ActionType, amount int64) { if t.Broadcast != nil { t.Broadcast(map[string]interface{}{ "type": "player:action", "tableId": t.Config.ID, "username": username, "action": action, "amount": amount, }) } } // MarshalConfig serialises the table config to JSON for Redis storage. func (c Config) Marshal() ([]byte, error) { return json.Marshal(c) }