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 Name string `json:"name"` // debugAdd Pack string `json:"pack"` // setPack Difficulty string `json:"difficulty"` // addBot Target string `json:"target"` // removePlayer (player ID) Card string `json:"card"` // revealChoose (Cockatoo): pet card id Value int `json:"value"` // battleChoose (Nurse Shark): Trumpets to spend } 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() // Safety net: if a scheduled bot move was ever lost (crash between // persist and timer), a player connecting re-arms it. s.scheduleBotsLocked(r) 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 "setPack": if err = requireHost(g, c.playerID); err == nil { err = g.SetPack(msg.Pack) } case "addBot": if err = requireHost(g, c.playerID); err == nil { err = addBot(g, msg.Difficulty) } case "removePlayer": if err = requireHost(g, c.playerID); err == nil { err = g.RemovePlayer(msg.Target) } case "start": if err = requireHost(g, c.playerID); err == nil { err = g.StartGame() } case "buy": err = g.Buy(c.playerID, msg.Row) case "buyAvocado": err = g.BuyAvocado(c.playerID, msg.Row) case "sell": err = g.Sell(c.playerID, msg.Cards) case "trade": err = g.TradeStart(c.playerID, msg.Cards) case "tradeChoose": err = g.TradeChoose(c.playerID, msg.Pick) case "revealChoose": err = g.RevealChoose(c.playerID, msg.Card) case "battleChoose": err = g.BattleChoose(c.playerID, msg.Value) case "pass": err = g.Pass(c.playerID) case "arrange": err = g.SubmitOrder(c.playerID, msg.Order) case "ready": err = g.AcknowledgeBattle(c.playerID) case "debugAdd": if !s.debug { err = game.ErrInvalidAction } else { err = g.DebugGrant(c.playerID, msg.Name) } default: err = game.ErrInvalidAction } if err != nil { c.sendError(err.Error()) return } s.commitLocked(r) } // 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) view.Debug = r.debug 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 } } } }