summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSanto Cariotti <santo@dcariotti.me>2025-04-18 21:25:32 +0200
committerSanto Cariotti <santo@dcariotti.me>2025-04-18 21:25:32 +0200
commit42d68aa99d59339dbdf928a54c28242635728daa (patch)
tree98dadfd64a0fc05d1fb6f6ddbc9a3e8963fbf1dd
parent7c5a6176b27b6b0c0c3ef8a4aedbdec871391a80 (diff)
Restore a game
-rw-r--r--internal/api/database/models.go23
-rw-r--r--internal/api/handlers/handlers.go31
-rw-r--r--pkg/p2p/network.go47
-rw-r--r--pkg/ui/multiplayer/multiplayer.go19
-rw-r--r--pkg/ui/views/game.go15
-rw-r--r--pkg/ui/views/game_api.go14
-rw-r--r--pkg/ui/views/game_moves.go11
-rw-r--r--pkg/ui/views/game_restore.go54
-rw-r--r--pkg/ui/views/play.go3
-rw-r--r--pkg/ui/views/play_api.go22
-rw-r--r--pkg/ui/views/play_keymap.go31
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,