package main import ( "context" "encoding/json" "errors" "log/slog" "net/http" "time" "github.com/google/uuid" "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[uuid.UUID, matchingPerson] creds db sessions db } type db interface { player.RowQuerier player.Execer } type person struct { conn *websocket.Conn id player.ID } type matchingPerson struct { sync chan struct{} person } 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) return person{conn: conn, id: p}, nil } 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(), 3*time.Minute, errQueueEmpty) ch := make(chan struct{}) go func() { defer close(ch) _, _, err := p.conn.Read(ctx) if err != nil { slog.ErrorContext(ctx, "player dropped on hello", "player", p.id, "err", err.Error()) stop() } }() mp := matchingPerson{sync: make(chan struct{}), person: p} id, chall, deal := s.l.Queue(ctx, uuid.New, mp) <-ch close(mp.sync) stop() if id == uuid.Nil { // Context canceled. p.conn.Close(websocket.StatusTryAgainLater, "sorry, queue is empty...") return } if deal { g := game.New(p.id, chall.id) <-chall.sync go gameActor(context.TODO(), g, p, chall.person, nil) } // Reply with the game ID so they can share. r := serve.GameStart{ ID: serve.GameID{UUID: 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) p.conn.Close(websocket.StatusNormalClosure, "looks like you dropped") return } } var errQueueEmpty = errors.New("sorry, queue is empty")