summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSanto Cariotti <santo@dcariotti.me>2025-04-24 13:53:54 +0200
committerSanto Cariotti <santo@dcariotti.me>2025-04-24 13:53:54 +0200
commitb35829071ab0d0f3479021eac151b90e49a2fca5 (patch)
tree0e2af88ed2732e2211ffbf5689488e010174783a
parent4ae96a216eb50ccec7712fa9ed0d4dc8d9950f68 (diff)
Play co-op 2 vs 2
-rw-r--r--internal/api/database/models.go14
-rw-r--r--internal/api/handlers/handlers.go97
-rw-r--r--pkg/p2p/network.go10
-rw-r--r--pkg/ui/multiplayer/multiplayer.go34
-rw-r--r--pkg/ui/views/game.go30
-rw-r--r--pkg/ui/views/game_api.go36
-rw-r--r--pkg/ui/views/game_keymap.go4
-rw-r--r--pkg/ui/views/game_moves.go11
-rw-r--r--pkg/ui/views/game_restore.go39
-rw-r--r--pkg/ui/views/game_util.go18
-rw-r--r--pkg/ui/views/play_api.go93
-rw-r--r--pkg/ui/views/play_keymap.go51
-rw-r--r--pkg/ui/views/play_util.go4
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