bla
This commit is contained in:
parent
0736d22ebe
commit
b3f02ea15e
10 changed files with 3567 additions and 0 deletions
348
internal/api/hub.go
Normal file
348
internal/api/hub.go
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"prsi/internal/engine"
|
||||
"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 supports multi-card play; CardIdx is kept for single-card 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})
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// 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"`
|
||||
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"`
|
||||
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,
|
||||
ActiveSuit: g.ActiveSuit,
|
||||
PenaltyCards: g.PenaltyCards,
|
||||
SkipNext: g.SkipNext,
|
||||
DiscardPile: discardTop,
|
||||
Winner: g.Winner,
|
||||
PendingChoice: g.PendingChoice,
|
||||
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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue