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