462 lines
13 KiB
Go
462 lines
13 KiB
Go
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})
|
|
}
|