Files
super-auto-pets-board-game/internal/server/ws.go
T
2026-07-22 23:07:29 -04:00

190 lines
4.3 KiB
Go

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
}
}
}
}