package api import ( "crypto/rand" "encoding/json" "fmt" "log" "net/http" "prsi/internal/engine" "sort" "sync" "github.com/gorilla/websocket" ) var upgrader = websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, } // Hub manages all active game rooms. type Hub struct { mu sync.RWMutex games map[string]*engine.Game // gameID → game conns map[string][]*playerConn // gameID → connected sockets scripts map[string]string // gameID → accumulated JS rules for this room rules map[string][]engine.ActiveRule // gameID → human-readable rule history } // playerConn pairs a WebSocket connection with the player ID that opened it. type playerConn struct { conn *websocket.Conn playerID string } func NewHub() *Hub { return &Hub{ games: make(map[string]*engine.Game), conns: make(map[string][]*playerConn), scripts: make(map[string]string), rules: make(map[string][]engine.ActiveRule), } } // ────────────────────────────────────────────────────────────────────────────── // WebSocket handler // ────────────────────────────────────────────────────────────────────────────── type wsMessage struct { Type string `json:"type"` PlayerID string `json:"player_id,omitempty"` PlayerName string `json:"player_name,omitempty"` GameID string `json:"game_id,omitempty"` // CardIndices carries selected card index; CardIdx is kept for backwards compat. CardIndices []int `json:"card_indices,omitempty"` CardIdx *int `json:"card_idx,omitempty"` ChosenSuit engine.Suit `json:"chosen_suit,omitempty"` Choice string `json:"choice,omitempty"` Script string `json:"script,omitempty"` Description string `json:"description,omitempty"` } func (h *Hub) HandleWS(w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { log.Println("ws upgrade:", err) return } defer conn.Close() for { _, raw, err := conn.ReadMessage() if err != nil { h.removeConn(conn) break } var m wsMessage if err := json.Unmarshal(raw, &m); err != nil { continue } h.handle(conn, m) } } // ────────────────────────────────────────────────────────────────────────────── // Message dispatch // ────────────────────────────────────────────────────────────────────────────── func (h *Hub) handle(conn *websocket.Conn, m wsMessage) { h.mu.Lock() defer h.mu.Unlock() switch m.Type { case "create": id := generateID() game, err := engine.NewGame(id, engine.DefaultScript) if err != nil { writeError(conn, err.Error()) return } h.games[id] = game h.scripts[id] = engine.DefaultScript conn.WriteJSON(map[string]string{"type": "created", "game_id": id}) case "join": game, ok := h.games[m.GameID] if !ok { writeError(conn, "game not found") return } if err := game.AddPlayer(m.PlayerID, m.PlayerName); err != nil { writeError(conn, err.Error()) return } // Register this connection. h.conns[m.GameID] = append(h.conns[m.GameID], &playerConn{conn: conn, playerID: m.PlayerID}) h.broadcast(m.GameID) case "start": game, ok := h.games[m.GameID] if !ok { writeError(conn, "game not found") return } // If the previous game ended, recreate with accumulated script but keep players. if game.State == engine.Ended { players := game.Players script := h.scripts[m.GameID] activeRules := h.rules[m.GameID] newGame, err := engine.NewGame(m.GameID, script) if err != nil { writeError(conn, "invalid rules script: "+err.Error()) return } newGame.ActiveRules = activeRules h.games[m.GameID] = newGame game = newGame for _, p := range players { game.AddPlayer(p.ID, p.Name) //nolint } } if err := game.Start(); err != nil { writeError(conn, err.Error()) return } h.broadcast(m.GameID) case "play": game, ok := h.games[m.GameID] if !ok { return } indices := m.CardIndices if len(indices) == 0 && m.CardIdx != nil { indices = []int{*m.CardIdx} } if err := game.PlayCards(m.PlayerID, indices, m.ChosenSuit); err != nil { writeError(conn, err.Error()) return } h.broadcast(m.GameID) case "draw": game, ok := h.games[m.GameID] if !ok { return } if err := game.Draw(m.PlayerID); err != nil { writeError(conn, err.Error()) return } h.broadcast(m.GameID) case "choice": game, ok := h.games[m.GameID] if !ok { return } if err := game.ApplyChoice(m.PlayerID, m.Choice); err != nil { writeError(conn, err.Error()) return } h.broadcast(m.GameID) // set_rule is sent by the winner after the LLM has produced and the client has // confirmed a new script. The hub validates it, stores it, and broadcasts. case "set_rule": game, ok := h.games[m.GameID] if !ok { writeError(conn, "game not found") return } // Only the winner may set a rule. if game.Winner == nil || game.Winner.ID != m.PlayerID { writeError(conn, "only the winner can propose a new rule") return } // Validate the script compiles. if _, err := engine.NewScriptRuntime(m.Script); err != nil { writeError(conn, "invalid script: "+err.Error()) return } h.scripts[m.GameID] = m.Script rule := engine.ActiveRule{ Description: m.Description, ProposedBy: game.Winner.Name, } h.rules[m.GameID] = append(h.rules[m.GameID], rule) // Broadcast so clients can display the new rule list. h.broadcastRules(m.GameID) } } // ────────────────────────────────────────────────────────────────────────────── // HTTP handler — exposes script source for the editor panel // ────────────────────────────────────────────────────────────────────────────── func (h *Hub) HandleGetScript(w http.ResponseWriter, r *http.Request) { gameID := r.URL.Query().Get("game_id") h.mu.RLock() script, ok := h.scripts[gameID] h.mu.RUnlock() if !ok { http.Error(w, "game not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"script": script}) } type lobbyInfo struct { GameID string `json:"game_id"` PlayerCount int `json:"player_count"` Players []string `json:"players"` State engine.GameState `json:"state"` HasRules bool `json:"has_rules"` } // HandleListLobbies returns waiting rooms that can be joined. func (h *Hub) HandleListLobbies(w http.ResponseWriter, r *http.Request) { h.mu.RLock() defer h.mu.RUnlock() rooms := make([]lobbyInfo, 0, len(h.games)) for id, g := range h.games { g.Mu.RLock() if g.State == engine.Waiting && len(g.Players) < 4 { names := make([]string, 0, len(g.Players)) for _, p := range g.Players { names = append(names, p.Name) } rooms = append(rooms, lobbyInfo{ GameID: id, PlayerCount: len(g.Players), Players: names, State: g.State, HasRules: len(g.ActiveRules) > 0, }) } g.Mu.RUnlock() } sort.Slice(rooms, func(i, j int) bool { if rooms[i].PlayerCount != rooms[j].PlayerCount { return rooms[i].PlayerCount > rooms[j].PlayerCount } return rooms[i].GameID < rooms[j].GameID }) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{"lobbies": rooms}) } // ────────────────────────────────────────────────────────────────────────────── // Broadcast helpers // ────────────────────────────────────────────────────────────────────────────── func (h *Hub) broadcast(gameID string) { game := h.games[gameID] conns := h.conns[gameID] // Build per-player views so each player only sees their own hand. for _, pc := range conns { view := buildView(game, pc.playerID) if err := pc.conn.WriteJSON(map[string]interface{}{ "type": "update", "game": view, }); err != nil { log.Println("ws write:", err) } } } func (h *Hub) broadcastRules(gameID string) { rules := h.rules[gameID] script := h.scripts[gameID] for _, pc := range h.conns[gameID] { pc.conn.WriteJSON(map[string]interface{}{ "type": "rules_updated", "rules": rules, "script": script, }) } } // gameView is what we send to each client — identical to Game but Hand only // contains the *receiving* player's cards; others get a count. type gameView struct { ID string `json:"id"` State engine.GameState `json:"state"` CurrentPlayer int `json:"current_player_index"` TurnNumber int `json:"turn_number"` RoundNumber int `json:"round_number"` LastActionType string `json:"last_action_type,omitempty"` LastActorIndex int `json:"last_actor_index,omitempty"` ActiveSuit engine.Suit `json:"active_suit"` PenaltyCards int `json:"penalty_cards"` SkipNext bool `json:"skip_next"` DiscardPile []engine.Card `json:"discard_pile"` Winner *engine.Player `json:"winner"` PendingChoice *engine.PendingChoice `json:"pending_choice,omitempty"` ChoiceQueue []engine.PendingChoice `json:"choice_queue,omitempty"` ActiveRules []engine.ActiveRule `json:"active_rules"` Players []playerView `json:"players"` } type playerView struct { ID string `json:"id"` Name string `json:"name"` HandCount int `json:"hand_count"` Hand []engine.Card `json:"hand"` // non-nil only for the receiving player } func buildView(g *engine.Game, receiverID string) gameView { g.Mu.RLock() defer g.Mu.RUnlock() players := make([]playerView, len(g.Players)) for i, p := range g.Players { pv := playerView{ ID: p.ID, Name: p.Name, HandCount: len(p.Hand), } if p.ID == receiverID { pv.Hand = p.Hand } players[i] = pv } // Only send the top 1 card of the discard pile to save bandwidth. var discardTop []engine.Card if len(g.DiscardPile) > 0 { discardTop = g.DiscardPile[len(g.DiscardPile)-1:] } return gameView{ ID: g.ID, State: g.State, CurrentPlayer: g.CurrentPlayer, TurnNumber: g.TurnNumber, RoundNumber: g.RoundNumber, LastActionType: g.LastActionType, LastActorIndex: g.LastActorIndex, ActiveSuit: g.ActiveSuit, PenaltyCards: g.PenaltyCards, SkipNext: g.SkipNext, DiscardPile: discardTop, Winner: g.Winner, PendingChoice: g.PendingChoice, ChoiceQueue: g.ChoiceQueue, ActiveRules: g.ActiveRules, Players: players, } } // ────────────────────────────────────────────────────────────────────────────── // Utility // ────────────────────────────────────────────────────────────────────────────── func (h *Hub) removeConn(conn *websocket.Conn) { h.mu.Lock() defer h.mu.Unlock() for gameID, list := range h.conns { for i, pc := range list { if pc.conn == conn { h.conns[gameID] = append(list[:i], list[i+1:]...) return } } } } func writeError(conn *websocket.Conn, msg string) { conn.WriteJSON(map[string]string{"type": "error", "message": msg}) } func generateID() string { b := make([]byte, 4) rand.Read(b) return fmt.Sprintf("%x", b) }