Initial commit.
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
// 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
|
||||
|
||||
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).
|
||||
func New(st *store.Store, staticDir string) *Server {
|
||||
return &Server{
|
||||
store: st,
|
||||
staticDir: staticDir,
|
||||
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("/", s.handleStatic)
|
||||
return mux
|
||||
}
|
||||
|
||||
// room is one live game plus its connections.
|
||||
type room struct {
|
||||
mu sync.Mutex
|
||||
game *game.Game
|
||||
conns map[*client]struct{}
|
||||
}
|
||||
|
||||
// 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{})}
|
||||
s.rooms[gameID] = r
|
||||
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
|
||||
}
|
||||
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{})}
|
||||
s.mu.Lock()
|
||||
s.rooms[g.ID] = r
|
||||
s.mu.Unlock()
|
||||
|
||||
r.mu.Lock()
|
||||
s.persist(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
|
||||
}
|
||||
s.persist(r)
|
||||
resp := joinResponse{GameID: r.game.ID, Code: r.game.Code, PlayerID: p.ID, Token: p.Token}
|
||||
r.broadcastLocked()
|
||||
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})
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"github.com/greyson/super-auto-pets-board-game/internal/game"
|
||||
)
|
||||
|
||||
// client is one WebSocket connection bound to a player in a room.
|
||||
type client struct {
|
||||
ws *websocket.Conn
|
||||
playerID string
|
||||
send chan []byte
|
||||
}
|
||||
|
||||
// clientMessage is anything a player can ask the server to do. Type decides
|
||||
// which other fields matter.
|
||||
type clientMessage struct {
|
||||
Type string `json:"type"`
|
||||
Row int `json:"row"` // buy
|
||||
Cards []string `json:"cards"` // discard, trade
|
||||
Pick int `json:"pick"` // tradeChoose
|
||||
Order []string `json:"order"` // arrange
|
||||
}
|
||||
|
||||
type serverMessage struct {
|
||||
Type string `json:"type"`
|
||||
State *game.View `json:"state,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// handleWS upgrades the connection and pumps messages until the client
|
||||
// leaves. Query params: game, player, token.
|
||||
func (s *Server) handleWS(w http.ResponseWriter, req *http.Request) {
|
||||
q := req.URL.Query()
|
||||
gameID, playerID, token := q.Get("game"), q.Get("player"), q.Get("token")
|
||||
|
||||
r, err := s.getRoom(gameID)
|
||||
if err != nil {
|
||||
httpError(w, http.StatusNotFound, "game not found")
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
p := r.game.PlayerByID(playerID)
|
||||
r.mu.Unlock()
|
||||
if p == nil || p.Token != token {
|
||||
httpError(w, http.StatusForbidden, "bad player credentials")
|
||||
return
|
||||
}
|
||||
|
||||
ws, err := websocket.Accept(w, req, &websocket.AcceptOptions{
|
||||
// Same-origin in production; the Vite dev server proxies /api, so
|
||||
// cross-origin checks buy nothing here yet.
|
||||
InsecureSkipVerify: true,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c := &client{ws: ws, playerID: playerID, send: make(chan []byte, 16)}
|
||||
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
go c.writeLoop(ctx)
|
||||
|
||||
r.mu.Lock()
|
||||
r.conns[c] = struct{}{}
|
||||
p.Connected = true
|
||||
r.broadcastLocked()
|
||||
r.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
r.mu.Lock()
|
||||
delete(r.conns, c)
|
||||
// Only mark disconnected if no other tab/connection remains.
|
||||
stillHere := false
|
||||
for other := range r.conns {
|
||||
if other.playerID == playerID {
|
||||
stillHere = true
|
||||
}
|
||||
}
|
||||
if !stillHere {
|
||||
p.Connected = false
|
||||
}
|
||||
r.broadcastLocked()
|
||||
r.mu.Unlock()
|
||||
ws.Close(websocket.StatusNormalClosure, "")
|
||||
}()
|
||||
|
||||
for {
|
||||
_, data, err := ws.Read(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var msg clientMessage
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
c.sendError("invalid message")
|
||||
continue
|
||||
}
|
||||
s.apply(r, c, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// apply runs one player action against the game under the room lock, then
|
||||
// persists and broadcasts on success.
|
||||
func (s *Server) apply(r *room, c *client, msg clientMessage) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
g := r.game
|
||||
var err error
|
||||
switch msg.Type {
|
||||
case "buy":
|
||||
err = g.Buy(c.playerID, msg.Row)
|
||||
case "discard":
|
||||
if g.Phase == game.PhaseCleanup {
|
||||
err = g.CleanupDiscard(c.playerID, msg.Cards)
|
||||
} else {
|
||||
err = g.Discard(c.playerID, msg.Cards)
|
||||
}
|
||||
case "trade":
|
||||
err = g.TradeStart(c.playerID, msg.Cards)
|
||||
case "tradeChoose":
|
||||
err = g.TradeChoose(c.playerID, msg.Pick)
|
||||
case "pass":
|
||||
err = g.Pass(c.playerID)
|
||||
case "arrange":
|
||||
err = g.SubmitOrder(c.playerID, msg.Order)
|
||||
case "ready":
|
||||
err = g.AcknowledgeBattle(c.playerID)
|
||||
default:
|
||||
err = game.ErrInvalidAction
|
||||
}
|
||||
if err != nil {
|
||||
c.sendError(err.Error())
|
||||
return
|
||||
}
|
||||
s.persist(r)
|
||||
r.broadcastLocked()
|
||||
}
|
||||
|
||||
// broadcastLocked sends each connected client its own view of the game.
|
||||
// Callers must hold r.mu.
|
||||
func (r *room) broadcastLocked() {
|
||||
for c := range r.conns {
|
||||
view := r.game.ViewFor(c.playerID)
|
||||
data, err := json.Marshal(serverMessage{Type: "state", State: &view})
|
||||
if err != nil {
|
||||
slog.Error("failed to marshal view", "err", err)
|
||||
continue
|
||||
}
|
||||
c.trySend(data)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *client) sendError(msg string) {
|
||||
data, _ := json.Marshal(serverMessage{Type: "error", Error: msg})
|
||||
c.trySend(data)
|
||||
}
|
||||
|
||||
// trySend queues a message, dropping it if the client's buffer is full (a
|
||||
// stalled client will resync from the next state broadcast anyway).
|
||||
func (c *client) trySend(data []byte) {
|
||||
select {
|
||||
case c.send <- data:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (c *client) writeLoop(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case data := <-c.send:
|
||||
writeCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
err := c.ws.Write(writeCtx, websocket.MessageText, data)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user