shotgun/server.go

109 lines
2.6 KiB
Go

package main
import (
"context"
"errors"
"log/slog"
"net/http"
"sync"
"time"
"nhooyr.io/websocket"
"nhooyr.io/websocket/wsjson"
"git.sunturtle.xyz/studio/shotgun/game"
"git.sunturtle.xyz/studio/shotgun/lobby"
"git.sunturtle.xyz/studio/shotgun/player"
"git.sunturtle.xyz/studio/shotgun/serve"
)
type Server struct {
l *lobby.Lobby
mu sync.Mutex
pp map[player.ID]*websocket.Conn
}
type person struct {
conn *websocket.Conn
id player.ID
}
func (s *Server) person(p player.ID) person {
s.mu.Lock()
defer s.mu.Unlock()
return person{conn: s.pp[p], id: p}
}
func (s *Server) accept(w http.ResponseWriter, r *http.Request, p player.ID) (person, error) {
conn, err := websocket.Accept(w, r, nil)
if err != nil {
return person{}, err
}
slog.Debug("upgraded", "player", p)
s.mu.Lock()
defer s.mu.Unlock()
s.pp[p] = conn
return person{conn: conn, id: p}, nil
}
func (s *Server) left(p player.ID) {
s.mu.Lock()
// NOTE(zeph): neither map index nor map delete can panic, so this critical
// section does not need a defer
c := s.pp[p]
delete(s.pp, p)
s.mu.Unlock()
if c != nil {
// We don't care about the error here since the connection is leaving.
// It's probably already closed anyway.
slog.Debug("leaving", "player", p)
c.Close(websocket.StatusNormalClosure, "bye")
}
}
// Queue connects players to games. The connection immediately upgrades.
// This handler MUST be wrapped in [serve.WithPlayerID].
func (s *Server) Queue(w http.ResponseWriter, r *http.Request) {
p := serve.PlayerID(r.Context())
if p == (player.ID{}) {
panic("missing player ID")
}
person, err := s.accept(w, r, p)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.joinAndServe(person)
}
func (s *Server) joinAndServe(p person) {
slog.Debug("joining", "player", p.id)
ctx, stop := context.WithTimeoutCause(context.Background(), 10*time.Minute, errQueueEmpty)
id, chall, deal := s.l.Queue(ctx, p.id)
stop()
if id == (lobby.GameID{}) {
// Context canceled.
p.conn.Close(websocket.StatusTryAgainLater, "sorry, queue is empty...")
return
}
if deal {
g := game.New(p.id, chall)
other := s.person(chall)
// TODO(zeph): save the game state s.t. we can provide a join channel
go gameActor(ctx, g, p, other, nil)
}
// Reply with the game ID so they can share.
r := serve.GameStart{
ID: id,
Dealer: deal,
}
if err := wsjson.Write(context.TODO(), p.conn, r); err != nil {
slog.WarnContext(ctx, "got a game but player dropped", "game", id, "player", p.id)
s.left(p.id)
return
}
}
var errQueueEmpty = errors.New("sorry, queue is empty")