reorg poker code

This commit is contained in:
nak 2026-03-17 20:17:36 -07:00
parent a68e4e0fb9
commit 2836676ff5
6 changed files with 1283 additions and 0 deletions

1
go.mod
View file

@ -4,6 +4,7 @@ go 1.22.2
require ( require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect 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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/gorilla/websocket v1.5.3 // indirect
github.com/redis/go-redis/v9 v9.18.0 // indirect github.com/redis/go-redis/v9 v9.18.0 // indirect

10
go.sum
View file

@ -1,10 +1,20 @@
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 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/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 h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 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 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= 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 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 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=

37
poker/deck.go Normal file
View file

@ -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:]
}

71
poker/eval.go Normal file
View file

@ -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
}

462
poker/manager.go Normal file
View file

@ -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})
}

702
poker/table.go Normal file
View file

@ -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)
}