reorg poker code
This commit is contained in:
parent
a68e4e0fb9
commit
2836676ff5
6 changed files with 1283 additions and 0 deletions
1
go.mod
1
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
|
||||
|
|
|
|||
10
go.sum
10
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=
|
||||
|
|
|
|||
37
poker/deck.go
Normal file
37
poker/deck.go
Normal 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
71
poker/eval.go
Normal 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
462
poker/manager.go
Normal 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
702
poker/table.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue