diff options
author | Santo Cariotti <santo@dcariotti.me> | 2025-04-24 13:53:54 +0200 |
---|---|---|
committer | Santo Cariotti <santo@dcariotti.me> | 2025-04-24 13:53:54 +0200 |
commit | b35829071ab0d0f3479021eac151b90e49a2fca5 (patch) | |
tree | 0e2af88ed2732e2211ffbf5689488e010174783a | |
parent | 4ae96a216eb50ccec7712fa9ed0d4dc8d9950f68 (diff) |
Play co-op 2 vs 2
-rw-r--r-- | internal/api/database/models.go | 14 | ||||
-rw-r--r-- | internal/api/handlers/handlers.go | 97 | ||||
-rw-r--r-- | pkg/p2p/network.go | 10 | ||||
-rw-r--r-- | pkg/ui/multiplayer/multiplayer.go | 34 | ||||
-rw-r--r-- | pkg/ui/views/game.go | 30 | ||||
-rw-r--r-- | pkg/ui/views/game_api.go | 36 | ||||
-rw-r--r-- | pkg/ui/views/game_keymap.go | 4 | ||||
-rw-r--r-- | pkg/ui/views/game_moves.go | 11 | ||||
-rw-r--r-- | pkg/ui/views/game_restore.go | 39 | ||||
-rw-r--r-- | pkg/ui/views/game_util.go | 18 | ||||
-rw-r--r-- | pkg/ui/views/play_api.go | 93 | ||||
-rw-r--r-- | pkg/ui/views/play_keymap.go | 51 | ||||
-rw-r--r-- | pkg/ui/views/play_util.go | 4 |
13 files changed, 324 insertions, 117 deletions
diff --git a/internal/api/database/models.go b/internal/api/database/models.go index 4acbca5..f065a8c 100644 --- a/internal/api/database/models.go +++ b/internal/api/database/models.go @@ -10,15 +10,29 @@ type User struct { UpdatedAt time.Time `json:"updated_at"` } +type GameType string + +const ( + SingleGameType GameType = "single" + PairGameType GameType = "pair" +) + type Game struct { ID int `json:"id"` + Type GameType `json:"type"` Player1ID int `json:"-"` Player1 User `gorm:"foreignKey:Player1ID" json:"player1"` Player2ID *int `json:"-"` Player2 *User `gorm:"foreignKey:Player2ID;null" json:"player2"` + Player3ID *int `json:"-"` + Player3 *User `gorm:"foreignKey:Player3ID;null" json:"player3"` + Player4ID *int `json:"-"` + Player4 *User `gorm:"foreignKey:Player4ID;null" json:"player4"` Name string `json:"name"` IP1 string `json:"ip1"` IP2 string `json:"ip2"` + IP3 string `json:"ip3"` + IP4 string `json:"ip4"` Outcome string `json:"outcome"` LastPlayer int `json:"last_player"` // Last player entered in game CreatedAt time.Time `json:"created_at"` diff --git a/internal/api/handlers/handlers.go b/internal/api/handlers/handlers.go index 34b4f4c..29da3b3 100644 --- a/internal/api/handlers/handlers.go +++ b/internal/api/handlers/handlers.go @@ -11,7 +11,6 @@ import ( "github.com/boozec/rahanna/internal/logger" "github.com/boozec/rahanna/pkg/p2p" "github.com/gorilla/mux" - "gorm.io/gorm" ) type NewGameRequest struct { @@ -106,7 +105,8 @@ func NewPlay(w http.ResponseWriter, r *http.Request) { } var payload struct { - IP string `json:"ip"` + IP string `json:"ip"` + Type database.GameType `json:"type"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { @@ -125,11 +125,10 @@ func NewPlay(w http.ResponseWriter, r *http.Request) { } play := database.Game{ + Type: payload.Type, Player1ID: claims.UserID, - Player2ID: nil, Name: name, IP1: payload.IP, - IP2: "", Outcome: "*", LastPlayer: 1, } @@ -139,7 +138,7 @@ func NewPlay(w http.ResponseWriter, r *http.Request) { return } - json.NewEncoder(w).Encode(map[string]interface{}{"id": play.ID, "name": name}) + json.NewEncoder(w).Encode(map[string]interface{}{"id": play.ID, "type": play.Type, "name": name}) } func EnterGame(w http.ResponseWriter, r *http.Request) { @@ -171,18 +170,55 @@ func EnterGame(w http.ResponseWriter, r *http.Request) { return } - if game.Player2ID == nil { - game.Player2ID = &claims.UserID - game.IP2 = payload.IP - game.LastPlayer = 2 - } else { - if game.Player1ID == claims.UserID { - game.IP1 = payload.IP - game.LastPlayer = 1 + switch game.Type { + case database.SingleGameType: + if game.Player2ID == nil { + game.Player2ID = &claims.UserID + game.IP2 = payload.IP + game.LastPlayer = 2 } else { + switch claims.UserID { + case game.Player1ID: + game.IP1 = payload.IP + game.LastPlayer = 1 + case *game.Player2ID: + game.IP2 = payload.IP + game.LastPlayer = 2 + } + } + + case database.PairGameType: + if game.Player2ID == nil { + game.Player2ID = &claims.UserID game.IP2 = payload.IP game.LastPlayer = 2 + } else if game.Player3ID == nil { + game.Player3ID = &claims.UserID + game.IP3 = payload.IP + game.LastPlayer = 3 + } else if game.Player4ID == nil { + game.Player4ID = &claims.UserID + game.IP4 = payload.IP + game.LastPlayer = 4 + } else { + switch claims.UserID { + case game.Player1ID: + game.IP1 = payload.IP + game.LastPlayer = 1 + case *game.Player2ID: + game.IP2 = payload.IP + game.LastPlayer = 2 + case *game.Player3ID: + game.IP3 = payload.IP + game.LastPlayer = 3 + case *game.Player4ID: + game.IP4 = payload.IP + game.LastPlayer = 4 + } } + + default: + log.Fatal("Game type not recognized") } game.UpdatedAt = time.Now() @@ -195,6 +231,8 @@ func EnterGame(w http.ResponseWriter, r *http.Request) { result := db.Where("id = ?", game.ID). Preload("Player1", auth.OmitPassword). Preload("Player2", auth.OmitPassword). + Preload("Player3", auth.OmitPassword). + Preload("Player4", auth.OmitPassword). First(&game) if result.Error != nil { @@ -218,13 +256,13 @@ func AllPlay(w http.ResponseWriter, r *http.Request) { db, _ := database.GetDb() var games []database.Game - if result := db.Where("player1_id = ? OR player2_id = ?", claims.UserID, claims.UserID). - Preload("Player1", func(db *gorm.DB) *gorm.DB { - return db.Omit("Password") - }). - Preload("Player2", func(db *gorm.DB) *gorm.DB { - return db.Omit("Password") - }). + if result := db.Where("player1_id = ? OR player2_id = ? OR player3_id = ? OR player4_id = ?", + claims.UserID, claims.UserID, claims.UserID, claims.UserID, + ). + Preload("Player1", auth.OmitPassword). + Preload("Player2", auth.OmitPassword). + Preload("Player3", auth.OmitPassword). + Preload("Player4", auth.OmitPassword). Order("updated_at DESC"). Find(&games); result.Error != nil { JsonError(&w, result.Error.Error()) @@ -249,13 +287,12 @@ func GetGameId(w http.ResponseWriter, r *http.Request) { db, _ := database.GetDb() var game database.Game - if result := db.Where("id = ? AND (player1_id = ? OR player2_id = ?)", id, claims.UserID, claims.UserID). - Preload("Player1", func(db *gorm.DB) *gorm.DB { - return db.Omit("Password") - }). - Preload("Player2", func(db *gorm.DB) *gorm.DB { - return db.Omit("Password") - }). + if result := db.Where("id = ? AND (player1_id = ? OR player2_id = ? OR player3_id = ? OR player4_id = ?)", + id, claims.UserID, claims.UserID, claims.UserID, claims.UserID). + Preload("Player1", auth.OmitPassword). + Preload("Player2", auth.OmitPassword). + Preload("Player3", auth.OmitPassword). + Preload("Player4", auth.OmitPassword). First(&game); result.Error != nil { JsonError(&w, result.Error.Error()) return @@ -291,8 +328,8 @@ func EndGame(w http.ResponseWriter, r *http.Request) { // FIXME: this is not secure if result := db.Where( - "id = ? AND (player1_id = ? OR player2_id = ?)", - id, claims.UserID, claims.UserID, + "id = ? AND (player1_id = ? OR player2_id = ? OR player3_id = ? OR player4_id = ?)", + id, claims.UserID, claims.UserID, claims.UserID, claims.UserID, ).First(&game); result.Error != nil { JsonError(&w, result.Error.Error()) return @@ -308,6 +345,8 @@ func EndGame(w http.ResponseWriter, r *http.Request) { result := db.Where("id = ?", game.ID). Preload("Player1", auth.OmitPassword). Preload("Player2", auth.OmitPassword). + Preload("Player3", auth.OmitPassword). + Preload("Player4", auth.OmitPassword). First(&game) if result.Error != nil { diff --git a/pkg/p2p/network.go b/pkg/p2p/network.go index a609832..a6d0316 100644 --- a/pkg/p2p/network.go +++ b/pkg/p2p/network.go @@ -101,6 +101,10 @@ func (n *TCPNetwork) Close() error { // Add a new peer connection to the local peer func (n *TCPNetwork) AddPeer(remoteID NetworkID, addr string) { + if remoteID == EmptyNetworkID { + return + } + n.Lock() n.connections[remoteID] = PeerConnection{Address: addr} n.Unlock() @@ -143,7 +147,7 @@ func (n *TCPNetwork) Send(remoteID NetworkID, messageType []byte, payload []byte n.removeConnection(remoteID) return fmt.Errorf("failed to send message: %v", err) } else { - n.Logger.Sugar().Infof("sent message to '%s' (%s): %s", remoteID, peerConn.Address, string(message.Payload)) + n.Logger.Sugar().Infof("sent message to '%s' (%s): type='%s', payload='%s'", remoteID, peerConn.Address, message.Type, message.Payload) } return nil @@ -207,7 +211,6 @@ func (n *TCPNetwork) handleConnection(conn net.Conn) { n.removeConnection(remoteID) return } - } n.Logger.Sugar().Infof("connected to remote peer %s (%s)\n", remoteID, remoteAddr) @@ -248,7 +251,7 @@ func (n *TCPNetwork) listenForMessages(conn net.Conn, remoteID NetworkID) { continue } - n.Logger.Sugar().Infof("received message from '%s' (%s): %s", message.Source, remoteAddr, string(message.Payload)) + n.Logger.Sugar().Infof("received message from '%s' (%s): type='%s', payload='%s'", message.Source, remoteAddr, message.Type, message.Payload) if n.OnReceiveFn != nil { n.OnReceiveFn(message) @@ -284,7 +287,6 @@ func (n *TCPNetwork) retryConnect(remoteID NetworkID, addr string) { n.Lock() n.connections[remoteID] = PeerConnection{Conn: conn, Address: addr} n.Unlock() - go n.handleConnection(conn) return } else { n.Logger.Sugar().Errorf("failed to connect to %s (%s): %v. Retrying in %v...", remoteID, addr, err, retryDelay) diff --git a/pkg/ui/multiplayer/multiplayer.go b/pkg/ui/multiplayer/multiplayer.go index 1b9365b..9694859 100644 --- a/pkg/ui/multiplayer/multiplayer.go +++ b/pkg/ui/multiplayer/multiplayer.go @@ -1,6 +1,7 @@ package multiplayer import ( + "slices" "time" "github.com/boozec/rahanna/internal/logger" @@ -12,20 +13,21 @@ type MoveType string const ( AbandonGameMessage MoveType = "abandon" - RestoreGameMessage MoveType = "restore" - RestoreAckGameMessage MoveType = "restore-ack" MoveGameMessage MoveType = "new-move" + RestoreAckGameMessage MoveType = "restore-ack" + RestoreGameMessage MoveType = "restore" ) type GameMove struct { - Type []byte `json:"type"` - Payload []byte `json:"payload"` + Source p2p.NetworkID `json:"source"` + Type []byte `json:"type"` + Payload []byte `json:"payload"` } type GameNetwork struct { server *p2p.TCPNetwork me p2p.NetworkID - peer p2p.NetworkID + peers []p2p.NetworkID } // Wrapper to a `TCPNetwork` @@ -44,20 +46,32 @@ func NewGameNetwork(localID string, address string, onHandshake p2p.NetworkHands } } -func (n *GameNetwork) Peer() p2p.NetworkID { - return n.peer +func (n *GameNetwork) Peers() []p2p.NetworkID { + return n.peers } func (n *GameNetwork) Me() p2p.NetworkID { return n.me } -func (n *GameNetwork) Send(messageType []byte, payload []byte) error { - return n.server.Send(n.peer, messageType, payload) +// Send a message to all peers +func (n *GameNetwork) SendAll(messageType []byte, payload []byte) error { + for _, peer := range n.peers { + n.server.Send(peer, messageType, payload) + } + + return nil +} + +// Send a message to only one peer +func (n *GameNetwork) Send(peer p2p.NetworkID, messageType []byte, payload []byte) error { + return n.server.Send(peer, messageType, payload) } func (n *GameNetwork) AddPeer(remoteID p2p.NetworkID, addr string) { - n.peer = remoteID + if exists := slices.Contains(n.peers, remoteID); !exists { + n.peers = append(n.peers, remoteID) + } n.server.AddPeer(remoteID, addr) } diff --git a/pkg/ui/views/game.go b/pkg/ui/views/game.go index afb81f5..a298de7 100644 --- a/pkg/ui/views/game.go +++ b/pkg/ui/views/game.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/boozec/rahanna/internal/api/database" + "github.com/boozec/rahanna/pkg/p2p" "github.com/boozec/rahanna/pkg/ui/multiplayer" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/textinput" @@ -93,18 +94,17 @@ func (m GameModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m, cmd = m.handleChessMoveMsg(msg) cmds = append(cmds, cmd) case SendRestoreMsg: - cmd = m.handleSendRestoreMsg() + cmd = m.handleSendRestoreMsg(p2p.NetworkID(msg)) cmds = append(cmds, cmd) case RestoreMoves: cmd = m.handleRestoreMoves(msg) cmds = append(cmds, cmd) - case database.Game: m, cmd = m.handleDatabaseGameMsg(msg) cmds = append(cmds, cmd, m.updateMovesListCmd()) case EndGameMsg: if msg.abandoned { - if m.network.Me() == m.playerPeer(1) { + if m.network.Me() == m.playerPeer(1) || m.network.Me() == m.playerPeer(3) { m.game.Outcome = string(chess.WhiteWon) } else { m.game.Outcome = string(chess.BlackWon) @@ -115,7 +115,7 @@ func (m GameModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.err = m.network.Close() case RestoreGameMsg: - m.network.Send([]byte("restore"), []byte(m.network.Me())) + m.network.SendAll([]byte("restore"), []byte(m.network.Me())) m.restore = false case error: @@ -135,8 +135,7 @@ func (m GameModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if err != nil { m.err = err } else { - m.turn++ - m.network.Send([]byte("new-move"), []byte(moveStr)) + m.network.SendAll([]byte("new-move"), []byte(moveStr)) m.err = nil } cmds = append(cmds, m.getMoves(), m.updateMovesListCmd()) @@ -187,12 +186,12 @@ func (m GameModel) View() string { switch m.game.Outcome { case string(chess.WhiteWon): outcome = "White won" - if m.network.Me() == m.playerPeer(1) { + if m.network.Me() == m.playerPeer(1) || m.network.Me() == m.playerPeer(3) { outcome += " (YOU)" } case string(chess.BlackWon): outcome = "Black won" - if m.network.Me() == m.playerPeer(2) { + if m.network.Me() == m.playerPeer(2) || m.network.Me() == m.playerPeer(4) { outcome += " (YOU)" } case string(chess.Draw): @@ -243,9 +242,22 @@ func (m GameModel) View() string { errorStr = m.err.Error() } + var playersHeader string + switch m.game.Type { + case database.SingleGameType: + playersHeader = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#f1c40f")). + Render(fmt.Sprintf("♔ %s vs ♚ %s", m.game.Player1.Username, m.game.Player2.Username)) + case database.PairGameType: + playersHeader = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#f1c40f")). + Render(fmt.Sprintf("♔ %s - %s vs ♚ %s - %s", + m.game.Player1.Username, m.game.Player3.Username, m.game.Player2.Username, m.game.Player4.Username)) + } + content := lipgloss.JoinVertical( lipgloss.Center, - lipgloss.NewStyle().Foreground(lipgloss.Color("#f1c40f")).Render(fmt.Sprintf("♔ %s vs ♚ %s", m.game.Player1.Username, m.game.Player2.Username)), + playersHeader, lipgloss.JoinHorizontal( lipgloss.Top, availableMovesListView, diff --git a/pkg/ui/views/game_api.go b/pkg/ui/views/game_api.go index 3c649f9..2f32f70 100644 --- a/pkg/ui/views/game_api.go +++ b/pkg/ui/views/game_api.go @@ -6,7 +6,6 @@ import ( "os" "github.com/boozec/rahanna/internal/api/database" - "github.com/boozec/rahanna/pkg/p2p" tea "github.com/charmbracelet/bubbletea" "github.com/notnil/chess" ) @@ -16,18 +15,29 @@ func (m GameModel) handleDatabaseGameMsg(msg database.Game) (GameModel, tea.Cmd) var cmd tea.Cmd - // Establish peer connection - if m.network.Peer() == p2p.EmptyNetworkID { - if m.network.Me() == m.playerPeer(1) { - if m.game.IP2 != "" { - remote := m.game.IP2 - m.network.AddPeer(m.playerPeer(2), remote) - } - } else { - if m.game.IP1 != "" { - remote := m.game.IP1 - m.network.AddPeer(m.playerPeer(1), remote) - } + peers := map[int]string{ + 1: m.game.IP1, + 2: m.game.IP2, + 3: m.game.IP3, + 4: m.game.IP4, + } + + myPlayerNum := -1 + switch m.network.Me() { + case m.playerPeer(1): + myPlayerNum = 1 + case m.playerPeer(2): + myPlayerNum = 2 + case m.playerPeer(3): + myPlayerNum = 3 + case m.playerPeer(4): + myPlayerNum = 4 + } + + // Add all peers to every other peer + for playerNum, ip := range peers { + if playerNum != myPlayerNum && ip != "" { + m.network.AddPeer(m.playerPeer(playerNum), ip) } } diff --git a/pkg/ui/views/game_keymap.go b/pkg/ui/views/game_keymap.go index ceacfc1..faf794a 100644 --- a/pkg/ui/views/game_keymap.go +++ b/pkg/ui/views/game_keymap.go @@ -38,13 +38,13 @@ func (m GameModel) handleKeyMsg(msg tea.KeyMsg) (GameModel, tea.Cmd) { // Abandon game only if it is not finished if m.game.Outcome == "*" { var outcome string - if m.network.Me() == m.playerPeer(1) { + if m.network.Me() == m.playerPeer(1) || m.network.Me() == m.playerPeer(3) { outcome = string(chess.BlackWon) } else { outcome = string(chess.WhiteWon) } - m.network.Send([]byte("abandon"), []byte("🏳️")) + m.network.SendAll([]byte("abandon"), []byte("🏳️")) return m, m.endGame(outcome) } case key.Matches(msg, m.keys.Quit): diff --git a/pkg/ui/views/game_moves.go b/pkg/ui/views/game_moves.go index 32f440f..ed11d30 100644 --- a/pkg/ui/views/game_moves.go +++ b/pkg/ui/views/game_moves.go @@ -27,6 +27,7 @@ func (i item) FilterValue() string { return i.title } func (m *GameModel) getMoves() tea.Cmd { m.network.AddReceiveFunction(func(msg p2p.Message) { gm := multiplayer.GameMove{ + Source: msg.Source, Type: msg.Type, Payload: msg.Payload, } @@ -40,7 +41,7 @@ func (m *GameModel) getMoves() tea.Cmd { case multiplayer.AbandonGameMessage: return EndGameMsg{abandoned: true} case multiplayer.RestoreGameMessage: - return SendRestoreMsg{} + return SendRestoreMsg(move.Source) case multiplayer.RestoreAckGameMessage: return RestoreMoves(string(move.Payload)) default: @@ -79,14 +80,8 @@ func (m GameModel) handleUpdateMovesListMsg() GameModel { } func (m GameModel) handleChessMoveMsg(msg ChessMoveMsg) (GameModel, tea.Cmd) { - err := m.chessGame.MoveStr(string(msg)) + m.err = m.chessGame.MoveStr(string(msg)) cmds := []tea.Cmd{m.getMoves(), m.updateMovesListCmd()} - if err != nil { - m.err = err - } else { - m.turn++ - m.err = nil - } if m.chessGame.Outcome() != chess.NoOutcome { cmds = append(cmds, m.endGame(m.chessGame.Outcome().String())) diff --git a/pkg/ui/views/game_restore.go b/pkg/ui/views/game_restore.go index 4ce8184..95f8844 100644 --- a/pkg/ui/views/game_restore.go +++ b/pkg/ui/views/game_restore.go @@ -5,25 +5,45 @@ import ( "strings" "time" + "github.com/boozec/rahanna/pkg/p2p" tea "github.com/charmbracelet/bubbletea" ) // Catch for `RestoreGameMessage` message from multiplayer -type SendRestoreMsg struct{} +type SendRestoreMsg p2p.NetworkID // Catch for `RestoreAckGameMessage` message from multiplayer type RestoreMoves string // For `RestoreGameMessage` from multiplayer it fixes the peer with the new // address and sends back an ACK to the peer' sender -func (m GameModel) handleSendRestoreMsg() tea.Cmd { +func (m GameModel) handleSendRestoreMsg(source p2p.NetworkID) tea.Cmd { _ = m.getGame()() - if m.network.Me() == m.playerPeer(1) { - remote := m.game.IP2 - m.network.AddPeer(m.playerPeer(2), remote) - } else { - remote := m.game.IP1 - m.network.AddPeer(m.playerPeer(1), remote) + + peers := map[int]string{ + 1: m.game.IP1, + 2: m.game.IP2, + 3: m.game.IP3, + 4: m.game.IP4, + } + + myPlayerNum := -1 + switch m.network.Me() { + case m.playerPeer(1): + myPlayerNum = 1 + case m.playerPeer(2): + myPlayerNum = 2 + case m.playerPeer(3): + myPlayerNum = 3 + case m.playerPeer(4): + myPlayerNum = 4 + } + + // Add all peers to every other peer + for playerNum, ip := range peers { + if playerNum != myPlayerNum && ip != "" { + m.network.AddPeer(m.playerPeer(playerNum), ip) + } } // FIXME: add a loading modal @@ -35,7 +55,7 @@ func (m GameModel) handleSendRestoreMsg() tea.Cmd { payload += fmt.Sprintf("%s\n", move.String()) } - m.err = m.network.Send([]byte("restore-ack"), []byte(payload)) + m.err = m.network.Send(source, []byte("restore-ack"), []byte(payload)) return nil } @@ -47,7 +67,6 @@ func (m *GameModel) handleRestoreMoves(msg RestoreMoves) tea.Cmd { m.chessGame.MoveStr(move) } - m.turn = len(moves) - 1 cmds := []tea.Cmd{m.getMoves(), m.updateMovesListCmd()} return tea.Batch(cmds...) } diff --git a/pkg/ui/views/game_util.go b/pkg/ui/views/game_util.go index f5e4a5d..8b8c658 100644 --- a/pkg/ui/views/game_util.go +++ b/pkg/ui/views/game_util.go @@ -3,6 +3,7 @@ package views import ( "fmt" + "github.com/boozec/rahanna/internal/api/database" "github.com/boozec/rahanna/pkg/p2p" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -27,7 +28,22 @@ func (m GameModel) buildWindowContent(content string, formWidth int) string { } func (m GameModel) isMyTurn() bool { - return m.turn%2 == 0 && m.network.Me() == m.playerPeer(1) || m.turn%2 == 1 && m.network.Me() == m.playerPeer(2) + if m.game == nil { + return false + } + + var totalPlayers int + + switch m.game.Type { + case database.SingleGameType: + totalPlayers = 2 + case database.PairGameType: + totalPlayers = 4 + } + + moves := len(m.chessGame.Moves()) + currentPlayer := (moves % totalPlayers) + 1 + return m.network.Me() == m.playerPeer(currentPlayer) } func (m GameModel) playerPeer(n int) p2p.NetworkID { diff --git a/pkg/ui/views/play_api.go b/pkg/ui/views/play_api.go index 62cd523..3846abe 100644 --- a/pkg/ui/views/play_api.go +++ b/pkg/ui/views/play_api.go @@ -8,6 +8,7 @@ import ( "os" "strconv" "strings" + "sync" "github.com/boozec/rahanna/internal/api/database" "github.com/boozec/rahanna/internal/logger" @@ -18,6 +19,7 @@ import ( type responseOk struct { Name string `json:"name"` + Type string `json:"type"` GameID int `json:"id"` IP string `json:"ip"` Port int `json:"int"` @@ -45,14 +47,31 @@ func (m *PlayModel) handlePlayResponse(msg playResponse) (tea.Model, tea.Cmd) { m.currentGameId = msg.Ok.GameID logger, _ := logger.GetLogger() - callbackCompleted := make(chan bool) - m.network = multiplayer.NewGameNetwork(fmt.Sprintf("%s-1", m.playName), fmt.Sprintf("%s:%d", msg.Ok.IP, msg.Ok.Port), p2p.DefaultHandshake, func(net.Conn) error { - close(callbackCompleted) + var wg sync.WaitGroup + var expectedPeers int + + switch msg.Ok.Type { + case string(database.SingleGameType): + expectedPeers = 1 + case string(database.PairGameType): + expectedPeers = 3 + default: + logger.Fatal("Type not recognized") + } + wg.Add(expectedPeers) + + handshakeCounter := 0 + m.network = multiplayer.NewGameNetwork(fmt.Sprintf("%s-1", m.playName), fmt.Sprintf("%s:%d", msg.Ok.IP, msg.Ok.Port), func(net.Conn) error { + handshakeCounter++ + if handshakeCounter <= expectedPeers && expectedPeers > 0 { + wg.Done() + } return nil - }, logger) + }, p2p.DefaultHandshake, logger) return m, func() tea.Msg { - <-callbackCompleted + wg.Wait() + return StartGameMsg{} } } @@ -67,21 +86,65 @@ func (m *PlayModel) handleGameResponse(msg database.Game) (tea.Model, tea.Cmd) { var ip []string var localID string + var expectedPeers int - if m.game.LastPlayer == 2 { - ip = strings.Split(m.game.IP2, ":") - localID = fmt.Sprintf("%s-2", m.game.Name) - } else { + switch m.game.LastPlayer { + case 1: ip = strings.Split(m.game.IP1, ":") localID = fmt.Sprintf("%s-1", m.game.Name) + + switch m.game.Type { + case database.SingleGameType: + expectedPeers = 1 + case database.PairGameType: + expectedPeers = 3 + } + + case 2: + ip = strings.Split(m.game.IP2, ":") + localID = fmt.Sprintf("%s-2", m.game.Name) + switch m.game.Type { + case database.SingleGameType: + expectedPeers = 0 + case database.PairGameType: + expectedPeers = 2 + } + + case 3: + ip = strings.Split(m.game.IP3, ":") + localID = fmt.Sprintf("%s-3", m.game.Name) + expectedPeers = 1 + + case 4: + ip = strings.Split(m.game.IP4, ":") + localID = fmt.Sprintf("%s-4", m.game.Name) + expectedPeers = 0 } + var wg sync.WaitGroup + + if m.gameToRestore != nil { + expectedPeers = 0 + } + + wg.Add(expectedPeers) + if len(ip) == 2 { localIP := ip[0] localPort, _ := strconv.ParseInt(ip[1], 10, 32) logger, _ := logger.GetLogger() - network := multiplayer.NewGameNetwork(localID, fmt.Sprintf("%s:%d", localIP, localPort), p2p.DefaultHandshake, p2p.DefaultHandshake, logger) + + handshakeCounter := 0 + network := multiplayer.NewGameNetwork(localID, fmt.Sprintf("%s:%d", localIP, localPort), func(conn net.Conn) error { + handshakeCounter++ + if handshakeCounter <= expectedPeers && expectedPeers > 0 { + wg.Done() + } + return nil + }, p2p.DefaultHandshake, logger) + + wg.Wait() return m, SwitchModelCmd(NewGameModel(m.width, m.height+1, m.game.ID, network, m.gameToRestore != nil)) } @@ -96,7 +159,7 @@ func (m *PlayModel) handleGamesResponse(msg []database.Game) (tea.Model, tea.Cmd return m, nil } -func (m *PlayModel) newGameCallback() tea.Cmd { +func (m *PlayModel) newGameCallback(gameType database.GameType) tea.Cmd { return func() tea.Msg { // Get authorization token authorization, err := getAuthorizationToken() @@ -114,7 +177,8 @@ func (m *PlayModel) newGameCallback() tea.Cmd { // Prepare request payload payload, err := json.Marshal(map[string]string{ - "ip": fmt.Sprintf("%s:%d", ip, port), + "ip": fmt.Sprintf("%s:%d", ip, port), + "type": string(gameType), }) if err != nil { return playResponse{Error: err.Error()} @@ -140,6 +204,7 @@ func (m *PlayModel) newGameCallback() tea.Cmd { // Decode successful response var response struct { Name string `json:"name"` + Type string `json:"type"` ID int `json:"id"` Error string `json:"error"` } @@ -147,7 +212,7 @@ func (m *PlayModel) newGameCallback() tea.Cmd { return playResponse{Error: fmt.Sprintf("Error decoding JSON: %v", err)} } - return playResponse{Ok: responseOk{Name: response.Name, GameID: response.ID, IP: ip, Port: port}} + return playResponse{Ok: responseOk{Name: response.Name, Type: response.Type, GameID: response.ID, IP: ip, Port: port}} } } @@ -169,8 +234,8 @@ func (m PlayModel) enterGame() tea.Cmd { // Prepare request payload payload, err := json.Marshal(map[string]string{ - "ip": fmt.Sprintf("%s:%d", ip, port), "name": m.namePrompt.Value(), + "ip": fmt.Sprintf("%s:%d", ip, port), }) if err != nil { return playResponse{Error: err.Error()} diff --git a/pkg/ui/views/play_keymap.go b/pkg/ui/views/play_keymap.go index 24d5307..9d2f014 100644 --- a/pkg/ui/views/play_keymap.go +++ b/pkg/ui/views/play_keymap.go @@ -5,6 +5,7 @@ import ( "fmt" "strconv" + "github.com/boozec/rahanna/internal/api/database" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -21,13 +22,14 @@ const ( // Keyboard controls type playKeyMap struct { - EnterNewGame key.Binding - StartNewGame key.Binding - RestoreGame key.Binding - GoLogout key.Binding - NextPage key.Binding - PrevPage key.Binding - Exit key.Binding + EnterNewGame key.Binding + StartNewSingleGame key.Binding + StartNewPairGame key.Binding + RestoreGame key.Binding + GoLogout key.Binding + NextPage key.Binding + PrevPage key.Binding + Exit key.Binding } // Default key bindings for the play model @@ -36,9 +38,13 @@ var defaultPlayKeyMap = playKeyMap{ key.WithKeys("alt+E", "alt+e"), key.WithHelp("Alt+E", "Enter a play using code"), ), - StartNewGame: key.NewBinding( + StartNewSingleGame: key.NewBinding( key.WithKeys("alt+s", "alt+S"), - key.WithHelp("Alt+S", "Start a new play"), + key.WithHelp("Alt+S", "Start a new single play"), + ), + StartNewPairGame: key.NewBinding( + key.WithKeys("alt+p", "alt+P"), + key.WithHelp("Alt+P", "Start a new pair play"), ), RestoreGame: key.NewBinding( key.WithKeys("0", "1", "2", "3", "4", "5", "6", "7", "8", "9"), @@ -72,12 +78,22 @@ func (m PlayModel) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, cmd } - case key.Matches(msg, m.keys.StartNewGame): + case key.Matches(msg, m.keys.StartNewSingleGame): + if m.page == LandingPage { + m.page = StartGamePage + if !m.isLoading { + m.isLoading = true + return m, m.newGameCallback(database.SingleGameType) + } + + return m, cmd + } + case key.Matches(msg, m.keys.StartNewPairGame): if m.page == LandingPage { m.page = StartGamePage if !m.isLoading { m.isLoading = true - return m, m.newGameCallback() + return m, m.newGameCallback(database.PairGameType) } return m, cmd @@ -138,9 +154,13 @@ func (m PlayModel) renderNavigationButtons() string { altCodeStyle.Render(m.keys.RestoreGame.Help().Key), m.keys.RestoreGame.Help().Desc) - startKey := fmt.Sprintf("%s %s", - altCodeStyle.Render(m.keys.StartNewGame.Help().Key), - m.keys.StartNewGame.Help().Desc) + startSingleKey := fmt.Sprintf("%s %s", + altCodeStyle.Render(m.keys.StartNewSingleGame.Help().Key), + m.keys.StartNewSingleGame.Help().Desc) + + startPairKey := fmt.Sprintf("%s %s", + altCodeStyle.Render(m.keys.StartNewPairGame.Help().Key), + m.keys.StartNewPairGame.Help().Desc) nextPageKey := fmt.Sprintf("%s %s", altCodeStyle.Render(m.keys.NextPage.Help().Key), @@ -153,7 +173,8 @@ func (m PlayModel) renderNavigationButtons() string { return lipgloss.JoinVertical( lipgloss.Left, enterKey, - startKey, + startSingleKey, + startPairKey, restoreKey, lipgloss.JoinHorizontal(lipgloss.Left, prevPageKey, " | ", nextPageKey), logoutKey, diff --git a/pkg/ui/views/play_util.go b/pkg/ui/views/play_util.go index b2f70b5..14f462a 100644 --- a/pkg/ui/views/play_util.go +++ b/pkg/ui/views/play_util.go @@ -45,13 +45,13 @@ func formatGamesForPage(userID int, games []database.Game, altCodeStyle lipgloss if game.Outcome != "*" { if len(game.Outcome) >= 2 { if game.Outcome[0:2] == "1-" { - if game.Player1.ID == userID { + if game.Player1.ID == userID || game.Player3 != nil && game.Player3.ID == userID { icon = winIcon } else { icon = loseIcon } } else if game.Outcome[0:2] == "0-" { - if game.Player2 != nil && game.Player2.ID == userID { + if game.Player2 != nil && game.Player2.ID == userID || game.Player4 != nil && game.Player4.ID == userID { icon = winIcon } else { icon = loseIcon |