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.
196 lines
4.5 KiB
Go
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
|
|
}
|
|
}
|
|
}
|
|
}
|