diff --git a/plugins/main.go b/plugins/main.go index af1cf87..3b60cea 100644 --- a/plugins/main.go +++ b/plugins/main.go @@ -30,6 +30,10 @@ func InitModule( logger.Error("Failed to register RPC: %v", err) return err } + if err := initializer.RegisterMatch("tictactoe", NewMatch); err != nil { + logger.Error("Failed to register RPC: %v", err) + return err + } logger.Info("Go module loaded successfully!") return nil diff --git a/plugins/match.go b/plugins/match.go new file mode 100644 index 0000000..d5d0378 --- /dev/null +++ b/plugins/match.go @@ -0,0 +1,286 @@ +package main + +import ( + "context" + "database/sql" + "encoding/json" + + "github.com/heroiclabs/nakama-common/runtime" +) + +const ( + OpMove int64 = 1 + OpState int64 = 2 +) + +// Server-side game state +type MatchState struct { + Board [3][3]string `json:"board"` + Players []string `json:"players"` + Turn int `json:"turn"` // index in Players + Winner string `json:"winner"` // "X", "O", "draw", "forfeit" + GameOver bool `json:"game_over"` // true when finished +} + +// Struct that implements runtime.Match +type TicTacToeMatch struct{} + +// Factory for RegisterMatch +func NewMatch( + ctx context.Context, + logger runtime.Logger, + db *sql.DB, + nk runtime.NakamaModule, +) (runtime.Match, error) { + logger.Info("TicTacToe NewMatch factory called") + return &TicTacToeMatch{}, nil +} + +// ---- MatchInit ---- +// Return initial state, tick rate (ticks/sec), and label +func (m *TicTacToeMatch) MatchInit( + ctx context.Context, + logger runtime.Logger, + db *sql.DB, + nk runtime.NakamaModule, + params map[string]interface{}, +) (interface{}, int, string) { + + state := &MatchState{ + Board: [3][3]string{}, + Players: []string{}, + Turn: 0, + Winner: "", + GameOver: false, + } + + tickRate := 5 // 5 ticks per second (~200ms) + label := "tictactoe" + + logger.Info("TicTacToe MatchInit: tickRate=%v label=%s", tickRate, label) + return state, tickRate, label +} + +// ---- MatchJoinAttempt ---- +func (m *TicTacToeMatch) MatchJoinAttempt( + ctx context.Context, + logger runtime.Logger, + db *sql.DB, + nk runtime.NakamaModule, + dispatcher runtime.MatchDispatcher, + tick int64, + state interface{}, + presence runtime.Presence, + metadata map[string]string, +) (interface{}, bool, string) { + + s := state.(*MatchState) + + if len(s.Players) >= 2 { + return s, false, "match full" + } + + return s, true, "" +} + +// ---- MatchJoin ---- +func (m *TicTacToeMatch) MatchJoin( + ctx context.Context, + logger runtime.Logger, + db *sql.DB, + nk runtime.NakamaModule, + dispatcher runtime.MatchDispatcher, + tick int64, + state interface{}, + presences []runtime.Presence, +) interface{} { + + s := state.(*MatchState) + + for _, p := range presences { + userID := p.GetUserId() + // avoid duplicates + if indexOf(s.Players, userID) == -1 { + s.Players = append(s.Players, userID) + } + } + + logger.Info("MatchJoin: now %d players", len(s.Players)) + return s +} + +// ---- MatchLeave ---- +func (m *TicTacToeMatch) MatchLeave( + ctx context.Context, + logger runtime.Logger, + db *sql.DB, + nk runtime.NakamaModule, + dispatcher runtime.MatchDispatcher, + tick int64, + state interface{}, + presences []runtime.Presence, +) interface{} { + + s := state.(*MatchState) + + // End the game if anyone leaves + if !s.GameOver { + s.GameOver = true + s.Winner = "forfeit" + logger.Info("MatchLeave: game ended by forfeit") + } + + return s +} + +// ---- MatchLoop ---- +func (m *TicTacToeMatch) MatchLoop( + ctx context.Context, + logger runtime.Logger, + db *sql.DB, + nk runtime.NakamaModule, + dispatcher runtime.MatchDispatcher, + tick int64, + state interface{}, + messages []runtime.MatchData, +) interface{} { + + s := state.(*MatchState) + + if s.GameOver { + return s + } + + for _, msg := range messages { + if msg.GetOpCode() != OpMove { + continue + } + + var move struct { + Row int `json:"row"` + Col int `json:"col"` + } + + if err := json.Unmarshal(msg.GetData(), &move); err != nil { + logger.Warn("Invalid move payload: %v", err) + continue + } + + playerID := msg.GetUserId() + playerIdx := indexOf(s.Players, playerID) + if playerIdx != s.Turn { + // not your turn + continue + } + + if move.Row < 0 || move.Row > 2 || move.Col < 0 || move.Col > 2 { + continue + } + + if s.Board[move.Row][move.Col] != "" { + continue + } + + symbols := []string{"X", "O"} + if playerIdx < 0 || playerIdx >= len(symbols) { + continue + } + s.Board[move.Row][move.Col] = symbols[playerIdx] + + if winner := checkWinner(s.Board); winner != "" { + s.Winner = winner + s.GameOver = true + } else if fullBoard(s.Board) { + s.Winner = "draw" + s.GameOver = true + } else { + s.Turn = 1 - s.Turn + } + } + + // Broadcast updated state to everyone + stateJSON, _ := json.Marshal(s) + if err := dispatcher.BroadcastMessage(OpState, stateJSON, nil, nil, true); err != nil { + logger.Error("BroadcastMessage failed: %v", err) + } + + return s +} + +// ---- MatchTerminate ---- +func (m *TicTacToeMatch) MatchTerminate( + ctx context.Context, + logger runtime.Logger, + db *sql.DB, + nk runtime.NakamaModule, + dispatcher runtime.MatchDispatcher, + tick int64, + state interface{}, + graceSeconds int, +) interface{} { + + logger.Info("MatchTerminate: grace=%d", graceSeconds) + return state +} + +// ---- MatchSignal (not used, but required) ---- +func (m *TicTacToeMatch) MatchSignal( + ctx context.Context, + logger runtime.Logger, + db *sql.DB, + nk runtime.NakamaModule, + dispatcher runtime.MatchDispatcher, + tick int64, + state interface{}, + data string, +) (interface{}, string) { + + logger.Info("MatchSignal: %s", data) + // no-op; just echo back + return state, "" +} + +// ---- Helpers ---- +func indexOf(arr []string, v string) int { + for i, s := range arr { + if s == v { + return i + } + } + return -1 +} + +func checkWinner(b [3][3]string) string { + lines := [][][2]int{ + {{0, 0}, {0, 1}, {0, 2}}, + {{1, 0}, {1, 1}, {1, 2}}, + {{2, 0}, {2, 1}, {2, 2}}, + {{0, 0}, {1, 0}, {2, 0}}, + {{0, 1}, {1, 1}, {2, 1}}, + {{0, 2}, {1, 2}, {2, 2}}, + {{0, 0}, {1, 1}, {2, 2}}, + {{0, 2}, {1, 1}, {2, 0}}, + } + + for _, l := range lines { + a, b2, c := l[0], l[1], l[2] + if b[a[0]][a[1]] != "" && + b[a[0]][a[1]] == b[b2[0]][b2[1]] && + b[a[0]][a[1]] == b[c[0]][c[1]] { + return b[a[0]][a[1]] + } + } + + return "" +} + +func fullBoard(b [3][3]string) bool { + for _, row := range b { + for _, v := range row { + if v == "" { + return false + } + } + } + return true +}