diff options
author | Santo Cariotti <santo@dcariotti.me> | 2025-04-18 21:25:32 +0200 |
---|---|---|
committer | Santo Cariotti <santo@dcariotti.me> | 2025-04-18 21:25:32 +0200 |
commit | 42d68aa99d59339dbdf928a54c28242635728daa (patch) | |
tree | 98dadfd64a0fc05d1fb6f6ddbc9a3e8963fbf1dd | |
parent | 7c5a6176b27b6b0c0c3ef8a4aedbdec871391a80 (diff) |
Restore a game
-rw-r--r-- | internal/api/database/models.go | 23 | ||||
-rw-r--r-- | internal/api/handlers/handlers.go | 31 | ||||
-rw-r--r-- | pkg/p2p/network.go | 47 | ||||
-rw-r--r-- | pkg/ui/multiplayer/multiplayer.go | 19 | ||||
-rw-r--r-- | pkg/ui/views/game.go | 15 | ||||
-rw-r--r-- | pkg/ui/views/game_api.go | 14 | ||||
-rw-r--r-- | pkg/ui/views/game_moves.go | 11 | ||||
-rw-r--r-- | pkg/ui/views/game_restore.go | 54 | ||||
-rw-r--r-- | pkg/ui/views/play.go | 3 | ||||
-rw-r--r-- | pkg/ui/views/play_api.go | 22 | ||||
-rw-r--r-- | pkg/ui/views/play_keymap.go | 31 |
11 files changed, 207 insertions, 63 deletions
diff --git a/internal/api/database/models.go b/internal/api/database/models.go index cd0d12d..4acbca5 100644 --- a/internal/api/database/models.go +++ b/internal/api/database/models.go @@ -11,15 +11,16 @@ type User struct { } type Game struct { - ID int `json:"id"` - Player1ID int `json:"-"` - Player1 User `gorm:"foreignKey:Player1ID" json:"player1"` - Player2ID *int `json:"-"` - Player2 *User `gorm:"foreignKey:Player2ID;null" json:"player2"` - Name string `json:"name"` - IP1 string `json:"ip1"` - IP2 string `json:"ip2"` - Outcome string `json:"outcome"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int `json:"id"` + Player1ID int `json:"-"` + Player1 User `gorm:"foreignKey:Player1ID" json:"player1"` + Player2ID *int `json:"-"` + Player2 *User `gorm:"foreignKey:Player2ID;null" json:"player2"` + Name string `json:"name"` + IP1 string `json:"ip1"` + IP2 string `json:"ip2"` + Outcome string `json:"outcome"` + LastPlayer int `json:"last_player"` // Last player entered in game + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } diff --git a/internal/api/handlers/handlers.go b/internal/api/handlers/handlers.go index c8b7425..db42f77 100644 --- a/internal/api/handlers/handlers.go +++ b/internal/api/handlers/handlers.go @@ -118,12 +118,13 @@ func NewPlay(w http.ResponseWriter, r *http.Request) { name := p2p.NewSession() play := database.Game{ - Player1ID: claims.UserID, - Player2ID: nil, - Name: name, - IP1: payload.IP, - IP2: "", - Outcome: "*", + Player1ID: claims.UserID, + Player2ID: nil, + Name: name, + IP1: payload.IP, + IP2: "", + Outcome: "*", + LastPlayer: 1, } if result := db.Create(&play); result.Error != nil { @@ -158,13 +159,25 @@ func EnterGame(w http.ResponseWriter, r *http.Request) { var game database.Game - if result := db.Where("name = ? AND player2_id IS NULL", payload.Name).First(&game); result.Error != nil { + if result := db.Where("name = ?", payload.Name).First(&game); result.Error != nil { JsonError(&w, result.Error.Error()) return } - game.Player2ID = &claims.UserID - game.IP2 = payload.IP + 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 + } else { + game.IP2 = payload.IP + game.LastPlayer = 2 + } + } + game.UpdatedAt = time.Now() if err := db.Save(&game).Error; err != nil { diff --git a/pkg/p2p/network.go b/pkg/p2p/network.go index 13c99b0..a609832 100644 --- a/pkg/p2p/network.go +++ b/pkg/p2p/network.go @@ -40,11 +40,12 @@ func DefaultHandshake(conn net.Conn) error { // Network options to define on new `TCPNetwork` type TCPNetworkOpts struct { - ListenAddr string - RetryDelay time.Duration - HandshakeFn NetworkHandshakeFunc - OnReceiveFn NetworkMessageReceiveFunc - Logger *zap.Logger + ListenAddr string + RetryDelay time.Duration + HandshakeFn NetworkHandshakeFunc + FirstHandshakeFn NetworkHandshakeFunc + OnReceiveFn NetworkMessageReceiveFunc + Logger *zap.Logger } // PeerConnection holds the connection and address of a peer. @@ -58,10 +59,11 @@ type TCPNetwork struct { sync.Mutex TCPNetworkOpts - id NetworkID - listener net.Listener - connections map[NetworkID]PeerConnection - isClosed bool + id NetworkID + listener net.Listener + connections map[NetworkID]PeerConnection + isClosed bool + handshakesCount uint } // Initiliaze a new TCP network @@ -100,11 +102,10 @@ func (n *TCPNetwork) Close() error { // Add a new peer connection to the local peer func (n *TCPNetwork) AddPeer(remoteID NetworkID, addr string) { n.Lock() - if _, exists := n.connections[remoteID]; !exists { - n.connections[remoteID] = PeerConnection{Address: addr} - go n.retryConnect(remoteID, addr) - } + n.connections[remoteID] = PeerConnection{Address: addr} n.Unlock() + + go n.retryConnect(remoteID, addr) } // Send methods is used to send a message to a specified remote peer @@ -140,7 +141,6 @@ func (n *TCPNetwork) Send(remoteID NetworkID, messageType []byte, payload []byte if err != nil { n.Logger.Sugar().Errorf("failed to send message to %s: %v. Reconnecting...", remoteID, err) n.removeConnection(remoteID) - go n.retryConnect(remoteID, peerConn.Address) 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)) @@ -187,6 +187,7 @@ func (n *TCPNetwork) handleConnection(conn net.Conn) { remoteID := NetworkID(remoteAddr) n.Lock() + n.handshakesCount++ n.connections[remoteID] = PeerConnection{Conn: conn, Address: remoteAddr} n.Unlock() @@ -199,6 +200,16 @@ func (n *TCPNetwork) handleConnection(conn net.Conn) { } } + if n.FirstHandshakeFn != nil && n.handshakesCount == 1 { + if err := n.FirstHandshakeFn(conn); err != nil { + n.Logger.Sugar().Errorf("error on first handshake with %s: %v\n", remoteAddr, err) + conn.Close() + n.removeConnection(remoteID) + return + } + + } + n.Logger.Sugar().Infof("connected to remote peer %s (%s)\n", remoteID, remoteAddr) n.listenForMessages(conn, remoteID) @@ -228,14 +239,6 @@ func (n *TCPNetwork) listenForMessages(conn net.Conn, remoteID NetworkID) { n.Logger.Sugar().Warnf("error reading from connection %s: %v", remoteAddr, err) } - n.Lock() - peerConn, exists := n.connections[remoteID] - n.Unlock() - if exists { - go n.retryConnect(remoteID, peerConn.Address) - } else { - n.Logger.Sugar().Warnf("no address to reconnect to peer %s", remoteID) - } return } diff --git a/pkg/ui/multiplayer/multiplayer.go b/pkg/ui/multiplayer/multiplayer.go index 98ccfb4..ee9d452 100644 --- a/pkg/ui/multiplayer/multiplayer.go +++ b/pkg/ui/multiplayer/multiplayer.go @@ -11,8 +11,10 @@ import ( type MoveType string const ( - AbandonGameMessage MoveType = "abandon" - MoveGameMessage MoveType = "new-move" + AbandonGameMessage MoveType = "abandon" + RestoreGameMessage MoveType = "restore" + RestoreAckGameMessage MoveType = "restore-ack" + MoveGameMessage MoveType = "new-move" ) type GameMove struct { @@ -26,13 +28,14 @@ type GameNetwork struct { peer p2p.NetworkID } -// Wrapper to a `TCPNetwork` -func NewGameNetwork(localID string, address string, onHandshake p2p.NetworkHandshakeFunc, logger *zap.Logger) *GameNetwork { +// Wrapper to a `TCPNetwork`RestoreAck +func NewGameNetwork(localID string, address string, onHandshake p2p.NetworkHandshakeFunc, onFirstHandshake p2p.NetworkHandshakeFunc, logger *zap.Logger) *GameNetwork { opts := p2p.TCPNetworkOpts{ - ListenAddr: address, - HandshakeFn: onHandshake, - RetryDelay: time.Second * 2, - Logger: logger, + ListenAddr: address, + HandshakeFn: onHandshake, + FirstHandshakeFn: onFirstHandshake, + RetryDelay: time.Second * 2, + Logger: logger, } server := p2p.NewTCPNetwork(p2p.NetworkID(localID), opts) return &GameNetwork{ diff --git a/pkg/ui/views/game.go b/pkg/ui/views/game.go index 3742101..85e879c 100644 --- a/pkg/ui/views/game.go +++ b/pkg/ui/views/game.go @@ -24,6 +24,7 @@ type GameModel struct { keys gameKeyMap // Game state + restore bool currentGameID int game *database.Game network *multiplayer.GameNetwork @@ -34,7 +35,7 @@ type GameModel struct { } // NewGameModel creates a new GameModel. -func NewGameModel(width, height int, currentGameID int, network *multiplayer.GameNetwork) GameModel { +func NewGameModel(width, height int, currentGameID int, network *multiplayer.GameNetwork, restore bool) GameModel { listDelegate := list.NewDefaultDelegate() listDelegate.ShowDescription = false listDelegate.Styles.SelectedTitle = lipgloss.NewStyle(). @@ -59,6 +60,7 @@ func NewGameModel(width, height int, currentGameID int, network *multiplayer.Gam incomingMoves: make(chan multiplayer.GameMove), turn: 0, availableMovesList: moveList, + restore: restore, } } @@ -89,6 +91,13 @@ func (m GameModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case ChessMoveMsg: m, cmd = m.handleChessMoveMsg(msg) cmds = append(cmds, cmd) + case SendRestoreMsg: + cmd = m.handleSendRestoreMsg() + 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()) @@ -104,6 +113,10 @@ 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.restore = false + case error: m.err = msg } diff --git a/pkg/ui/views/game_api.go b/pkg/ui/views/game_api.go index e2ef280..3c649f9 100644 --- a/pkg/ui/views/game_api.go +++ b/pkg/ui/views/game_api.go @@ -21,17 +21,21 @@ func (m GameModel) handleDatabaseGameMsg(msg database.Game) (GameModel, tea.Cmd) if m.network.Me() == m.playerPeer(1) { if m.game.IP2 != "" { remote := m.game.IP2 - go m.network.AddPeer(m.playerPeer(2), remote) + m.network.AddPeer(m.playerPeer(2), remote) } } else { if m.game.IP1 != "" { remote := m.game.IP1 - go m.network.AddPeer(m.playerPeer(1), remote) + m.network.AddPeer(m.playerPeer(1), remote) } } } - if m.game.Outcome != chess.NoOutcome.String() { + if m.restore { + cmd = func() tea.Msg { + return RestoreGameMsg{} + } + } else if m.game.Outcome != chess.NoOutcome.String() { cmd = func() tea.Msg { return EndGameMsg{} } @@ -62,6 +66,8 @@ func (m *GameModel) getGame() tea.Cmd { return nil } + m.game = &game + return game } } @@ -70,6 +76,8 @@ type EndGameMsg struct { abandoned bool } +type RestoreGameMsg struct{} + func (m *GameModel) endGame(outcome string) tea.Cmd { return func() tea.Msg { var game database.Game diff --git a/pkg/ui/views/game_moves.go b/pkg/ui/views/game_moves.go index ea1fa02..32f440f 100644 --- a/pkg/ui/views/game_moves.go +++ b/pkg/ui/views/game_moves.go @@ -35,10 +35,17 @@ func (m *GameModel) getMoves() tea.Cmd { return func() tea.Msg { move := <-m.incomingMoves - if multiplayer.MoveType(string(move.Type)) == multiplayer.AbandonGameMessage { + + switch multiplayer.MoveType(string(move.Type)) { + case multiplayer.AbandonGameMessage: return EndGameMsg{abandoned: true} + case multiplayer.RestoreGameMessage: + return SendRestoreMsg{} + case multiplayer.RestoreAckGameMessage: + return RestoreMoves(string(move.Payload)) + default: + return ChessMoveMsg(string(move.Payload)) } - return ChessMoveMsg(string(move.Payload)) } } diff --git a/pkg/ui/views/game_restore.go b/pkg/ui/views/game_restore.go new file mode 100644 index 0000000..b2e647c --- /dev/null +++ b/pkg/ui/views/game_restore.go @@ -0,0 +1,54 @@ +package views + +import ( + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +// Catch for `RestoreGameMessage` message from multiplayer +type SendRestoreMsg struct{} + +// 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 { + if m.network.Me() == m.playerPeer(1) { + _ = m.getGame()() + remote := m.game.IP2 + m.network.AddPeer(m.playerPeer(2), remote) + } else { + _ = m.getGame()() + remote := m.game.IP1 + m.network.AddPeer(m.playerPeer(1), remote) + } + + // FIXME: add a loading modal + time.Sleep(2 * time.Second) + + payload := "" + + for _, move := range m.chessGame.Moves() { + payload += fmt.Sprintf("%s\n", move.String()) + } + + m.err = m.network.Send([]byte("restore-ack"), []byte(payload)) + + return nil +} + +// Restores the moves for `m.chessGame` +func (m *GameModel) handleRestoreMoves(msg RestoreMoves) tea.Cmd { + moves := strings.Split(string(msg), "\n") + for _, move := range moves { + 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/play.go b/pkg/ui/views/play.go index 20e2ebb..3d75a90 100644 --- a/pkg/ui/views/play.go +++ b/pkg/ui/views/play.go @@ -46,6 +46,7 @@ type PlayModel struct { game *database.Game network *multiplayer.GameNetwork games []database.Game + gameToRestore *database.Game } // NewPlayModel creates a new play model instance @@ -89,7 +90,7 @@ func (m PlayModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.userID, m.err = getUserID() return m.handleGamesResponse(msg) case StartGameMsg: - return m, SwitchModelCmd(NewGameModel(m.width, m.height+1, m.currentGameId, m.network)) + return m, SwitchModelCmd(NewGameModel(m.width, m.height+1, m.currentGameId, m.network, m.gameToRestore != nil)) case error: return m.handleError(msg) } diff --git a/pkg/ui/views/play_api.go b/pkg/ui/views/play_api.go index c098930..bf9b08a 100644 --- a/pkg/ui/views/play_api.go +++ b/pkg/ui/views/play_api.go @@ -46,8 +46,8 @@ func (m *PlayModel) handlePlayResponse(msg playResponse) (tea.Model, tea.Cmd) { 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), func(net.Conn) error { - callbackCompleted <- true + 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) return nil }, logger) @@ -59,19 +59,31 @@ func (m *PlayModel) handlePlayResponse(msg playResponse) (tea.Model, tea.Cmd) { return m, nil } + func (m *PlayModel) handleGameResponse(msg database.Game) (tea.Model, tea.Cmd) { m.isLoading = false m.game = &msg m.err = nil - ip := strings.Split(m.game.IP2, ":") + + var ip []string + var localID string + + if m.game.LastPlayer == 2 { + ip = strings.Split(m.game.IP2, ":") + localID = fmt.Sprintf("%s-2", m.game.Name) + } else { + ip = strings.Split(m.game.IP1, ":") + localID = fmt.Sprintf("%s-1", m.game.Name) + } + if len(ip) == 2 { localIP := ip[0] localPort, _ := strconv.ParseInt(ip[1], 10, 32) logger, _ := logger.GetLogger() - network := multiplayer.NewGameNetwork(fmt.Sprintf("%s-2", m.game.Name), fmt.Sprintf("%s:%d", localIP, localPort), p2p.DefaultHandshake, logger) + network := multiplayer.NewGameNetwork(localID, fmt.Sprintf("%s:%d", localIP, localPort), p2p.DefaultHandshake, p2p.DefaultHandshake, logger) - return m, SwitchModelCmd(NewGameModel(m.width, m.height+1, m.game.ID, network)) + return m, SwitchModelCmd(NewGameModel(m.width, m.height+1, m.game.ID, network, m.gameToRestore != nil)) } return m, nil } diff --git a/pkg/ui/views/play_keymap.go b/pkg/ui/views/play_keymap.go index d5ccc9c..200f427 100644 --- a/pkg/ui/views/play_keymap.go +++ b/pkg/ui/views/play_keymap.go @@ -1,11 +1,14 @@ package views import ( + "errors" "fmt" + "strconv" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/notnil/chess" ) type PlayModelPage int @@ -20,6 +23,7 @@ const ( type playKeyMap struct { EnterNewGame key.Binding StartNewGame key.Binding + RestoreGame key.Binding GoLogout key.Binding Quit key.Binding NextPage key.Binding @@ -36,6 +40,10 @@ var defaultPlayKeyMap = playKeyMap{ key.WithKeys("alt+s", "alt+S"), key.WithHelp("Alt+S", "Start a new play"), ), + RestoreGame: key.NewBinding( + key.WithKeys("0", "1", "2", "3", "4", "5", "6", "7", "8", "9"), + key.WithHelp("[0-9]", "Restore a game"), + ), GoLogout: key.NewBinding( key.WithKeys("alt+Q", "alt+q"), key.WithHelp("Alt+Q", "Logout"), @@ -60,7 +68,6 @@ func (m PlayModel) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, m.keys.EnterNewGame): m.page = InsertCodePage - m.namePrompt, cmd = m.namePrompt.Update("suca") return m, cmd case key.Matches(msg, m.keys.StartNewGame): @@ -70,6 +77,23 @@ func (m PlayModel) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, m.newGameCallback() } + case key.Matches(msg, m.keys.RestoreGame): + idx, err := strconv.Atoi(msg.String()) + m.err = err + if err == nil { + gameIndex := m.paginator.Page*m.paginator.PerPage + idx + if gameIndex < len(m.games) { + m.gameToRestore = &m.games[gameIndex] + if m.gameToRestore.Outcome != chess.NoOutcome.String() { + m.err = errors.New("this game is closed") + } else { + m.err = nil + m.namePrompt.SetValue(m.gameToRestore.Name) + return m, m.enterGame() + } + } + } + case key.Matches(msg, m.keys.GoLogout): return m, logout(m.width, m.height+1) @@ -107,6 +131,10 @@ func (m PlayModel) renderNavigationButtons() string { altCodeStyle.Render(m.keys.EnterNewGame.Help().Key), m.keys.EnterNewGame.Help().Desc) + restoreKey := fmt.Sprintf("%s %s", + 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) @@ -123,6 +151,7 @@ func (m PlayModel) renderNavigationButtons() string { lipgloss.Left, enterKey, startKey, + restoreKey, lipgloss.JoinHorizontal(lipgloss.Left, prevPageKey, " | ", nextPageKey), logoutKey, quitKey, |