overte-api/poker/table.go

757 lines
18 KiB
Go
Raw Normal View History

2026-03-17 20:17:36 -07:00
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) {
2026-03-18 05:02:22 +00:00
t.mu.Unlock()
2026-03-17 20:17:36 -07:00
return errors.New("invalid seat index")
}
if t.Seats[seatIndex].Username != "" {
2026-03-18 05:02:22 +00:00
t.mu.Unlock()
2026-03-17 20:17:36 -07:00
return errors.New("seat taken")
}
for _, s := range t.Seats {
if s.Username == username {
2026-03-18 05:02:22 +00:00
t.mu.Unlock()
2026-03-17 20:17:36 -07:00
return errors.New("already seated")
}
}
if buyin < t.Config.MinBuyin || buyin > t.Config.MaxBuyin {
2026-03-18 05:02:22 +00:00
t.mu.Unlock()
2026-03-17 20:17:36 -07:00
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
2026-03-18 05:02:22 +00:00
t.mu.Unlock()
2026-03-17 20:17:36 -07:00
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 {
2026-03-18 05:02:22 +00:00
t.mu.Unlock()
2026-03-17 20:17:36 -07:00
return 0, errors.New("not seated")
}
if t.Hand.Phase != PhaseWaiting && !seat.Folded {
2026-03-18 05:02:22 +00:00
t.mu.Unlock()
2026-03-17 20:17:36 -07:00
return 0, errors.New("cannot stand mid-hand while active")
}
stack := seat.Stack
2026-03-18 05:02:22 +00:00
*seat = Seat{}
t.mu.Unlock()
2026-03-17 20:17:36 -07:00
t.broadcastState()
return stack, nil
}
2026-03-18 07:50:25 +00:00
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
2026-03-18 07:38:45 +00:00
}
2026-03-17 20:17:36 -07:00
// 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 {
2026-03-18 05:02:22 +00:00
t.mu.Unlock()
2026-03-17 20:17:36 -07:00
return errors.New("can only top up between hands")
}
seat := t.findSeat(username)
if seat == nil {
2026-03-18 05:02:22 +00:00
t.mu.Unlock()
2026-03-17 20:17:36 -07:00
return errors.New("not seated")
}
if seat.Stack+amount > t.Config.MaxBuyin {
2026-03-18 05:02:22 +00:00
t.mu.Unlock()
2026-03-17 20:17:36 -07:00
return fmt.Errorf("would exceed max buy-in of %d", t.Config.MaxBuyin)
}
seat.Stack += amount
2026-03-18 05:02:22 +00:00
t.mu.Unlock()
2026-03-17 20:17:36 -07:00
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 {
2026-03-18 05:02:22 +00:00
t.mu.Unlock()
2026-03-17 20:17:36 -07:00
return errors.New("no action in current phase")
}
seatIdx := t.findSeatIndex(username)
if seatIdx < 0 {
2026-03-18 05:02:22 +00:00
t.mu.Unlock()
2026-03-17 20:17:36 -07:00
return errors.New("not seated")
}
if seatIdx != t.Hand.ActionOn {
2026-03-18 05:02:22 +00:00
t.mu.Unlock()
2026-03-17 20:17:36 -07:00
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 {
2026-03-18 05:02:22 +00:00
t.mu.Unlock()
2026-03-17 20:17:36 -07:00
return errors.New("cannot check — must call or raise")
}
case ActionCall:
toCall := currentBet - seat.Bet
if toCall <= 0 {
2026-03-18 05:02:22 +00:00
t.mu.Unlock()
2026-03-17 20:17:36 -07:00
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 {
2026-03-18 05:02:22 +00:00
t.mu.Unlock()
2026-03-17 20:17:36 -07:00
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:
2026-03-18 05:02:22 +00:00
t.mu.Unlock()
2026-03-17 20:17:36 -07:00
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 {
2026-03-18 07:38:45 +00:00
extra = remainder
2026-03-17 20:17:36 -07:00
}
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
2026-03-18 07:38:45 +00:00
t.cleanupSeats()
2026-03-17 20:17:36 -07:00
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)
2026-03-18 07:38:45 +00:00
t.mu.Lock()
2026-03-17 20:17:36 -07:00
t.Hand.Phase = PhaseWaiting
2026-03-18 07:38:45 +00:00
t.cleanupSeats()
t.mu.Unlock()
2026-03-17 20:17:36 -07:00
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
}
2026-03-18 07:38:45 +00:00
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{}
}
}
}
2026-03-17 20:17:36 -07:00
// ── 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)
}