From 2836676ff536d5e431cff9c0888790dd9e1122ca Mon Sep 17 00:00:00 2001 From: nak Date: Tue, 17 Mar 2026 20:17:36 -0700 Subject: [PATCH] reorg poker code --- go.mod | 1 + go.sum | 10 + poker/deck.go | 37 +++ poker/eval.go | 71 +++++ poker/manager.go | 462 +++++++++++++++++++++++++++++++ poker/table.go | 702 +++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 1283 insertions(+) create mode 100644 poker/deck.go create mode 100644 poker/eval.go create mode 100644 poker/manager.go create mode 100644 poker/table.go diff --git a/go.mod b/go.mod index 053ca0f..b9df23c 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.22.2 require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/chehsunliu/poker v0.1.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/redis/go-redis/v9 v9.18.0 // indirect diff --git a/go.sum b/go.sum index 049c9b9..da6b3cb 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,20 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chehsunliu/poker v0.1.0 h1:OeB4O+QROhA/DiXUhBBlkgbzCx0ZVWMpWgKNu+PX9vI= +github.com/chehsunliu/poker v0.1.0/go.mod h1:V6K4yyDbafp0k6lUnYbwoTS/KsHSB1EWiJdEk54uB1w= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/loganjspears/joker v0.0.0-20180219043703-3f2f69a75914/go.mod h1:76SAnflG7ZFhgtnaVCpP6A5Z1S/VMFzRBN7KGm5j4oc= +github.com/notnil/joker v0.0.0-20180219043703-3f2f69a75914/go.mod h1:L0Sdr2nYdktjerdXpIn9wOCn+GebPs/nCL2qH6RTGa0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/poker/deck.go b/poker/deck.go new file mode 100644 index 0000000..cc90bcf --- /dev/null +++ b/poker/deck.go @@ -0,0 +1,37 @@ +package poker + +import ( + "math/rand" + "time" +) + +// Card represents a playing card as a two-character string. +// Ranks: 2-9, T, J, Q, K, A +// Suits: s (spades), h (hearts), d (diamonds), c (clubs) +// Examples: "As", "Kh", "Td", "2c" + +var ranks = []string{"2", "3", "4", "5", "6", "7", "8", "9", "T", "J", "Q", "K", "A"} +var suits = []string{"s", "h", "d", "c"} + +// NewDeck returns a freshly shuffled 52-card deck. +func NewDeck() []string { + deck := make([]string, 0, 52) + for _, r := range ranks { + for _, s := range suits { + deck = append(deck, r+s) + } + } + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + rng.Shuffle(len(deck), func(i, j int) { + deck[i], deck[j] = deck[j], deck[i] + }) + return deck +} + +// Deal removes and returns the top n cards from the deck. +func Deal(deck []string, n int) (cards []string, remaining []string) { + if n > len(deck) { + n = len(deck) + } + return deck[:n], deck[n:] +} diff --git a/poker/eval.go b/poker/eval.go new file mode 100644 index 0000000..a33f821 --- /dev/null +++ b/poker/eval.go @@ -0,0 +1,71 @@ +package poker + +import ( + "sort" + + gopoker "github.com/chehsunliu/poker" +) + +// HandRank evaluates the best 5-card hand from the given cards (hole + community). +// Returns a rank value where lower is better (1 = royal flush). +func HandRank(cards []string) int32 { + gocards := make([]gopoker.Card, len(cards)) + for i, c := range cards { + gocards[i] = gopoker.NewCard(c) + } + return gopoker.Evaluate(gocards) +} + +// HandDescription returns a human-readable description of the best hand. +func HandDescription(cards []string) string { + gocards := make([]gopoker.Card, len(cards)) + for i, c := range cards { + gocards[i] = gopoker.NewCard(c) + } + return gopoker.RankString(gopoker.Evaluate(gocards)) +} + +// Winner represents a player and their hand rank at showdown. +type Winner struct { + Username string + Rank int32 + Desc string +} + +// Showdown evaluates all active players' hands against the community cards +// and returns winners sorted best to worst. Ties are included. +// hands maps username -> []string of hole cards (2 cards). +// community is the board (3-5 cards). +func Showdown(hands map[string][]string, community []string) []Winner { + results := make([]Winner, 0, len(hands)) + for username, hole := range hands { + all := append(hole, community...) + rank := HandRank(all) + results = append(results, Winner{ + Username: username, + Rank: rank, + Desc: HandDescription(all), + }) + } + // Sort ascending — lower rank value = better hand + sort.Slice(results, func(i, j int) bool { + return results[i].Rank < results[j].Rank + }) + return results +} + +// Winners returns only the players who tied for best hand. +func Winners(hands map[string][]string, community []string) []Winner { + all := Showdown(hands, community) + if len(all) == 0 { + return nil + } + best := all[0].Rank + var winners []Winner + for _, w := range all { + if w.Rank == best { + winners = append(winners, w) + } + } + return winners +} diff --git a/poker/manager.go b/poker/manager.go new file mode 100644 index 0000000..523f777 --- /dev/null +++ b/poker/manager.go @@ -0,0 +1,462 @@ +package poker + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "strings" + "sync" + + "github.com/redis/go-redis/v9" +) + +// Manager owns all poker tables and routes messages to/from them. +// It is the single entry point for both HTTP handlers and WS messages. +type Manager struct { + mu sync.RWMutex + tables map[string]*Table + + rdb *redis.Client + ctx context.Context + + // BroadcastToTable sends a message to all WS clients subscribed to a table. + BroadcastToTable func(tableID string, msg interface{}) + + // SendToUser sends a message to a single connected user. + SendToUser func(username string, msg interface{}) + + // ValidateToken validates a Bearer token and returns the username. + ValidateToken func(r *http.Request) (string, error) + + // AdjustBalance atomically adjusts a user's VERT balance. + // Negative amount = deduct. Returns new balance or error. + AdjustBalance func(username string, delta int64) (int64, error) +} + +func NewManager(rdb *redis.Client, ctx context.Context) *Manager { + m := &Manager{ + tables: make(map[string]*Table), + rdb: rdb, + ctx: ctx, + } + return m +} + +// LoadTables restores all table configs from Redis on startup. +func (m *Manager) LoadTables() error { + keys, err := m.rdb.SMembers(m.ctx, "poker:tables").Result() + if err != nil { + return err + } + for _, id := range keys { + raw, err := m.rdb.Get(m.ctx, "poker:table:"+id+":config").Bytes() + if err != nil { + log.Printf("[poker] could not load table %s: %v", id, err) + continue + } + var cfg Config + if err := json.Unmarshal(raw, &cfg); err != nil { + log.Printf("[poker] could not parse config for table %s: %v", id, err) + continue + } + t := m.newTable(cfg) + m.mu.Lock() + m.tables[id] = t + m.mu.Unlock() + log.Printf("[poker] loaded table %s (%s)", id, cfg.Name) + } + return nil +} + +func (m *Manager) newTable(cfg Config) *Table { + t := NewTable(cfg) + t.Broadcast = func(msg interface{}) { + if m.BroadcastToTable != nil { + m.BroadcastToTable(cfg.ID, msg) + } + } + t.SendPrivate = func(username string, msg interface{}) { + if m.SendToUser != nil { + m.SendToUser(username, msg) + } + } + // Handle bust events — return chips to wallet + go func() { + // bust events arrive via Broadcast in table.go, handled in WS routing + }() + return t +} + +// ── HTTP handlers ──────────────────────────────────────────────── + +// RegisterRoutes adds all poker routes to the provided mux. +// adminAuth is middleware for admin-only endpoints. +func (m *Manager) RegisterRoutes(mux *http.ServeMux, adminAuth func(http.HandlerFunc) http.HandlerFunc) { + // Public + mux.HandleFunc("/poker/tables", m.handleListTables) + mux.HandleFunc("/poker/tables/", m.handleTableRoute) + + // Admin + mux.HandleFunc("/poker/admin/tables", adminAuth(m.handleAdminCreateTable)) + mux.HandleFunc("/poker/admin/tables/", adminAuth(m.handleAdminTableRoute)) +} + +// GET /poker/tables +func (m *Manager) handleListTables(w http.ResponseWriter, r *http.Request) { + m.mu.RLock() + defer m.mu.RUnlock() + + tables := make([]map[string]interface{}, 0, len(m.tables)) + for _, t := range m.tables { + tables = append(tables, t.PublicState()) + } + jsonResponse(w, map[string]interface{}{"tables": tables}) +} + +// Routes under /poker/tables/:id/... +func (m *Manager) handleTableRoute(w http.ResponseWriter, r *http.Request) { + // Path: /poker/tables/{id}/{action} + parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/poker/tables/"), "/") + if len(parts) < 1 || parts[0] == "" { + http.Error(w, "missing table id", http.StatusBadRequest) + return + } + tableID := parts[0] + action := "" + if len(parts) >= 2 { + action = parts[1] + } + + m.mu.RLock() + t, ok := m.tables[tableID] + m.mu.RUnlock() + + if !ok { + http.Error(w, "table not found", http.StatusNotFound) + return + } + + switch { + case r.Method == http.MethodGet && action == "": + jsonResponse(w, t.PublicState()) + + case r.Method == http.MethodPost && action == "sit": + m.handleSit(w, r, t) + + case r.Method == http.MethodPost && action == "stand": + m.handleStand(w, r, t) + + case r.Method == http.MethodPost && action == "action": + m.handleAction(w, r, t) + + case r.Method == http.MethodPost && action == "topup": + m.handleTopUp(w, r, t) + + case r.Method == http.MethodPost && action == "reveal": + m.handleReveal(w, r, t) + + default: + http.Error(w, "not found", http.StatusNotFound) + } +} + +// POST /poker/tables/:id/sit +func (m *Manager) handleSit(w http.ResponseWriter, r *http.Request, t *Table) { + username, err := m.ValidateToken(r) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + var body struct { + SeatIndex int `json:"seatIndex"` + Buyin int64 `json:"buyin"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid body", http.StatusBadRequest) + return + } + if body.Buyin == 0 { + body.Buyin = t.Config.MinBuyin + } + + // Deduct from wallet first + if m.AdjustBalance != nil { + if _, err := m.AdjustBalance(username, -body.Buyin); err != nil { + jsonError(w, err.Error(), http.StatusPaymentRequired) + return + } + } + + if err := t.Sit(username, body.SeatIndex, body.Buyin); err != nil { + // Refund on failure + if m.AdjustBalance != nil { + m.AdjustBalance(username, body.Buyin) + } + jsonError(w, err.Error(), http.StatusBadRequest) + return + } + + log.Printf("[poker] %s sat at table %s seat %d with %d", username, t.Config.ID, body.SeatIndex, body.Buyin) + jsonResponse(w, map[string]interface{}{"ok": true, "buyin": body.Buyin}) +} + +// POST /poker/tables/:id/stand +func (m *Manager) handleStand(w http.ResponseWriter, r *http.Request, t *Table) { + username, err := m.ValidateToken(r) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + stack, err := t.Stand(username) + if err != nil { + jsonError(w, err.Error(), http.StatusBadRequest) + return + } + + // Return chips to wallet + if m.AdjustBalance != nil && stack > 0 { + m.AdjustBalance(username, stack) + } + + log.Printf("[poker] %s stood from table %s with %d chips", username, t.Config.ID, stack) + jsonResponse(w, map[string]interface{}{"ok": true, "returned": stack}) +} + +// POST /poker/tables/:id/action +func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request, t *Table) { + username, err := m.ValidateToken(r) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + var body struct { + Action ActionType `json:"action"` + Amount int64 `json:"amount"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid body", http.StatusBadRequest) + return + } + if err := t.Action(username, body.Action, body.Amount); err != nil { + jsonError(w, err.Error(), http.StatusBadRequest) + return + } + jsonResponse(w, map[string]interface{}{"ok": true}) +} + +// POST /poker/tables/:id/topup +func (m *Manager) handleTopUp(w http.ResponseWriter, r *http.Request, t *Table) { + username, err := m.ValidateToken(r) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + var body struct { + Amount int64 `json:"amount"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid body", http.StatusBadRequest) + return + } + + if m.AdjustBalance != nil { + if _, err := m.AdjustBalance(username, -body.Amount); err != nil { + jsonError(w, err.Error(), http.StatusPaymentRequired) + return + } + } + + if err := t.TopUp(username, body.Amount); err != nil { + if m.AdjustBalance != nil { + m.AdjustBalance(username, body.Amount) // refund + } + jsonError(w, err.Error(), http.StatusBadRequest) + return + } + jsonResponse(w, map[string]interface{}{"ok": true}) +} + +// POST /poker/tables/:id/reveal +func (m *Manager) handleReveal(w http.ResponseWriter, r *http.Request, t *Table) { + username, err := m.ValidateToken(r) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + t.Reveal(username) + jsonResponse(w, map[string]interface{}{"ok": true}) +} + +// ── Admin handlers ─────────────────────────────────────────────── + +// POST /poker/admin/tables +func (m *Manager) handleAdminCreateTable(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var cfg Config + if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { + http.Error(w, "invalid body", http.StatusBadRequest) + return + } + if err := validateConfig(cfg); err != nil { + jsonError(w, err.Error(), http.StatusBadRequest) + return + } + + // Persist to Redis + raw, _ := cfg.Marshal() + m.rdb.Set(m.ctx, "poker:table:"+cfg.ID+":config", raw, 0) + m.rdb.SAdd(m.ctx, "poker:tables", cfg.ID) + + // Create in memory + t := m.newTable(cfg) + m.mu.Lock() + m.tables[cfg.ID] = t + m.mu.Unlock() + + log.Printf("[poker] created table %s (%s)", cfg.ID, cfg.Name) + + // Notify domain script to spawn table entity in world + if m.BroadcastToTable != nil { + m.BroadcastToTable("domain", map[string]interface{}{ + "type": "table:spawn", + "table": cfg, + }) + } + + jsonResponse(w, map[string]interface{}{"ok": true, "id": cfg.ID}) +} + +// Routes under /poker/admin/tables/:id +func (m *Manager) handleAdminTableRoute(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/poker/admin/tables/") + if id == "" { + http.Error(w, "missing table id", http.StatusBadRequest) + return + } + + switch r.Method { + case http.MethodDelete: + m.mu.Lock() + t, ok := m.tables[id] + if ok { + delete(m.tables, id) + } + m.mu.Unlock() + + if !ok { + http.Error(w, "table not found", http.StatusNotFound) + return + } + + // Return all chips to players + t.mu.Lock() + for _, s := range t.Seats { + if s.Username != "" && s.Stack > 0 && m.AdjustBalance != nil { + m.AdjustBalance(s.Username, s.Stack) + } + } + t.mu.Unlock() + + m.rdb.Del(m.ctx, "poker:table:"+id+":config") + m.rdb.SRem(m.ctx, "poker:tables", id) + + // Tell domain script to despawn entities + if m.BroadcastToTable != nil { + m.BroadcastToTable("domain", map[string]interface{}{ + "type": "table:despawn", + "tableId": id, + }) + } + + jsonResponse(w, map[string]interface{}{"ok": true}) + + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +// ── WS message routing ─────────────────────────────────────────── + +// HandleWSMessage processes incoming WS messages routed to the poker manager. +// Expected message types: "poker:action", "poker:reveal", "poker:stand" +func (m *Manager) HandleWSMessage(username string, msg map[string]interface{}) { + msgType, _ := msg["type"].(string) + tableID, _ := msg["tableId"].(string) + + m.mu.RLock() + t, ok := m.tables[tableID] + m.mu.RUnlock() + + if !ok && msgType != "admin:position" { + log.Printf("[poker] WS message for unknown table %s", tableID) + return + } + + switch msgType { + case "poker:action": + action, _ := msg["action"].(string) + amount, _ := msg["amount"].(float64) + if err := t.Action(username, ActionType(action), int64(amount)); err != nil { + log.Printf("[poker] action error for %s: %v", username, err) + } + + case "poker:reveal": + t.Reveal(username) + + case "player:bust": + // Handled in table.go via broadcast, no extra routing needed + + case "admin:position": + // Admin tablet sends position so server knows where to spawn the table + // Store temporarily in Redis for the next table:spawn request + pos, _ := json.Marshal(msg["position"]) + rot, _ := json.Marshal(msg["rotation"]) + m.rdb.Set(m.ctx, "poker:admin:position:"+username, string(pos), 0) + m.rdb.Set(m.ctx, "poker:admin:rotation:"+username, string(rot), 0) + } +} + +// ── Helpers ────────────────────────────────────────────────────── + +func validateConfig(cfg Config) error { + if cfg.ID == "" { + return errors.New("id is required") + } + if cfg.Name == "" { + return errors.New("name is required") + } + if cfg.SeatCount < 2 || cfg.SeatCount > 9 { + return errors.New("seatCount must be between 2 and 9") + } + if cfg.SmallBlind <= 0 || cfg.BigBlind != cfg.SmallBlind*2 { + return errors.New("bigBlind must be exactly 2x smallBlind") + } + if cfg.MinBuyin < cfg.BigBlind*10 { + return fmt.Errorf("minBuyin must be at least 10x bigBlind (%d)", cfg.BigBlind*10) + } + if cfg.MaxBuyin < cfg.MinBuyin { + return errors.New("maxBuyin must be >= minBuyin") + } + if cfg.TurnTimer < 10 || cfg.TurnTimer > 120 { + return errors.New("turnTimer must be between 10 and 120 seconds") + } + return nil +} + +func jsonResponse(w http.ResponseWriter, v interface{}) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) +} + +func jsonError(w http.ResponseWriter, msg string, code int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} diff --git a/poker/table.go b/poker/table.go new file mode 100644 index 0000000..b1ae04a --- /dev/null +++ b/poker/table.go @@ -0,0 +1,702 @@ +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() + defer t.mu.Unlock() + + if seatIndex < 0 || seatIndex >= len(t.Seats) { + return errors.New("invalid seat index") + } + if t.Seats[seatIndex].Username != "" { + return errors.New("seat taken") + } + // Check player not already seated + for _, s := range t.Seats { + if s.Username == username { + return errors.New("already seated") + } + } + if buyin < t.Config.MinBuyin || buyin > t.Config.MaxBuyin { + 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.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() + defer t.mu.Unlock() + + seat := t.findSeat(username) + if seat == nil { + return 0, errors.New("not seated") + } + + // Allow standing mid-hand only if folded or hand is waiting + if t.Hand.Phase != PhaseWaiting && !seat.Folded { + return 0, errors.New("cannot stand mid-hand while active") + } + + stack := seat.Stack + *seat = Seat{} // clear seat + t.broadcastState() + return stack, nil +} + +// TopUp adds chips to a seated player's stack (between hands only). +func (t *Table) TopUp(username string, amount int64) error { + t.mu.Lock() + defer t.mu.Unlock() + + if t.Hand.Phase != PhaseWaiting { + return errors.New("can only top up between hands") + } + seat := t.findSeat(username) + if seat == nil { + return errors.New("not seated") + } + if seat.Stack+amount > t.Config.MaxBuyin { + return fmt.Errorf("would exceed max buy-in of %d", t.Config.MaxBuyin) + } + seat.Stack += amount + 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() + defer t.mu.Unlock() + + if t.Hand.Phase == PhaseWaiting || t.Hand.Phase == PhaseDealing || t.Hand.Phase == PhaseShowdown { + return errors.New("no action in current phase") + } + + seatIdx := t.findSeatIndex(username) + if seatIdx < 0 { + return errors.New("not seated") + } + if seatIdx != t.Hand.ActionOn { + 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 { + return errors.New("cannot check — must call or raise") + } + + case ActionCall: + toCall := currentBet - seat.Bet + if toCall <= 0 { + 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 { + 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: + 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() + t.mu.Lock() + 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() { + // Build hands for non-folded players + remaining := make(map[string][]string) + for username, cards := range t.hands { + if !t.folded[username] { + remaining[username] = cards + } + } + + winners := Winners(remaining, t.Hand.Community) + + // Broadcast all remaining hands + winners + revealedHands := make(map[string][]string) + for _, w := range winners { + revealedHands[w.Username] = remaining[w.Username] + } + // Also include any players who chose to reveal + 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 + } + + // Split pot among winners + 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 // first winner gets remainder chips + } + 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, + }) + } + + // Brief pause then reset + time.Sleep(5 * time.Second) + t.mu.Lock() + t.Hand.Phase = PhaseWaiting + // Remove players with no chips + for _, s := range t.Seats { + if s.Username != "" && s.Stack == 0 { + log.Printf("[poker] %s busted out of table %s", s.Username, t.Config.ID) + // Return to wallet handled by manager on bust event + if t.Broadcast != nil { + t.Broadcast(map[string]interface{}{ + "type": "player:bust", + "username": s.Username, + "tableId": t.Config.ID, + }) + } + *s = Seat{} + } + } + t.mu.Unlock() + t.broadcastState() + t.mu.Lock() + t.maybeStartHand() + t.mu.Unlock() +} + +func (t *Table) awardPot() { + // Find the only remaining active player + 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.Hand.Phase = PhaseWaiting + 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 +} + +// ── 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) +}