// 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 a pack (?pack=…, default Turtle), for the // debug panel. Unknown packs fall back to the default. func (s *Server) handleCatalog(w http.ResponseWriter, req *http.Request) { pack := req.URL.Query().Get("pack") if pack == "" { pack = game.DefaultPack } writeJSON(w, game.CatalogForPack(pack)) } // 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}) }