diff options
author | Santo Cariotti <santo@dcariotti.me> | 2025-04-08 16:33:19 +0200 |
---|---|---|
committer | Santo Cariotti <santo@dcariotti.me> | 2025-04-08 16:33:19 +0200 |
commit | d22c0c359ba33574fc517d1067b213f2e006549f (patch) | |
tree | 160e71ca4dea219c492789b1ba3522762f377e35 /pkg/ui/views | |
parent | e530502c7c5554b8f7c669cbde1bd2175d077a19 (diff) |
Start new server on new play
Diffstat (limited to 'pkg/ui/views')
-rw-r--r-- | pkg/ui/views/play.go | 533 |
1 files changed, 321 insertions, 212 deletions
diff --git a/pkg/ui/views/play.go b/pkg/ui/views/play.go index 991849e..f31c7c1 100644 --- a/pkg/ui/views/play.go +++ b/pkg/ui/views/play.go @@ -10,13 +10,15 @@ import ( "github.com/boozec/rahanna/internal/api/database" "github.com/boozec/rahanna/internal/network" + "github.com/boozec/rahanna/pkg/ui/multiplayer" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) -var chess string = ` +const ( + chessBoard = ` A B C D E F G H +---------------+ 8 |♜ ♞ ♝ ♛ ♚ ♝ ♞ ♜| 8 @@ -30,7 +32,29 @@ var chess string = ` +---------------+ A B C D E F G H ` +) + +type PlayModelPage int +const ( + LandingPage PlayModelPage = iota + InsertCodePage + StartGamePage +) + +type responseOk struct { + Name string `json:"name"` + IP string `json:"ip"` + Port int `json:"int"` +} + +// API response types +type playResponse struct { + Ok responseOk + Error string `json:"error"` +} + +// Keyboard controls type playKeyMap struct { EnterNewGame key.Binding StartNewGame key.Binding @@ -38,18 +62,14 @@ type playKeyMap struct { Quit key.Binding } -type playResponse struct { - Name string `json:"name"` - Error string `json:"error"` -} - -var defaultGameKeyMap = playKeyMap{ +// Default key bindings for the play model +var defaultPlayKeyMap = playKeyMap{ EnterNewGame: key.NewBinding( key.WithKeys("alt+E", "alt+e"), key.WithHelp("Alt+E", "Enter a play using code"), ), StartNewGame: key.NewBinding( - key.WithKeys("alt+s", "alt+s"), + key.WithKeys("alt+s", "alt+S"), key.WithHelp("Alt+S", "Start a new play"), ), GoLogout: key.NewBinding( @@ -62,40 +82,33 @@ var defaultGameKeyMap = playKeyMap{ ), } -type PlayModelPage int - -const ( - LandingPage PlayModelPage = iota - InsertCodePage - StartGamePage -) - type PlayModel struct { - width int - height int + // UI dimensions + width int + height int + + // UI state err error keys playKeyMap namePrompt textinput.Model page PlayModelPage isLoading bool - playName string - play *database.Game + + // Game state + playName string + play *database.Game + network *multiplayer.PlayNetwork } +// NewPlayModel creates a new play model instance func NewPlayModel(width, height int) PlayModel { - namePrompt := textinput.New() - namePrompt.Prompt = " " - namePrompt.TextStyle = inputStyle - namePrompt.Placeholder = "rectangular-lake" - namePrompt.Focus() - namePrompt.CharLimit = 23 - namePrompt.Width = 23 + namePrompt := createNamePrompt() return PlayModel{ width: width, height: height, err: nil, - keys: defaultGameKeyMap, + keys: defaultPlayKeyMap, namePrompt: namePrompt, page: LandingPage, isLoading: false, @@ -104,6 +117,19 @@ func NewPlayModel(width, height int) PlayModel { } } +// Create and configure the name input prompt +func createNamePrompt() textinput.Model { + namePrompt := textinput.New() + namePrompt.Prompt = " " + namePrompt.TextStyle = inputStyle + namePrompt.Placeholder = "rectangular-lake" + namePrompt.Focus() + namePrompt.CharLimit = 23 + namePrompt.Width = 23 + + return namePrompt +} + func (m PlayModel) Init() tea.Cmd { ClearScreen() return textinput.Blink @@ -116,129 +142,202 @@ func (m PlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - return m, nil - + return m.handleWindowSize(msg) case tea.KeyMsg: - switch { - case key.Matches(msg, m.keys.EnterNewGame): - m.page = InsertCodePage - return m, nil - case key.Matches(msg, m.keys.StartNewGame): - m.page = StartGamePage - if !m.isLoading { - m.isLoading = true - return m, m.newGameCallback() - } - case key.Matches(msg, m.keys.GoLogout): - return m, m.logout() - case key.Matches(msg, m.keys.Quit): - return m, tea.Quit - case msg.Type == tea.KeyEnter: - if m.page == InsertCodePage { - if !m.isLoading { - m.isLoading = true - return m, m.enterGame() - } - } - } + return m.handleKeyPress(msg) 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 + return m.handlePlayResponse(msg) case database.Game: - m.isLoading = false - m.play = &msg - m.err = nil - return m, nil + return m.handleGameResponse(msg) case error: - m.isLoading = false - m.err = msg + return m.handleError(msg) } - var cmd tea.Cmd = nil - + // Handle input updates when on the InsertCodePage if m.page == InsertCodePage { + var cmd tea.Cmd m.namePrompt, cmd = m.namePrompt.Update(msg) + return m, cmd } - return m, tea.Batch(cmd) + return m, nil } func (m PlayModel) View() string { formWidth := getFormWidth(m.width) - - var content string base := lipgloss.NewStyle().Align(lipgloss.Center).Width(m.width) + content := m.renderPageContent(base) + + // Build the main window with error handling + windowContent := m.buildWindowContent(content, formWidth) + + // Create navigation buttons + buttons := m.renderNavigationButtons() + + centeredContent := lipgloss.JoinVertical( + lipgloss.Center, + getLogo(m.width), + windowContent, + lipgloss.NewStyle().MarginTop(2).Render(buttons), + ) + + return lipgloss.Place( + m.width, + m.height, + lipgloss.Center, + lipgloss.Center, + centeredContent, + ) +} + +func (m PlayModel) handleWindowSize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { + m.width = msg.Width + m.height = msg.Height + return m, nil +} + +func (m PlayModel) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, m.keys.EnterNewGame): + m.page = InsertCodePage + return m, nil + + case key.Matches(msg, m.keys.StartNewGame): + m.page = StartGamePage + if !m.isLoading { + m.isLoading = true + return m, m.newGameCallback() + } + + case key.Matches(msg, m.keys.GoLogout): + 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.isLoading { + m.isLoading = true + return m, m.enterGame() + } + } + + return m, nil +} + +func (m *PlayModel) handlePlayResponse(msg playResponse) (tea.Model, tea.Cmd) { + 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.Ok.Name + m.network = multiplayer.NewPlayNetwork("peer-1", msg.Ok.IP, msg.Ok.Port) + } + + return m, nil +} + +func (m *PlayModel) handleGameResponse(msg database.Game) (tea.Model, tea.Cmd) { + m.isLoading = false + m.play = &msg + m.err = nil + return m, nil +} + +func (m *PlayModel) handleError(msg error) (tea.Model, tea.Cmd) { + m.isLoading = false + m.err = msg + return m, nil +} + +func (m *PlayModel) renderPageContent(base lipgloss.Style) string { switch m.page { case LandingPage: - content = chess m.namePrompt.Blur() + return chessBoard + case InsertCodePage: m.namePrompt.Focus() - var statusMsg string - if m.isLoading { - statusMsg = "Loading..." - content = base. - Render( - lipgloss.NewStyle(). - Align(lipgloss.Center). - 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), - ), - ) - } + return m.renderInsertCodeContent(base) case StartGamePage: - 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)) - } + return m.renderStartGameContent(base) + } + + return "" +} - content = base. - Render(statusMsg) +func (m PlayModel) renderInsertCodeContent(base lipgloss.Style) string { + // When loading, show loading status + if m.isLoading { + return base.Render( + lipgloss.NewStyle(). + Align(lipgloss.Center). + Bold(true). + Render("Loading..."), + ) } - var windowContent string + // When we have a play, show who we're playing against + if m.play != nil { + playerName := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#e67e22")). + Render(m.play.Player1.Username) + + statusMsg := fmt.Sprintf("You are playing versus %s", playerName) + + return base.Render( + lipgloss.NewStyle(). + Align(lipgloss.Center). + Width(m.width). + Bold(true). + Render(statusMsg), + ) + } + + // Default: show input prompt + return 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(fmt.Sprintf("Press %s to join", + lipgloss.NewStyle().Italic(true).Render("Enter"))), + ), + ) +} + +func (m PlayModel) renderStartGameContent(base lipgloss.Style) string { + var statusMsg string + + if m.isLoading { + statusMsg = "Loading..." + } else if m.playName != "" { + gameCode := lipgloss.NewStyle(). + Italic(true). + Foreground(lipgloss.Color("#F39C12")). + Render(m.playName) + + statusMsg = fmt.Sprintf("Share `%s` to your friend", gameCode) + } + + return base.Render(statusMsg) +} + +func (m PlayModel) buildWindowContent(content string, formWidth int) string { if m.err != nil { formError := fmt.Sprintf("Error: %v", m.err.Error()) - windowContent = lipgloss.JoinVertical( + return lipgloss.JoinVertical( lipgloss.Center, windowStyle.Width(formWidth).Render(lipgloss.JoinVertical( lipgloss.Center, @@ -246,172 +345,147 @@ func (m PlayModel) View() string { 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.EnterNewGame.Help().Key), m.keys.EnterNewGame.Help().Desc) - startKey := fmt.Sprintf("%s %s", altCodeStyle.Render(m.keys.StartNewGame.Help().Key), m.keys.StartNewGame.Help().Desc) - logoutKey := fmt.Sprintf("%s %s", altCodeStyle.Render(m.keys.GoLogout.Help().Key), m.keys.GoLogout.Help().Desc) - quitKey := fmt.Sprintf("%s %s", altCodeStyle.Render(m.keys.Quit.Help().Key), m.keys.Quit.Help().Desc) + return lipgloss.JoinVertical( + lipgloss.Center, + windowStyle.Width(formWidth).Render(lipgloss.JoinVertical( + lipgloss.Center, + content, + )), + ) +} + +func (m PlayModel) renderNavigationButtons() string { + enterKey := fmt.Sprintf("%s %s", + altCodeStyle.Render(m.keys.EnterNewGame.Help().Key), + m.keys.EnterNewGame.Help().Desc) + + startKey := fmt.Sprintf("%s %s", + altCodeStyle.Render(m.keys.StartNewGame.Help().Key), + m.keys.StartNewGame.Help().Desc) + + logoutKey := fmt.Sprintf("%s %s", + altCodeStyle.Render(m.keys.GoLogout.Help().Key), + m.keys.GoLogout.Help().Desc) + + quitKey := fmt.Sprintf("%s %s", + altCodeStyle.Render(m.keys.Quit.Help().Key), + m.keys.Quit.Help().Desc) // Vertically align the buttons - buttons := lipgloss.JoinVertical( + return lipgloss.JoinVertical( lipgloss.Left, enterKey, startKey, logoutKey, quitKey, ) - - centeredContent := lipgloss.JoinVertical( - lipgloss.Center, - getLogo(m.width), - windowContent, - lipgloss.NewStyle().MarginTop(2).Render(buttons), - ) - - return lipgloss.Place( - m.width, - m.height, - lipgloss.Center, - lipgloss.Center, - centeredContent, - ) } -func (m PlayModel) newGameCallback() tea.Cmd { +func (m *PlayModel) newGameCallback() tea.Cmd { return func() tea.Msg { - f, err := os.Open(".rahannarc") + // Get authorization token + authorization, err := getAuthorizationToken() 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") + "/play" + // Set up network connection port, err := network.GetRandomAvailablePort() if err != nil { return playResponse{Error: err.Error()} } + ip := network.GetOutboundIP().String() + // FIXME: ip + ip = "0.0.0.0" + + // Prepare request payload payload, err := json.Marshal(map[string]string{ - "ip": fmt.Sprintf("%s:%d", network.GetOutboundIP().String(), port), + "ip": fmt.Sprintf("%s:%d", ip, port), }) - if err != nil { return playResponse{Error: err.Error()} } - req, err := http.NewRequest("POST", url, bytes.NewReader(payload)) + // Send API request + url := os.Getenv("API_BASE") + "/play" + resp, err := sendAPIRequest("POST", url, payload, authorization) 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() + // Handle response if resp.StatusCode != http.StatusOK { var response playResponse - err = json.NewDecoder(resp.Body).Decode(&response) - if err != nil { + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { return playResponse{Error: fmt.Sprintf("HTTP error: %d, unable to decode body", resp.StatusCode)} } return playResponse{Error: response.Error} } - var response playResponse - err = json.NewDecoder(resp.Body).Decode(&response) - if err != nil { + // Decode successful response + var response struct { + Name string `json:"name"` + Error string `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { return playResponse{Error: fmt.Sprintf("Error decoding JSON: %v", err)} } - return response + return playResponse{Ok: responseOk{Name: response.Name, IP: ip, Port: port}} } } func (m PlayModel) enterGame() tea.Cmd { return func() tea.Msg { - f, err := os.Open(".rahannarc") + // Get authorization token + authorization, err := getAuthorizationToken() 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-game" + // Set up network connection port, err := network.GetRandomAvailablePort() if err != nil { return playResponse{Error: err.Error()} } + ip := network.GetOutboundIP().String() + // FIXME: ip + ip = "0.0.0.0" + + // Prepare request payload payload, err := json.Marshal(map[string]string{ - "ip": fmt.Sprintf("%s:%d", network.GetOutboundIP().String(), port), + "ip": fmt.Sprintf("%s:%d", ip, port), "name": m.namePrompt.Value(), }) - if err != nil { return playResponse{Error: err.Error()} } - req, err := http.NewRequest("POST", url, bytes.NewReader(payload)) + // Send API request + url := os.Getenv("API_BASE") + "/enter-game" + resp, err := sendAPIRequest("POST", url, payload, authorization) 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() + // Handle response if resp.StatusCode != http.StatusOK { var response playResponse - err = json.NewDecoder(resp.Body).Decode(&response) - if err != nil { + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { return playResponse{Error: fmt.Sprintf("HTTP error: %d, unable to decode body", resp.StatusCode)} } return playResponse{Error: response.Error} } + // Decode successful response var response database.Game - err = json.NewDecoder(resp.Body).Decode(&response) - if err != nil { + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { return playResponse{Error: fmt.Sprintf("Error decoding JSON: %v", err)} } @@ -425,3 +499,38 @@ func (m PlayModel) logout() tea.Cmd { } return SwitchModelCmd(NewAuthModel(m.width, m.height+1)) } + +// getAuthorizationToken reads the authentication token from the .rahannarc file +func getAuthorizationToken() (string, error) { + f, err := os.Open(".rahannarc") + if err != nil { + return "", err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + var authorization string + for scanner.Scan() { + authorization = scanner.Text() + } + + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("error reading auth token: %v", err) + } + + return authorization, nil +} + +// sendAPIRequest sends an HTTP request to the API with the given parameters +func sendAPIRequest(method, url string, payload []byte, authorization string) (*http.Response, error) { + req, err := http.NewRequest(method, url, bytes.NewReader(payload)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", authorization)) + + client := &http.Client{} + return client.Do(req) +} |