207 lines
5.7 KiB
Go
207 lines
5.7 KiB
Go
// Package server exposes the game engine over HTTP + WebSockets. Each game
|
|
// lives in a "room": the authoritative Game state, a lock, and the set of
|
|
// connected clients. All mutations happen under the room lock and are
|
|
// persisted to the store before being broadcast.
|
|
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/greyson/super-auto-pets-board-game/internal/game"
|
|
"github.com/greyson/super-auto-pets-board-game/internal/store"
|
|
)
|
|
|
|
// Server routes HTTP/WS traffic to game rooms.
|
|
type Server struct {
|
|
store *store.Store
|
|
staticDir string
|
|
debug bool // DEBUG mode: unlocks the "buy any card" panel
|
|
|
|
mu sync.Mutex
|
|
rooms map[string]*room // by game ID
|
|
}
|
|
|
|
// New creates a server. staticDir is the built frontend to serve (may be
|
|
// empty or missing during backend-only development). debug unlocks testing
|
|
// aids like the debug card panel.
|
|
func New(st *store.Store, staticDir string, debug bool) *Server {
|
|
return &Server{
|
|
store: st,
|
|
staticDir: staticDir,
|
|
debug: debug,
|
|
rooms: make(map[string]*room),
|
|
}
|
|
}
|
|
|
|
// Handler builds the full route table.
|
|
func (s *Server) Handler() http.Handler {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("POST /api/games", s.handleCreate)
|
|
mux.HandleFunc("POST /api/join", s.handleJoin)
|
|
mux.HandleFunc("GET /api/ws", s.handleWS)
|
|
mux.HandleFunc("GET /api/catalog", s.handleCatalog)
|
|
mux.HandleFunc("/", s.handleStatic)
|
|
return mux
|
|
}
|
|
|
|
// handleCatalog returns every card in the game, for the debug panel.
|
|
func (s *Server) handleCatalog(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, game.Catalog())
|
|
}
|
|
|
|
// room is one live game plus its connections.
|
|
type room struct {
|
|
mu sync.Mutex
|
|
game *game.Game
|
|
conns map[*client]struct{}
|
|
debug bool // mirrors Server.debug, for broadcastLocked
|
|
// botArmed is set while a delayed bot move is scheduled, so only one
|
|
// timer exists per room at a time.
|
|
botArmed bool
|
|
}
|
|
|
|
// getRoom returns the room for a game ID, loading it from the store if it
|
|
// isn't in memory (e.g. after a server restart).
|
|
func (s *Server) getRoom(gameID string) (*room, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if r, ok := s.rooms[gameID]; ok {
|
|
return r, nil
|
|
}
|
|
g, err := s.store.Load(gameID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
r := &room{game: g, conns: make(map[*client]struct{}), debug: s.debug}
|
|
s.rooms[gameID] = r
|
|
// If the game was persisted mid-bot-turn (e.g. across a server restart),
|
|
// get the bot moving again.
|
|
r.mu.Lock()
|
|
s.scheduleBotsLocked(r)
|
|
r.mu.Unlock()
|
|
return r, nil
|
|
}
|
|
|
|
// getRoomByCode resolves a join code to a room.
|
|
func (s *Server) getRoomByCode(code string) (*room, error) {
|
|
code = strings.ToUpper(strings.TrimSpace(code))
|
|
s.mu.Lock()
|
|
for _, r := range s.rooms {
|
|
if r.game.Code == code {
|
|
s.mu.Unlock()
|
|
return r, nil
|
|
}
|
|
}
|
|
s.mu.Unlock()
|
|
g, err := s.store.LoadByCode(code)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.getRoom(g.ID)
|
|
}
|
|
|
|
// persist saves the room's game; callers must hold r.mu.
|
|
func (s *Server) persist(r *room) {
|
|
if err := s.store.Save(r.game); err != nil {
|
|
slog.Error("failed to persist game", "game", r.game.ID, "err", err)
|
|
}
|
|
}
|
|
|
|
type joinResponse struct {
|
|
GameID string `json:"gameId"`
|
|
Code string `json:"code"`
|
|
PlayerID string `json:"playerId"`
|
|
Token string `json:"token"`
|
|
}
|
|
|
|
func (s *Server) handleCreate(w http.ResponseWriter, req *http.Request) {
|
|
var body struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
|
httpError(w, http.StatusBadRequest, "invalid JSON body")
|
|
return
|
|
}
|
|
// The creator becomes the host (seat 0). They then set up the lobby —
|
|
// choosing a pack, adding a bot, or waiting for a friend — and start the
|
|
// game when ready.
|
|
g := game.New()
|
|
p, err := g.AddPlayer(strings.TrimSpace(body.Name))
|
|
if err != nil {
|
|
httpError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
r := &room{game: g, conns: make(map[*client]struct{}), debug: s.debug}
|
|
s.mu.Lock()
|
|
s.rooms[g.ID] = r
|
|
s.mu.Unlock()
|
|
|
|
r.mu.Lock()
|
|
s.commitLocked(r)
|
|
r.mu.Unlock()
|
|
writeJSON(w, joinResponse{GameID: g.ID, Code: g.Code, PlayerID: p.ID, Token: p.Token})
|
|
}
|
|
|
|
func (s *Server) handleJoin(w http.ResponseWriter, req *http.Request) {
|
|
var body struct {
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
|
httpError(w, http.StatusBadRequest, "invalid JSON body")
|
|
return
|
|
}
|
|
r, err := s.getRoomByCode(body.Code)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
httpError(w, http.StatusNotFound, "no game with that code")
|
|
return
|
|
}
|
|
if err != nil {
|
|
httpError(w, http.StatusInternalServerError, "failed to load game")
|
|
return
|
|
}
|
|
r.mu.Lock()
|
|
p, err := r.game.AddPlayer(strings.TrimSpace(body.Name))
|
|
if err != nil {
|
|
r.mu.Unlock()
|
|
httpError(w, http.StatusConflict, err.Error())
|
|
return
|
|
}
|
|
resp := joinResponse{GameID: r.game.ID, Code: r.game.Code, PlayerID: p.ID, Token: p.Token}
|
|
s.commitLocked(r)
|
|
r.mu.Unlock()
|
|
writeJSON(w, resp)
|
|
}
|
|
|
|
// handleStatic serves the built frontend with an SPA fallback to index.html.
|
|
func (s *Server) handleStatic(w http.ResponseWriter, req *http.Request) {
|
|
if s.staticDir == "" {
|
|
httpError(w, http.StatusNotFound, "frontend not built (run: mise run build-web)")
|
|
return
|
|
}
|
|
path := filepath.Join(s.staticDir, filepath.Clean("/"+req.URL.Path))
|
|
if info, err := os.Stat(path); err == nil && !info.IsDir() {
|
|
http.ServeFile(w, req, path)
|
|
return
|
|
}
|
|
http.ServeFile(w, req, filepath.Join(s.staticDir, "index.html"))
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func httpError(w http.ResponseWriter, status int, msg string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
|
}
|