prsi/internal/api/hub.go
Pavel Flegr ff5d6ab91d asd
2026-05-07 19:10:23 +02:00

402 lines
12 KiB
Go

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