215 lines
5.2 KiB
Go
215 lines
5.2 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
|
|
Pack string `json:"pack"` // setPack
|
|
Difficulty string `json:"difficulty"` // addBot
|
|
Target string `json:"target"` // removePlayer (player ID)
|
|
}
|
|
|
|
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 "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
|
|
}
|
|
}
|
|
}
|
|
}
|