402 lines
12 KiB
Go
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)
|
|
}
|