diff --git a/game.go b/game.go index 9ab0f6a..2bccc8a 100644 --- a/game.go +++ b/game.go @@ -21,7 +21,7 @@ type action struct { // gameActor is an actor that updates a game's state and relays changes to all // observers. -func gameActor(ctx context.Context, g *game.Game, dealer, chall person, join chan person) { +func gameActor(ctx context.Context, g *game.Game, dealer, chall person, join <-chan person) { // Games should generally be on the order of minutes. A four hour game is // definitely expired. ctx, stop := context.WithTimeoutCause(ctx, 4*time.Hour, errGameExpired) diff --git a/server.go b/server.go index 6ce62a4..277da96 100644 --- a/server.go +++ b/server.go @@ -8,24 +8,19 @@ import ( "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 - rooms map[lobby.GameID]*room -} + l *lobby.Lobby -type room struct { mu sync.Mutex - pp []person - // NOTE(zeph): since only the players can ever see revealed shells, we - // could factor out the players into separate fields to save work on - // generating dtos for observers. hold that idea until it's needed. + pp map[player.ID]*websocket.Conn } type person struct { @@ -33,27 +28,35 @@ type person struct { id player.ID } -func (s *Server) room(id serve.GameID) *room { +func (s *Server) person(p player.ID) person { s.mu.Lock() defer s.mu.Unlock() - return s.rooms[id] + return person{conn: s.pp[p], id: p} } -func (s *Server) join(id serve.GameID, p person) { - s.mu.Lock() - defer s.mu.Unlock() - r := s.rooms[id] - if r == nil { - r = &room{} - s.rooms[id] = r +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 } - r.pp = append(r.pp, p) -} - -func (s *Server) close(id serve.GameID) { s.mu.Lock() defer s.mu.Unlock() - delete(s.rooms, id) + 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. + c.Close(websocket.StatusNormalClosure, "bye") + } } // Queue connects players to games. The connection immediately upgrades. @@ -63,26 +66,40 @@ func (s *Server) Queue(w http.ResponseWriter, r *http.Request) { if p == (player.ID{}) { panic("missing player ID") } - conn, err := websocket.Accept(w, r, nil) + person, err := s.accept(w, r, p) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - person := person{conn: conn, id: p} - go s.joinAndServe(person) + s.joinAndServe(person) } func (s *Server) joinAndServe(p person) { ctx, stop := context.WithTimeoutCause(context.Background(), 10*time.Minute, errQueueEmpty) - game := s.l.Queue(ctx, p.id) + id, chall, deal := s.l.Queue(ctx, p.id) stop() - if game == (lobby.GameID{}) { + if id == (lobby.GameID{}) { // Context canceled. - p.conn.Close(websocket.StatusNormalClosure, "sorry, queue is empty...") + 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 := struct { + Game lobby.GameID `json:"game"` + }{ + Game: id, + } + if err := wsjson.Write(context.TODO(), p.conn, r); err != nil { + // TODO(zeph): log + s.left(p.id) return } - s.join(game, p) - // TODO(zeph): need to broadcast to observers, need to listen for updates in a single goroutine, ... } var errQueueEmpty = errors.New("sorry, queue is empty")