From a68e4e0fb984f118a1c16768150b1bf736687d76 Mon Sep 17 00:00:00 2001 From: nak Date: Tue, 17 Mar 2026 18:29:04 -0700 Subject: [PATCH] Add poker code --- hub.go | 68 +++++++++++++++++++++++++++++++++++++++++++++------------ main.go | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 121 insertions(+), 15 deletions(-) diff --git a/hub.go b/hub.go index 1a41bce..02a9004 100644 --- a/hub.go +++ b/hub.go @@ -4,6 +4,7 @@ import ( "encoding/json" "log" "net/http" + "strings" "sync" "time" @@ -28,7 +29,6 @@ import ( type Message struct { Type string `json:"type"` Username string `json:"username,omitempty"` - Payload string `json:"payload,omitempty"` } // --- Hub --- @@ -62,9 +62,27 @@ func (h *Hub) count() int { return len(h.clients) } -// Broadcast sends a message to all connected clients except the sender. -// Pass nil sender to broadcast to everyone. -func (h *Hub) Broadcast(msg Message, sender *Client) { +// SendToUser sends a message to the first client identified as username. +func (h *Hub) SendToUser(username string, msg interface{}) { + data, err := json.Marshal(msg) + if err != nil { + return + } + h.mu.RLock() + defer h.mu.RUnlock() + for c := range h.clients { + if c.username == username { + select { + case c.send <- data: + default: + log.Printf("[WS] dropped private message to %s", username) + } + return + } + } +} + +func (h *Hub) Broadcast(msg interface{}, sender *Client) { data, err := json.Marshal(msg) if err != nil { return @@ -87,9 +105,10 @@ func (h *Hub) Broadcast(msg Message, sender *Client) { // --- Client --- type Client struct { - hub *Hub - conn *websocket.Conn - send chan []byte + hub *Hub + conn *websocket.Conn + send chan []byte + username string // set when client identifies itself via a session token } const ( @@ -124,10 +143,32 @@ func (c *Client) readPump() { log.Printf("[WS] bad message: %s", data) continue } - // Relay to all other clients — domain script and entity scripts - // all share the same hub, so a recall from the tablet entity script - // arrives at the domain script automatically. - if msg.Type != "pong" { + + switch { + case msg.Type == "pong": + // keepalive, no relay + + case msg.Type == "identify" && msg.Username != "": + // Clients send {"type":"identify","username":"nak"} after connecting + // so the hub knows which WS connection belongs to which player. + // The domain script doesn't need to identify. + c.username = msg.Username + log.Printf("[WS] client identified as %s", c.username) + + case strings.HasPrefix(msg.Type, "poker:") || msg.Type == "admin:position": + // Route poker messages to the manager with the identified username + username := c.username + if username == "" { + username = msg.Username + } + if pokerManager != nil && username != "" { + var raw map[string]interface{} + json.Unmarshal(data, &raw) + pokerManager.HandleWSMessage(username, raw) + } + + default: + // Broadcast everything else (recall, presence events, etc.) c.hub.Broadcast(msg, c) } } @@ -171,7 +212,6 @@ var upgrader = websocket.Upgrader{ var hub = newHub() func handleWS(w http.ResponseWriter, r *http.Request) { - log.Printf("[WS] new connection from %s (total after: %d)", r.RemoteAddr, hub.count()+1) conn, err := upgrader.Upgrade(w, r, nil) if err != nil { log.Println("[WS] upgrade error:", err) @@ -215,7 +255,7 @@ func presenceLoop() { for u := range curr { if !prev[u] { log.Printf("[presence] connected: %s", u) - hub.Broadcast(Message{Type: "user:connected", Username: u}, nil) + hub.Broadcast(map[string]string{"type": "user:connected", "username": u}, nil) } } @@ -223,7 +263,7 @@ func presenceLoop() { for u := range prev { if !curr[u] { log.Printf("[presence] disconnected: %s", u) - hub.Broadcast(Message{Type: "user:disconnected", Username: u}, nil) + hub.Broadcast(map[string]string{"type": "user:disconnected", "username": u}, nil) } } diff --git a/main.go b/main.go index 13cf64b..3d62b98 100644 --- a/main.go +++ b/main.go @@ -17,6 +17,7 @@ import ( "time" "github.com/redis/go-redis/v9" + "wizards.cyou/overte-api/poker" ) var rdb *redis.Client @@ -24,6 +25,8 @@ var ctx = context.Background() var domainAdminUser string var domainAdminPass string var hmacSecret []byte +var adminToken string +var pokerManager *poker.Manager // Shape definitions: name -> face count -> price (100 * faces) var shapes = map[string]int{ @@ -81,6 +84,28 @@ func main() { } log.Println("Connected to Redis") + // Admin token for poker admin UI + adminToken = os.Getenv("ADMIN_TOKEN") + if adminToken == "" { + log.Fatal("ADMIN_TOKEN environment variable must be set") + } + + // Poker manager + pokerManager = poker.NewManager(rdb, ctx) + pokerManager.ValidateToken = validateToken + pokerManager.AdjustBalance = func(username string, delta int64) (int64, error) { + return adjustBalance(username, delta) + } + pokerManager.BroadcastToTable = func(tableID string, msg interface{}) { + hub.Broadcast(msg, nil) + } + pokerManager.SendToUser = func(username string, msg interface{}) { + hub.SendToUser(username, msg) + } + if err := pokerManager.LoadTables(); err != nil { + log.Printf("Warning: could not load poker tables: %v", err) + } + go presenceLoop() http.HandleFunc("/ws", handleWS) @@ -90,6 +115,7 @@ func main() { http.HandleFunc("/api/purchase", handlePurchase) http.HandleFunc("/api/connected", handleConnected) http.HandleFunc("/api/recall", handleRecall) + pokerManager.RegisterRoutes(http.DefaultServeMux, adminAuthMiddleware) log.Println("Listening on :8080") log.Fatal(http.ListenAndServe(":8080", nil)) @@ -418,8 +444,48 @@ func handleRecall(w http.ResponseWriter, r *http.Request) { http.Error(w, "unauthorized: "+err.Error(), http.StatusUnauthorized) return } - hub.Broadcast(Message{Type: "recall", Username: username}, nil) + hub.Broadcast(map[string]string{"type": "recall", "username": username}, nil) log.Printf("Recall broadcast for %s", username) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]bool{"ok": true}) } + +// --- Poker helpers --- + +// adjustBalance atomically adjusts a user's VERT balance. +// delta can be negative (deduct) or positive (add). +func adjustBalance(username string, delta int64) (int64, error) { + var newBalance float64 + err := rdb.Watch(ctx, func(tx *redis.Tx) error { + balance, err := tx.HGet(ctx, "balances", username).Float64() + if err == redis.Nil { + balance = 0 + } else if err != nil { + return err + } + if delta < 0 && int64(balance) < -delta { + return errors.New("insufficient balance") + } + _, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error { + pipe.HIncrByFloat(ctx, "balances", username, float64(delta)) + return nil + }) + if err == nil { + newBalance = balance + float64(delta) + } + return err + }, "balances") + return int64(newBalance), err +} + +// adminAuthMiddleware checks the Authorization header against ADMIN_TOKEN. +func adminAuthMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != adminToken { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + next(w, r) + } +}