Files
super-auto-pets-board-game/internal/server/ws.go
T
Greyson Parrelli 72e198a750 Fix shop action costs and pass semantics.
Only buying costs gold; selling and trading (Triple) are free. Pass is
now a final action, legal only at or under the pet limit: it forfeits
remaining gold and ends that player's shopping for the round, with the
shop closing once everyone has passed. That makes the separate cleanup
phase unreachable (you sell down in-shop before passing), so it is
removed. The client asks for confirmation before passing, and the bot
knows buys are the only coin sink, when passing is legal, and that it
must sell down before it can pass.
2026-07-23 16:11:07 -04:00

196 lines
4.5 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
Name string `json:"name"` // debugAdd
}
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 "buy":
err = g.Buy(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 "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
}
}
}
}