package main import ( "context" "encoding/json" "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 creds db sessions db mu sync.Mutex pp map[player.ID]*websocket.Conn } type db interface { player.RowQuerier player.Execer } 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") } } func (s *Server) Register(w http.ResponseWriter, r *http.Request) { ctx := r.Context() slog := slog.With( slog.String("remote", r.RemoteAddr), slog.String("forwarded-for", r.Header.Get("X-Forwarded-For")), slog.String("user-agent", r.UserAgent()), ) if err := r.ParseForm(); err != nil { slog.WarnContext(ctx, "error parsing form on register", "err", err.Error()) http.Error(w, "what", http.StatusBadRequest) return } user, pass := r.PostFormValue("user"), r.PostFormValue("pass") if user == "" || pass == "" { slog.WarnContext(ctx, "missing user or pass on register") http.Error(w, "missing credentials", http.StatusBadRequest) return } err := player.Register(ctx, s.creds, user, pass) if err != nil { slog.ErrorContext(ctx, "registration failed", "err", err.Error()) http.Error(w, "something went wrong, maybe someone already has that username, idk", http.StatusInternalServerError) return } p, err := player.Login(ctx, s.creds, user, pass) if err != nil { slog.ErrorContext(ctx, "login failed", "err", err.Error()) http.Error(w, "no", http.StatusUnauthorized) return } id, err := player.StartSession(ctx, s.sessions, p, time.Now()) if err != nil { slog.ErrorContext(ctx, "failed to create session", "player", p, "err", err.Error()) http.Error(w, "something went wrong", http.StatusInternalServerError) return } serve.SetSession(w, id) http.Redirect(w, r, "/", http.StatusSeeOther) slog.InfoContext(ctx, "registered", "user", user) } func (s *Server) Login(w http.ResponseWriter, r *http.Request) { ctx := r.Context() slog := slog.With( slog.String("remote", r.RemoteAddr), slog.String("forwarded-for", r.Header.Get("X-Forwarded-For")), slog.String("user-agent", r.UserAgent()), ) if err := r.ParseForm(); err != nil { slog.WarnContext(ctx, "error parsing form on login", "err", err.Error()) http.Error(w, "what", http.StatusBadRequest) return } user, pass := r.PostFormValue("user"), r.PostFormValue("pass") if user == "" || pass == "" { slog.WarnContext(ctx, "missing user or pass on login") http.Error(w, "missing credentials", http.StatusBadRequest) return } p, err := player.Login(ctx, s.creds, user, pass) if err != nil { slog.ErrorContext(ctx, "login failed", "err", err.Error()) http.Error(w, "no", http.StatusUnauthorized) return } id, err := player.StartSession(ctx, s.sessions, p, time.Now()) if err != nil { slog.ErrorContext(ctx, "failed to create session", "player", p, "err", err.Error()) http.Error(w, "something went wrong", http.StatusInternalServerError) return } serve.SetSession(w, id) http.Redirect(w, r, "/", http.StatusSeeOther) slog.InfoContext(ctx, "logged in", "player", p, "id", id) } func (s *Server) Logout(w http.ResponseWriter, r *http.Request) { ctx := r.Context() slog := slog.With( slog.String("remote", r.RemoteAddr), slog.String("forwarded-for", r.Header.Get("X-Forwarded-For")), slog.String("user-agent", r.UserAgent()), ) id := serve.ReqSession(ctx) if id == (player.Session{}) { slog.WarnContext(ctx, "no session on logout") http.Error(w, "what", http.StatusUnauthorized) panic("unreachable") } err := player.Logout(ctx, s.sessions, id) if err != nil { slog.ErrorContext(ctx, "logout failed", "err", err.Error()) http.Error(w, "something went wrong", http.StatusInternalServerError) return } serve.RemoveSession(w) slog.InfoContext(ctx, "logged out", "session", id) http.Redirect(w, r, "/", http.StatusSeeOther) } func (s *Server) Me(w http.ResponseWriter, r *http.Request) { id := serve.ReqPlayer(r.Context()) if id == (player.ID{}) { panic("missing player ID") } q := struct { ID player.ID `json:"id"` }{id} json.NewEncoder(w).Encode(q) } // 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.ReqPlayer(r.Context()) 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) go func() { for { _, _, err := p.conn.Read(context.Background()) if err != nil || ctx.Err() != nil { return } } }() 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(context.TODO(), 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")