From f75ec8f8f5b3d0d75f752b26df1088e9d42d2634 Mon Sep 17 00:00:00 2001 From: Santo Cariotti Date: Mon, 7 Apr 2025 21:31:09 +0200 Subject: Join a game --- .gitignore | 1 + api/handlers/handlers.go | 62 ++++++++++++++++ cmd/api/main.go | 1 + cmd/ui/main.go | 19 +++++ network/network.go | 14 ++++ ui/views/play.go | 189 +++++++++++++++++++++++++++++++++++++---------- 6 files changed, 246 insertions(+), 40 deletions(-) create mode 100644 .gitignore create mode 100644 cmd/ui/main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..35cce1c --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.rahannarc diff --git a/api/handlers/handlers.go b/api/handlers/handlers.go index 9166ec7..fe2ca58 100644 --- a/api/handlers/handlers.go +++ b/api/handlers/handlers.go @@ -4,12 +4,14 @@ import ( "encoding/json" "log/slog" "net/http" + "time" "github.com/boozec/rahanna/api/auth" "github.com/boozec/rahanna/api/database" utils "github.com/boozec/rahanna/pkg" "github.com/boozec/rahanna/relay" "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" ) type NewPlayRequest struct { @@ -136,3 +138,63 @@ func NewPlay(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]string{"name": name}) } + +func EnterPlay(w http.ResponseWriter, r *http.Request) { + slog.Info("POST /enter-play") + claims, err := auth.ValidateJWT(r.Header.Get("Authorization")) + + if err != nil { + utils.JsonError(&w, err.Error()) + return + } + + var payload struct { + Name string `json:"name"` + IP string `json:"ip"` + } + + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + utils.JsonError(&w, err.Error()) + return + } + + if err != nil { + utils.JsonError(&w, err.Error()) + return + } + + db, _ := database.GetDb() + + var play database.Play + + result := db.Where("name = ? AND player2_id IS NULL", payload.Name).First(&play) + if result.Error != nil { + utils.JsonError(&w, result.Error.Error()) + return + } + + play.Player2ID = &claims.UserID + play.IP2 = payload.IP + play.UpdatedAt = time.Now() + + if err := db.Save(&play).Error; err != nil { + utils.JsonError(&w, err.Error()) + return + } + + result = db.Where("id = ?", play.ID). + Preload("Player1", func(db *gorm.DB) *gorm.DB { + return db.Omit("Password") + }). + Preload("Player2", func(db *gorm.DB) *gorm.DB { + return db.Omit("Password") + }). + First(&play) + + if result.Error != nil { + utils.JsonError(&w, result.Error.Error()) + return + } + + json.NewEncoder(w).Encode(play) +} diff --git a/cmd/api/main.go b/cmd/api/main.go index 67ebe07..b1f912f 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -19,6 +19,7 @@ func main() { r.HandleFunc("/auth/register", handlers.RegisterUser).Methods(http.MethodPost) r.HandleFunc("/auth/login", handlers.LoginUser).Methods(http.MethodPost) r.Handle("/play", middleware.AuthMiddleware(http.HandlerFunc(handlers.NewPlay))).Methods(http.MethodPost) + r.Handle("/enter-play", middleware.AuthMiddleware(http.HandlerFunc(handlers.EnterPlay))).Methods(http.MethodPost) slog.Info("Serving on :8080") handler := cors.AllowAll().Handler(r) diff --git a/cmd/ui/main.go b/cmd/ui/main.go new file mode 100644 index 0000000..4bdcce9 --- /dev/null +++ b/cmd/ui/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "log" + + "github.com/boozec/rahanna/ui/views" + tea "github.com/charmbracelet/bubbletea" +) + +func main() { + views.ClearScreen() + + p := tea.NewProgram(views.NewRahannaModel(), tea.WithAltScreen()) + + if _, err := p.Run(); err != nil { + log.Fatal(err) + } + views.ClearScreen() +} diff --git a/network/network.go b/network/network.go index 322487b..5ab2dac 100644 --- a/network/network.go +++ b/network/network.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "log/slog" + "math/rand" "net" "sync" "time" @@ -224,3 +225,16 @@ func GetOutboundIP() net.IP { return localAddr.IP } + +func GetRandomAvailablePort() (int, error) { + for i := 0; i < 100; i += 1 { + port := rand.Intn(65535-1024) + 1024 + addr := fmt.Sprintf(":%d", port) + ln, err := net.Listen("tcp", addr) + if err == nil { + defer ln.Close() + return port, nil + } + } + return 0, fmt.Errorf("failed to find an available port after multiple attempts") +} diff --git a/ui/views/play.go b/ui/views/play.go index b6afbd1..d4f6b3b 100644 --- a/ui/views/play.go +++ b/ui/views/play.go @@ -4,11 +4,11 @@ import ( "bufio" "bytes" "encoding/json" - "errors" "fmt" "net/http" "os" + "github.com/boozec/rahanna/api/database" "github.com/boozec/rahanna/network" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textinput" @@ -79,6 +79,7 @@ type PlayModel struct { page PlayModelPage isLoading bool playName string + play *database.Play } func NewPlayModel(width, height int) PlayModel { @@ -99,6 +100,7 @@ func NewPlayModel(width, height int) PlayModel { page: LandingPage, isLoading: false, playName: "", + play: nil, } } @@ -130,26 +132,34 @@ func (m PlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.newPlayCallback() } case key.Matches(msg, m.keys.GoLogout): - if err := os.Remove(".rahannarc"); err != nil { - m.err = err - return m, nil - } - return m, SwitchModelCmd(NewAuthModel(m.width, m.height+1)) + return m, m.logout() case key.Matches(msg, m.keys.Quit): return m, tea.Quit case msg.Type == tea.KeyEnter: if m.page == InsertCodePage { - m.err = errors.New("Can't join for now...") + if !m.isLoading { + m.isLoading = true + return m, m.enterPlay() + } } } case playResponse: m.isLoading = false + m.err = nil if msg.Error != "" { m.err = fmt.Errorf(msg.Error) + if msg.Error == "unauthorized" { + return m, m.logout() + } } else { m.playName = msg.Name } return m, nil + case database.Play: + m.isLoading = false + m.play = &msg + m.err = nil + return m, nil case error: m.isLoading = false m.err = msg @@ -167,13 +177,8 @@ func (m PlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m PlayModel) View() string { formWidth := getFormWidth(m.width) - // Error message - formError := "" - if m.err != nil { - formError = fmt.Sprintf("Error: %v", m.err.Error()) - } - var content string + base := lipgloss.NewStyle().Align(lipgloss.Center).Width(m.width) switch m.page { case LandingPage: @@ -181,50 +186,75 @@ func (m PlayModel) View() string { m.namePrompt.Blur() case InsertCodePage: m.namePrompt.Focus() - content = m.namePrompt.View() - - statusMsg := fmt.Sprintf("Press %s to join", lipgloss.NewStyle().Italic(true).Render("Enter")) + var statusMsg string if m.isLoading { statusMsg = "Loading..." - } - - content = lipgloss.NewStyle(). - Align(lipgloss.Center). - Width(m.width). - Render( - lipgloss.JoinVertical(lipgloss.Left, - lipgloss.NewStyle().Width(23).Render("Insert play code:"), - m.namePrompt.View(), + content = base. + Render( lipgloss.NewStyle(). Align(lipgloss.Center). - PaddingTop(2). - Width(23). Bold(true). Render(statusMsg), - ), - ) + ) + } else if m.play != nil { + statusMsg = fmt.Sprintf("You are playing versus %s", lipgloss.NewStyle().Foreground(lipgloss.Color("#e67e22")).Render(m.play.Player1.Username)) + content = base. + Render( + lipgloss.NewStyle(). + Align(lipgloss.Center). + Width(m.width). + Bold(true). + Render(statusMsg), + ) + } else { + statusMsg = fmt.Sprintf("Press %s to join", lipgloss.NewStyle().Italic(true).Render("Enter")) + content = base. + Render( + lipgloss.JoinVertical(lipgloss.Left, + lipgloss.NewStyle().Width(23).Render("Insert play code:"), + m.namePrompt.View(), + lipgloss.NewStyle(). + Align(lipgloss.Center). + PaddingTop(2). + Width(23). + Bold(true). + Render(statusMsg), + ), + ) + } + case StartPlayPage: - statusMsg := fmt.Sprintf("Share `%s` to your friend", lipgloss.NewStyle().Italic(true).Foreground(lipgloss.Color("#F39C12")).Render(m.playName)) + var statusMsg string if m.isLoading { statusMsg = "Loading..." + } else if m.playName != "" { + statusMsg = fmt.Sprintf("Share `%s` to your friend", lipgloss.NewStyle().Italic(true).Foreground(lipgloss.Color("#F39C12")).Render(m.playName)) } - content = lipgloss.NewStyle(). - Align(lipgloss.Center). - Width(m.width). + content = base. Render(statusMsg) } - windowContent := lipgloss.JoinVertical( - lipgloss.Center, - windowStyle. - Width(formWidth). - Render(lipgloss.JoinVertical( + var windowContent string + if m.err != nil { + formError := fmt.Sprintf("Error: %v", m.err.Error()) + windowContent = lipgloss.JoinVertical( + lipgloss.Center, + windowStyle.Width(formWidth).Render(lipgloss.JoinVertical( lipgloss.Center, errorStyle.Align(lipgloss.Center).Width(formWidth-4).Render(formError), content, )), - ) + ) + } else { + windowContent = lipgloss.JoinVertical( + lipgloss.Center, + windowStyle.Width(formWidth).Render(lipgloss.JoinVertical( + lipgloss.Center, + content, + )), + ) + } enterKey := fmt.Sprintf("%s %s", altCodeStyle.Render(m.keys.EnterNewPlay.Help().Key), m.keys.EnterNewPlay.Help().Desc) startKey := fmt.Sprintf("%s %s", altCodeStyle.Render(m.keys.StartNewPlay.Help().Key), m.keys.StartNewPlay.Help().Desc) @@ -276,8 +306,13 @@ func (m PlayModel) newPlayCallback() tea.Cmd { url := os.Getenv("API_BASE") + "/play" + port, err := network.GetRandomAvailablePort() + if err != nil { + return playResponse{Error: err.Error()} + } + payload, err := json.Marshal(map[string]string{ - "ip": network.GetOutboundIP().String(), + "ip": fmt.Sprintf("%s:%d", network.GetOutboundIP().String(), port), }) if err != nil { @@ -316,3 +351,77 @@ func (m PlayModel) newPlayCallback() tea.Cmd { return response } } + +func (m PlayModel) enterPlay() tea.Cmd { + return func() tea.Msg { + f, err := os.Open(".rahannarc") + if err != nil { + return playResponse{Error: err.Error()} + } + defer f.Close() + + scanner := bufio.NewScanner(f) + var authorization string + for scanner.Scan() { + authorization = scanner.Text() + } + + if err := scanner.Err(); err != nil { + fmt.Println("Error during scanning:", err) + } + + url := os.Getenv("API_BASE") + "/enter-play" + + port, err := network.GetRandomAvailablePort() + if err != nil { + return playResponse{Error: err.Error()} + } + + payload, err := json.Marshal(map[string]string{ + "ip": fmt.Sprintf("%s:%d", network.GetOutboundIP().String(), port), + "name": m.namePrompt.Value(), + }) + + if err != nil { + return playResponse{Error: err.Error()} + } + + req, err := http.NewRequest("POST", url, bytes.NewReader(payload)) + if err != nil { + return playResponse{Error: err.Error()} + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", authorization)) + + client := &http.Client{} + + resp, err := client.Do(req) + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + var response playResponse + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return playResponse{Error: fmt.Sprintf("HTTP error: %d, unable to decode body", resp.StatusCode)} + } + return playResponse{Error: response.Error} + } + + var response database.Play + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return playResponse{Error: fmt.Sprintf("Error decoding JSON: %v", err)} + } + + return response + } +} + +func (m PlayModel) logout() tea.Cmd { + if err := os.Remove(".rahannarc"); err != nil { + return nil + } + return SwitchModelCmd(NewAuthModel(m.width, m.height+1)) +} -- cgit v1.2.3-18-g5258