overte-api/poker/manager.go

463 lines
13 KiB
Go
Raw Normal View History

2026-03-17 20:17:36 -07:00
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})
}