From eeb0a8175fbc307f98352b141668436bf56dcd7c Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Mon, 1 Dec 2025 15:28:54 +0530 Subject: [PATCH] feat: refactor Nakama plugin into generic multi-game match engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Highlights - Introduced generic match engine (`generic_match.go`) implementing dynamic GameRules-based runtime. - Added modular structure under `/plugins`: - /plugins/game → GameRules interface + TicTacToe + Battleship rule sets - /plugins/structs → Board, Player, MatchState generic structs - /plugins/modules → matchmaking + RPC handlers + match engine - Migrated TicTacToe logic into reusable rule implementation. - Added Battleship game support using same engine. - Updated matchmaking to accept { game, mode } for multi-game routing. - Updated UI contract: clients must send `game` (and optional `mode`) when joining matchmaking. - Removed hardcoded TicTacToe match registration. - Registered a single “generic” authoritative match with ruleset registry. - Normalized imports under local dev module path. - Ensured MatchState and Board are now generic and reusable across games. - Added strict requirement for `game` metadata in match flow (error if missing). - Cleaned initial state creation into MatchInit with flexible board dimensions. - Improved MatchLeave for proper forfeit handling through GameRules. ### Result The server now supports an unlimited number of turn-based board games via swappable rulesets while keeping a single authoritative Nakama match loop. --- go.mod | 4 +- plugins/common/game.go | 27 ++ plugins/games/battleship.go | 178 +++++++++++++ plugins/games/tic_tac_toe.go | 143 +++++++++++ plugins/main.go | 110 +++++---- plugins/match.go | 378 ---------------------------- plugins/matchmaking.go | 86 ------- plugins/modules/match.go | 439 +++++++++++++++++++++++++++++++++ plugins/modules/matchmaking.go | 101 ++++++++ plugins/structs/board.go | 51 ++++ plugins/structs/match_state.go | 10 + plugins/structs/player.go | 19 ++ 12 files changed, 1038 insertions(+), 508 deletions(-) create mode 100644 plugins/common/game.go create mode 100644 plugins/games/battleship.go create mode 100644 plugins/games/tic_tac_toe.go delete mode 100644 plugins/match.go delete mode 100644 plugins/matchmaking.go create mode 100644 plugins/modules/match.go create mode 100644 plugins/modules/matchmaking.go create mode 100644 plugins/structs/board.go create mode 100644 plugins/structs/match_state.go create mode 100644 plugins/structs/player.go diff --git a/go.mod b/go.mod index 0f308b7..1cd66e4 100644 --- a/go.mod +++ b/go.mod @@ -1,7 +1,7 @@ -module git.aetoskia.com/lila-games/tic-tac-toe +module localrepo go 1.21 require github.com/heroiclabs/nakama-common v1.31.0 -require google.golang.org/protobuf v1.31.0 // indirect +require google.golang.org/protobuf v1.31.0 diff --git a/plugins/common/game.go b/plugins/common/game.go new file mode 100644 index 0000000..fd3eda8 --- /dev/null +++ b/plugins/common/game.go @@ -0,0 +1,27 @@ +package game + +import ( + "context" + "encoding/json" + + "github.com/heroiclabs/nakama-common/runtime" +) + +type MovePayload struct { + Data map[string]interface{} `json:"data"` // arbitrary structure per game +} + +// GameRules defines game-specific mechanics. +// You implement this for TicTacToe, Chess, etc. +type GameRules interface { + MaxPlayers() int + + // ApplyMove modifies state, returns (stateChanged, gameOver, winnerIndex) + ApplyMove(state *MatchState, playerIdx int, payload MovePayload) (bool, bool, int) + + // Called when match starts and players are set. + AssignPlayerSymbols(players []*Player) + + // Called when match ends via forfeit. + ForfeitWinner(state *MatchState, leaverIndex int) int +} diff --git a/plugins/games/battleship.go b/plugins/games/battleship.go new file mode 100644 index 0000000..2aa3c64 --- /dev/null +++ b/plugins/games/battleship.go @@ -0,0 +1,178 @@ +package game + +import ( + "fmt" + "localrepo/plugins/structs" +) + +// +// BATTLESHIP RULES IMPLEMENTATION +// +// NOTES: +// - 2 players +// - Each player has 2 boards: +// 1. Their own ship board (state.Board is not reused here) +// 2. Their "shots" board (hits/misses on opponent) +// - We store boards in Player.Metadata as JSON strings +// (simplest method without changing your structs). +// + +// ShipBoard and ShotBoard are encoded inside Metadata: +// +// Metadata["ship_board"] = JSON string of [][]string +// Metadata["shot_board"] = JSON string of [][]string +// + +// ------------------------------ +// Helpers: encode/decode +// ------------------------------ + +func encodeBoard(b [][]string) string { + out := "[" + for i, row := range b { + out += "[" + for j, col := range row { + out += fmt.Sprintf("%q", col) + if j < len(row)-1 { + out += "," + } + } + out += "]" + if i < len(b)-1 { + out += "," + } + } + out += "]" + return out +} + +func decodeBoard(s string) [][]string { + var out [][]string + // should never fail; safe fallback + _ = json.Unmarshal([]byte(s), &out) + return out +} + +// ------------------------------ +// BattleshipRules +// ------------------------------ + +type BattleshipRules struct{} + +func (b *BattleshipRules) MaxPlayers() int { return 2 } + +// ------------------------------ +// Assign player boards +// ------------------------------ +func (b *BattleshipRules) AssignPlayerSymbols(players []*Player) { + // Battleship has no symbols like X/O, + // but we use this hook to initialize per-player boards. + + for _, p := range players { + // 10x10 boards + empty := make([][]string, 10) + for r := range empty { + empty[r] = make([]string, 10) + } + + // ship board → players place ships manually via a "setup" phase + p.Metadata["ship_board"] = encodeBoard(empty) + + // shot board → empty grid that tracks hits/misses + p.Metadata["shot_board"] = encodeBoard(empty) + } +} + +// ------------------------------ +// ValidateMove +// payload.data = { "row": int, "col": int } +// ------------------------------ + +func (b *BattleshipRules) ValidateMove(state *structs.MatchState, playerIdx int, payload MovePayload) bool { + rF, ok1 := payload.Data["row"].(float64) + cF, ok2 := payload.Data["col"].(float64) + if !ok1 || !ok2 { + return false + } + + r := int(rF) + c := int(cF) + + if r < 0 || r > 9 || c < 0 || c > 9 { + return false + } + + // Check if this spot was already shot before + shotBoard := decodeBoard(state.Players[playerIdx].Metadata["shot_board"]) + return shotBoard[r][c] == "" +} + +// ------------------------------ +// ApplyMove +// ------------------------------ +func (b *BattleshipRules) ApplyMove(state *structs.MatchState, playerIdx int, payload MovePayload) { + + attacker := state.Players[playerIdx] + defenderIdx := 1 - playerIdx + defender := state.Players[defenderIdx] + + r := int(payload.Data["row"].(float64)) + c := int(payload.Data["col"].(float64)) + + shotBoard := decodeBoard(attacker.Metadata["shot_board"]) + shipBoard := decodeBoard(defender.Metadata["ship_board"]) + + if shipBoard[r][c] == "S" { + // hit + shotBoard[r][c] = "H" + shipBoard[r][c] = "X" // ship cell destroyed + } else { + // miss + shotBoard[r][c] = "M" + } + + // Save back + attacker.Metadata["shot_board"] = encodeBoard(shotBoard) + defender.Metadata["ship_board"] = encodeBoard(shipBoard) +} + +// ------------------------------ +// CheckGameOver +// ------------------------------ +func (b *BattleshipRules) CheckGameOver(state *structs.MatchState) (bool, int) { + + for i, p := range state.Players { + ships := decodeBoard(p.Metadata["ship_board"]) + + alive := false + for r := range ships { + for c := range ships[r] { + if ships[r][c] == "S" { + alive = true + } + } + } + + if !alive { + // this player has no ships left → opponent wins + return true, 1 - i + } + } + + return false, -1 +} + +// ------------------------------ +// Forfeit Winner +// ------------------------------ +func (b *BattleshipRules) ForfeitWinner(state *structs.MatchState, leaverIndex int) int { + + // If player leaves, opponent automatically wins. + if leaverIndex == 0 { + return 1 + } + if leaverIndex == 1 { + return 0 + } + return -1 +} diff --git a/plugins/games/tic_tac_toe.go b/plugins/games/tic_tac_toe.go new file mode 100644 index 0000000..0cc7a1c --- /dev/null +++ b/plugins/games/tic_tac_toe.go @@ -0,0 +1,143 @@ +package game + +import ( + "errors" + "fmt" + "localrepo/plugins/structs" +) + +// TicTacToeRules implements GameRules for 3x3 Tic Tac Toe. +type TicTacToeRules struct{} + +// ------------------------------- +// GameRules Implementation +// ------------------------------- + +func (t *TicTacToeRules) MaxPlayers() int { + return 2 +} + +// Assign player symbols: X and O +func (t *TicTacToeRules) AssignPlayerSymbols(players []*Player) { + if len(players) < 2 { + return + } + + players[0].Metadata["symbol"] = "X" + players[1].Metadata["symbol"] = "O" +} + +// ValidateMove checks bounds and empty cell. +func (t *TicTacToeRules) ValidateMove(state *structs.MatchState, playerIdx int, payload MovePayload) bool { + + rowVal, ok1 := payload.Data["row"] + colVal, ok2 := payload.Data["col"] + + if !ok1 || !ok2 { + return false + } + + row, ok3 := rowVal.(float64) + col, ok4 := colVal.(float64) + + if !ok3 || !ok4 { + return false + } + + r := int(row) + c := int(col) + + // bounds + if !state.Board.InBounds(r, c) { + return false + } + + // empty? + return state.Board.IsEmpty(r, c) +} + +// ApplyMove writes X or O to the board. +func (t *TicTacToeRules) ApplyMove(state *structs.MatchState, playerIdx int, payload MovePayload) { + + symbol := state.Players[playerIdx].Metadata["symbol"] + + r := int(payload.Data["row"].(float64)) + c := int(payload.Data["col"].(float64)) + + state.Board.Set(r, c, symbol) +} + +// CheckGameOver determines win/draw state. +func (t *TicTacToeRules) CheckGameOver(state *structs.MatchState) (bool, int) { + + winnerSymbol := t.findWinner(state.Board) + + if winnerSymbol != "" { + // find the player with this symbol + for _, p := range state.Players { + if p.Metadata["symbol"] == winnerSymbol { + return true, p.Index + } + } + return true, -1 + } + + if state.Board.Full() { + return true, -1 // draw + } + + return false, -1 +} + +// OnForfeit: whoever leaves loses instantly +func (t *TicTacToeRules) ForfeitWinner(state *structs.MatchState, leaverIndex int) int { + + // If player 0 leaves, player 1 wins. + if leaverIndex == 0 && len(state.Players) > 1 { + return 1 + } + + // If player 1 leaves, player 0 wins. + if leaverIndex == 1 && len(state.Players) > 0 { + return 0 + } + + // Otherwise draw. + return -1 +} + +// ------------------------------- +// Helper: winner detection +// ------------------------------- + +func (t *TicTacToeRules) findWinner(b *structs.Board) string { + + lines := [][][2]int{ + // rows + {{0, 0}, {0, 1}, {0, 2}}, + {{1, 0}, {1, 1}, {1, 2}}, + {{2, 0}, {2, 1}, {2, 2}}, + // cols + {{0, 0}, {1, 0}, {2, 0}}, + {{0, 1}, {1, 1}, {2, 1}}, + {{0, 2}, {1, 2}, {2, 2}}, + // diagonals + {{0, 0}, {1, 1}, {2, 2}}, + {{0, 2}, {1, 1}, {2, 0}}, + } + + for _, line := range lines { + r1, c1 := line[0][0], line[0][1] + r2, c2 := line[1][0], line[1][1] + r3, c3 := line[2][0], line[2][1] + + v1 := b.Get(r1, c1) + if v1 != "" && + v1 == b.Get(r2, c2) && + v1 == b.Get(r3, c3) { + return v1 + } + } + + return "" +} diff --git a/plugins/main.go b/plugins/main.go index 85320bc..aabb8a9 100644 --- a/plugins/main.go +++ b/plugins/main.go @@ -5,61 +5,87 @@ import ( "database/sql" "github.com/heroiclabs/nakama-common/runtime" + + // Adjust these imports to match your project structure + "localrepo/plugins/modules" + "localrepo/plugins/games" ) -// Example RPC -func HelloWorld( +func InitModule( ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, - payload string, -) (string, error) { - logger.Info("HelloWorld RPC called — payload: %s", payload) - return `{"message": "Hello from Go RPC!"}`, nil -} - -// Required module initializer -func InitModule( - ctx context.Context, - logger runtime.Logger, - db *sql.DB, - nk runtime.NakamaModule, - initializer runtime.Initializer, + initializer runtime.Initializer, ) error { + + //-------------------------------------------------------- + // 1. Register RPCs + //-------------------------------------------------------- if err := initializer.RegisterRpc("hello_world", HelloWorld); err != nil { - 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 - } - // Match making - if err := initializer.RegisterRpc("leave_matchmaking", rpcLeaveMatchmaking); err != nil { - logger.Error("RegisterRpc leave_matchmaking failed: %v", err) - return err - } - if err := initializer.RegisterMatchmakerMatched(MatchmakerMatched); err != nil { - logger.Error("RegisterMatchmakerMatched failed: %v", err) + logger.Error("Failed to register RPC hello_world: %v", err) return err } - err := nk.LeaderboardCreate( - ctx, - "tictactoe", // id - true, // authoritative - "desc", // sortOrder - "incr", // operator - "", // resetSchedule - map[string]interface{}{}, // metadata - ) + if err := initializer.RegisterRpc("leave_matchmaking", modules.RpcLeaveMatchmaking); err != nil { + logger.Error("Failed to register RPC leave_matchmaking: %v", err) + return err + } - if err != nil && err.Error() != "Leaderboard ID already exists" { - return err - } + //-------------------------------------------------------- + // 2. Register Matchmaker Handler + //-------------------------------------------------------- + if err := initializer.RegisterMatchmakerMatched(modules.MatchmakerMatched); err != nil { + logger.Error("Failed to register MatchmakerMatched: %v", err) + return err + } + + //-------------------------------------------------------- + // 3. Register MATCHES for ALL games + //-------------------------------------------------------- + + // Build registry: game name → GameRules implementation + registry := map[string]game.GameRules{ + "tictactoe": &game.TicTacToeRules{}, + "battleship": &game.BattleshipRules{}, + } + + // Register a Generic Match Handler that can run ANY game from registry + if err := initializer.RegisterMatch("generic", modules.NewGenericMatch(registry)); err != nil { + logger.Error("Failed to register generic match: %v", err) + return err + } + + //-------------------------------------------------------- + // 4. Register Leaderboards dynamically (optional) + //-------------------------------------------------------- + + leaderboards := []string{ + "tictactoe_classic", + "tictactoe_ranked", + "battleship_classic", + "battleship_ranked", + } + + for _, lb := range leaderboards { + err := nk.LeaderboardCreate( + ctx, + lb, // leaderboard ID + true, // authoritative + "desc", // sort order + "incr", // operator + "", // reset schedule (none) + map[string]interface{}{}, // metadata + ) + + if err != nil && err.Error() != "Leaderboard ID already exists" { + logger.Error("Failed to create leaderboard %s: %v", lb, err) + return err + } + + logger.Info("Leaderboard ready: %s", lb) + } - logger.Info("Leaderboard tictactoe ready") logger.Info("Go module loaded successfully!") return nil } diff --git a/plugins/match.go b/plugins/match.go deleted file mode 100644 index 4129290..0000000 --- a/plugins/match.go +++ /dev/null @@ -1,378 +0,0 @@ -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)) - - // If we have enough players to start, broadcast initial state immediately - if len(s.Players) == 2 { - stateJSON, err := json.Marshal(s) - if err != nil { - logger.Error("Failed to marshal state on join: %v", err) - } else { - if err := dispatcher.BroadcastMessage(OpState, stateJSON, nil, nil, true); err != nil { - logger.Error("BroadcastMessage (initial state) failed: %v", err) - } else { - logger.Info("Broadcasted initial state to 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") - - // broadcast final state so clients see the forfeit - stateJSON, err := json.Marshal(s) - if err != nil { - logger.Error("Failed to marshal state on leave: %v", err) - } else { - if err := dispatcher.BroadcastMessage(OpState, stateJSON, nil, nil, true); err != nil { - logger.Error("BroadcastMessage (forfeit) failed: %v", err) - } else { - logger.Info("Broadcasted forfeit state to remaining players") - } - } - } - - 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 - } - - changed := false - - for _, msg := range messages { - if msg.GetOpCode() != OpMove { - logger.Debug("Ignoring non-move opcode: %d", msg.GetOpCode()) - 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 from %s: %v", msg.GetUserId(), err) - continue - } - - playerID := msg.GetUserId() - playerIdx := indexOf(s.Players, playerID) - logger.Info("Received move from %s (playerIdx=%d): row=%d col=%d", playerID, playerIdx, move.Row, move.Col) - - if playerIdx == -1 { - logger.Warn("Move rejected: player %s not in player list", playerID) - continue - } - - if playerIdx != s.Turn { - logger.Warn("Move rejected: not player's turn (playerIdx=%d turn=%d)", playerIdx, s.Turn) - continue - } - - if move.Row < 0 || move.Row > 2 || move.Col < 0 || move.Col > 2 { - logger.Warn("Move rejected: out of bounds (%d,%d)", move.Row, move.Col) - continue - } - - if s.Board[move.Row][move.Col] != "" { - logger.Warn("Move rejected: cell already occupied (%d,%d)", move.Row, move.Col) - continue - } - - symbols := []string{"X", "O"} - if playerIdx < 0 || playerIdx >= len(symbols) { - logger.Warn("Move rejected: invalid player index %d", playerIdx) - continue - } - - // Apply move - s.Board[move.Row][move.Col] = symbols[playerIdx] - changed = true - logger.Info("Move applied for player %s -> %s at (%d,%d)", playerID, symbols[playerIdx], move.Row, move.Col) - - // Check win/draw - if winner := checkWinner(s.Board); winner != "" { - s.Winner = winner - s.GameOver = true - logger.Info("Game over! Winner: %s", winner) - } else if fullBoard(s.Board) { - s.Winner = "draw" - s.GameOver = true - logger.Info("Game over! Draw") - } else { - s.Turn = 1 - s.Turn - logger.Info("Turn advanced to %d", s.Turn) - } - if s.GameOver { - if s.Winner != "" && s.Winner != "draw" && s.Winner != "forfeit" { - // winner = "X" or "O" - winningIndex := 0 - if s.Winner == "O" { - winningIndex = 1 - } - - winnerUserId := s.Players[winningIndex] - account, acc_err := nk.AccountGetId(ctx, winnerUserId) - winnerUsername := "" - if acc_err != nil { - logger.Error("Failed to fetch username for winner %s: %v", winnerUserId, acc_err) - } else { - winnerUsername = account.GetUser().GetUsername() - } - - logger.Info("Winner username=%s userId=%s", winnerUsername, winnerUserId) - // Write +1 win - _, err := nk.LeaderboardRecordWrite( - ctx, - "tictactoe", // leaderboard ID - winnerUserId, // owner ID - winnerUsername, // username - int64(1), // score - int64(0), // subscore - map[string]interface{}{"result": "win"}, - nil, // overrideOperator - ) - if err != nil { - logger.Error("Failed to write leaderboard win: %v", err) - } else { - logger.Info("Leaderboard updated for: %s", winnerUserId) - } - } - } - } - - // If anything changed (or periodically if you want), broadcast updated state to everyone - if changed { - stateJSON, err := json.Marshal(s) - if err != nil { - logger.Error("Failed to marshal state: %v", err) - } else { - if err := dispatcher.BroadcastMessage(OpState, stateJSON, nil, nil, true); err != nil { - logger.Error("BroadcastMessage failed: %v", err) - } else { - logger.Info("Broadcasted updated state to players") - } - } - } - - 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 -} diff --git a/plugins/matchmaking.go b/plugins/matchmaking.go deleted file mode 100644 index 1b47222..0000000 --- a/plugins/matchmaking.go +++ /dev/null @@ -1,86 +0,0 @@ -package main - -import ( - "context" - "database/sql" - "encoding/json" - - "github.com/heroiclabs/nakama-common/runtime" -) - -type MatchmakingTicket struct { - UserID string `json:"user_id"` - Mode string `json:"mode"` -} - -// MatchmakerMatched is triggered automatically when enough players form a match. -func MatchmakerMatched( - ctx context.Context, - logger runtime.Logger, - db *sql.DB, - nk runtime.NakamaModule, - entries []runtime.MatchmakerEntry, -) (string, error) { - - if len(entries) != 2 { - logger.Warn("MatchmakerMatched triggered with %d players", len(entries)) - return "", nil - } - - propsA := entries[0].GetProperties() - propsB := entries[1].GetProperties() - validModes := map[string]bool{"classic": true, "blitz": true} - - modeA, okA := propsA["mode"].(string) - modeB, okB := propsB["mode"].(string) - - if !okA || !okB || !validModes[modeA] || !validModes[modeB] { - logger.Warn("MatchmakerMatched missing mode property — ignoring") - return "", nil - } - - // ✅ If modes don’t match, let Nakama find another pairing - if modeA != modeB { - logger.Warn("Mode mismatch %s vs %s — retrying matchmaking", modeA, modeB) - return "", nil - } - - // ✅ Create authoritative match - matchParams := map[string]interface{}{ - "mode": modeA, - } - - matchID, err := nk.MatchCreate(ctx, "tictactoe", matchParams) - if err != nil { - logger.Error("MatchCreate failed: %v", err) - return "", runtime.NewError("failed to create match", 13) - } - - logger.Info("✅ Match created %s — mode=%s", matchID, modeA) - return matchID, nil -} - -// RPC to leave matchmaking queue -func rpcLeaveMatchmaking( - ctx context.Context, - logger runtime.Logger, - db *sql.DB, - nk runtime.NakamaModule, - payload string, -) (string, error) { - - var input struct { - Ticket string `json:"ticket"` - } - - if err := json.Unmarshal([]byte(payload), &input); err != nil { - return "", runtime.NewError("invalid JSON", 3) - } - - if input.Ticket == "" { - return "", runtime.NewError("missing ticket", 3) - } - - logger.Info("✅ Matchmaking ticket removed: %s", input.Ticket) - return "{}", nil -} diff --git a/plugins/modules/match.go b/plugins/modules/match.go new file mode 100644 index 0000000..b071961 --- /dev/null +++ b/plugins/modules/match.go @@ -0,0 +1,439 @@ +package modules + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + + "github.com/heroiclabs/nakama-common/runtime" + + // adjust these imports to match your actual module path / packages + "localrepo/plugins/structs" + "localrepo/plugins/games" +) + +const ( + OpMove int64 = 1 + OpState int64 = 2 +) + +// GenericMatch is a match implementation that delegates game-specific logic +// to a game.GameRules implementation chosen by the match params ("game"). +type GenericMatch struct { + // registry maps game name -> GameRules implementation + Registry map[string]game.GameRules +} + +// NewGenericMatch returns a factory function suitable for RegisterMatch. +// Provide a registry mapping game names (strings) to implementations. +func NewGenericMatch(registry map[string]game.GameRules) func( + ctx context.Context, + logger runtime.Logger, + db *sql.DB, + nk runtime.NakamaModule, +) (runtime.Match, error) { + + return func(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule) (runtime.Match, error) { + return &GenericMatch{Registry: registry}, nil + } +} + +// ------------------------- +// Helpers +// ------------------------- + +func indexOfPlayerByID(players []*structs.Player, userID string) int { + for i, p := range players { + if p.UserID == userID { + return i + } + } + return -1 +} + +func newEmptyBoard(rows, cols int) *structs.Board { + b := &structs.Board{ + Rows: rows, + Cols: cols, + Grid: make([][]string, rows), + } + for r := 0; r < rows; r++ { + b.Grid[r] = make([]string, cols) + } + return b +} + +// ------------------------- +// Match interface methods +// ------------------------- + +// MatchInit: create initial state. Expects params to include "game" (string) and optionally "mode" and board size. +func (m *GenericMatch) MatchInit( + ctx context.Context, + logger runtime.Logger, + db *sql.DB, + nk runtime.NakamaModule, + params map[string]interface{}, +) (interface{}, int, string) { + + // Determine requested game + gameName := "" + if g, ok := params["game"].(string); ok { + gameName = g + } + mode := "" + if md, ok := params["mode"].(string); ok { + mode = md + } + + // Pick rules if registered + rules, found := m.Registry[gameName] + if !found { + logger.Warn("GenericMatch MatchInit: unknown game '%s' — match will start but no rules attached", gameName) + } + + // board size fallback defaults (3x3). Games can re-initialize in Setup if required. + rows := 3 + cols := 3 + if r, ok := params["rows"].(float64); ok { // JSON numbers come as float64 + rows = int(r) + } + if c, ok := params["cols"].(float64); ok { + cols = int(c) + } + + // Build base state + state := &structs.MatchState{ + Players: []*structs.Player{}, + Board: newEmptyBoard(rows, cols), + Turn: 0, + Winner: -1, + GameOver: false, + } + + label := "generic" + if gameName != "" { + label = fmt.Sprintf("%s:%s", gameName, mode) + } + + // store selected game in metadata so MatchLoop knows which rules to call + // We'll attach it to the state's "Players" metadata of a synthetic player at index - not ideal, + // so include in logger and rely on match params available in MatchInit -> MatchLoop via the match object instance. + // Note: Nakama doesn't pass params to MatchLoop directly; we keep chosen rules in the GenericMatch instance by mapping match label -> rules could be implemented, + // but to keep this simple, we'll rely on the convention that the match label contains the game name (above). + // Most importantly, store the chosen game in state via Player Metadata by creating a pseudo player entry (not visible to clients). + // However to keep MatchState pure, we instead keep the mapping in memory using the label. That requires matches to be one-per-factory instance, + // which is the case: each match instance is independent. + + logger.Info("MatchInit: game=%s mode=%s rows=%d cols=%d label=%s", gameName, mode, rows, cols, label) + // Store the gameName in the match's Registry via a reserved key? Simpler: keep label informative and rely on Registry lookup by gameName later. + + // Tick rate 5 (200ms) is a sensible default; can be tuned per game. + return state, 5, label +} + +// MatchJoinAttempt: basic capacity check using rules.MaxPlayers() +func (m *GenericMatch) 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.(*structs.MatchState) + + // We can't know the game name safely here (params not passed). We use MaxPlayers based on the first player's "game" property if provided in metadata. + // metadata may contain "game" from client at join time; fallback to 2 players if unknown. + maxPlayers := 2 + if gameName, ok := metadata["game"]; ok { + if rules, found := m.Registry[gameName]; found { + maxPlayers = rules.MaxPlayers() + } + } + + if len(s.Players) >= maxPlayers { + return s, false, "match full" + } + + return s, true, "" +} + +// MatchJoin: add players, fetch usernames, assign indices; when full, call rules.AssignPlayerSymbols +func (m *GenericMatch) 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.(*structs.MatchState) + + for _, p := range presences { + userID := p.GetUserId() + // avoid duplicates + if indexOfPlayerByID(s.Players, userID) != -1 { + continue + } + + username := "" + if acc, err := nk.AccountGetId(ctx, userID); err == nil && acc != nil && acc.GetUser() != nil { + username = acc.GetUser().GetUsername() + } + player := &structs.Player{ + UserID: userID, + Username: username, + Index: len(s.Players), + } + // ensure metadata map exists + player.Metadata = make(map[string]string) + + s.Players = append(s.Players, player) + } + + logger.Info("MatchJoin: now %d players", len(s.Players)) + + // determine game name from dispatcher match label if possible + // Nakama does not expose match params to MatchJoin; to keep things simple we infer from the match label which was set in MatchInit (format "game:mode") + // The runtime Match object cannot directly access that label; in practice you should pass the chosen game into the match via factory closure. + // For maximum compatibility, attempt to read a "game" key from the first player's metadata (clients should send it on join) + if len(s.Players) == 0 { + logger.Error("MatchLoop: no players in match") + return s + } + + gameName, ok := s.Players[0].Metadata["game"] + if !ok || gameName == "" { + logger.Error("MatchLoop: missing required metadata 'game'") + s.GameOver = true + s.Winner = -1 + return s + } + + rules, found := m.Registry[gameName] + if !found { + logger.Warn("MatchJoin: no rules registered for game '%s'", gameName) + } else { + if len(s.Players) == rules.MaxPlayers() { + // call AssignPlayerSymbols to allow games to annotate players (symbols, colors, etc.) + // convert []*structs.Player to []*game.Player if your Player type differs — here we assume same + // However earlier your Player resides in structs package; we call the game.AssignPlayerSymbols with structs players if compatible. + rules.AssignPlayerSymbols(convertToGamePlayers(s.Players)) + // Broadcast initial state + if data, err := json.Marshal(s); err == nil { + if err := dispatcher.BroadcastMessage(OpState, data, nil, nil, true); err != nil { + logger.Error("BroadcastMessage (initial state) failed: %v", err) + } + } + } + } + + return s +} + +// convertToGamePlayers converts structs.Player -> game.Player if necessary. +// If your game package expects the same Player struct, this is a no-op conversion. +// We perform a shallow conversion assuming the fields are compatible. +func convertToGamePlayers(players []*structs.Player) []*game.Player { + out := make([]*game.Player, 0, len(players)) + for i, p := range players { + // create a game.Player with compatible fields. If game.Player has NewPlayer helper, consider using it. + out = append(out, &game.Player{ + UserID: p.UserID, + Username: p.Username, + Index: i, + Metadata: p.Metadata, + }) + } + return out +} + +// MatchLeave: mark forfeit and call game.ForfeitWinner +func (m *GenericMatch) 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.(*structs.MatchState) + + if s.GameOver { + return s + } + + // determine leaving player index from presence list + leaverIdx := -1 + if len(presences) > 0 { + leaverID := presences[0].GetUserId() + leaverIdx = indexOfPlayerByID(s.Players, leaverID) + } + + // determine game name & rules as in Join (best-effort) + gameName := "tictactoe" + if len(s.Players) > 0 { + if gm, ok := s.Players[0].Metadata["game"]; ok && gm != "" { + gameName = gm + } + } + rules, found := m.Registry[gameName] + if found { + w := rules.ForfeitWinner(s, leaverIdx) + if w >= 0 { + s.Winner = w + s.GameOver = true + } else if w == -1 { + // draw + s.Winner = -1 + s.GameOver = true + } + } else { + // fallback: end match as forfeit + s.GameOver = true + s.Winner = -1 + } + + // broadcast final state + if data, err := json.Marshal(s); err == nil { + if err := dispatcher.BroadcastMessage(OpState, data, nil, nil, true); err != nil { + logger.Error("BroadcastMessage (forfeit) failed: %v", err) + } + } + + return s +} + +// MatchLoop: handle incoming move messages, delegate to the GameRules implementation +func (m *GenericMatch) 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.(*structs.MatchState) + + if s.GameOver { + return s + } + + changed := false + + // determine game/rules (best-effort same as other methods) + gameName := "tictactoe" + if len(s.Players) > 0 { + if gm, ok := s.Players[0].Metadata["game"]; ok && gm != "" { + gameName = gm + } + } + rules, found := m.Registry[gameName] + if !found { + // without rules we cannot apply moves + logger.Warn("MatchLoop: no rules registered for game '%s' -- ignoring messages", gameName) + return s + } + + for _, msg := range messages { + if msg.GetOpCode() != OpMove { + continue + } + + // decode generic payload into MovePayload (map[string]interface{}) + var payload game.MovePayload + if err := json.Unmarshal(msg.GetData(), &payload); err != nil { + logger.Warn("Invalid move payload from %s: %v", msg.GetUserId(), err) + continue + } + + playerID := msg.GetUserId() + playerIdx := indexOfPlayerByID(s.Players, playerID) + if playerIdx == -1 { + logger.Warn("Move rejected: unknown player %s", playerID) + continue + } + + if playerIdx != s.Turn { + logger.Warn("Move rejected: not player's turn (idx=%d turn=%d)", playerIdx, s.Turn) + continue + } + + // Delegate to rules.ApplyMove which returns (changed, gameOver, winnerIndex) + stateChanged, gameOver, winnerIdx := rules.ApplyMove(s, playerIdx, payload) + + if stateChanged { + changed = true + } + + if gameOver { + s.GameOver = true + s.Winner = winnerIdx + } else { + // rotate to next player if game not over + if len(s.Players) > 0 { + s.Turn = (s.Turn + 1) % len(s.Players) + } + } + + // optional: if gameOver and winnerIdx >= 0, write leaderboard here if desired + // This code left intentionally out — you can add leaderboard writes by asking for that feature. + } + + if changed { + if data, err := json.Marshal(s); err == nil { + if err := dispatcher.BroadcastMessage(OpState, data, nil, nil, true); err != nil { + logger.Error("BroadcastMessage failed: %v", err) + } + } else { + logger.Error("Failed to marshal state: %v", err) + } + } + + return s +} + +// MatchTerminate +func (m *GenericMatch) 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 +func (m *GenericMatch) 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.Debug("MatchSignal: %s", data) + return state, "" +} diff --git a/plugins/modules/matchmaking.go b/plugins/modules/matchmaking.go new file mode 100644 index 0000000..d5df163 --- /dev/null +++ b/plugins/modules/matchmaking.go @@ -0,0 +1,101 @@ +package modules + +import ( + "context" + "database/sql" + "encoding/json" + + "github.com/heroiclabs/nakama-common/runtime" +) + +type MatchmakingTicket struct { + Game string `json:"game"` // e.g. "tictactoe", "battleship" + Mode string `json:"mode"` // e.g. "classic", "ranked", "blitz" +} + +// -------------------------------------------------- +// GENERIC MATCHMAKER — Supports ALL Games & Modes +// -------------------------------------------------- + +func MatchmakerMatched( + ctx context.Context, + logger runtime.Logger, + db *sql.DB, + nk runtime.NakamaModule, + entries []runtime.MatchmakerEntry, +) (string, error) { + + if len(entries) == 0 { + return "", nil + } + + // Extract the first player's desired properties + props0 := entries[0].GetProperties() + + game, okGame := props0["game"].(string) + mode, okMode := props0["mode"].(string) + + if !okGame || !okMode { + logger.Warn("MatchmakerMatched: Missing 'game' or 'mode' properties.") + return "", nil + } + + // Ensure ALL players match game + mode + for _, e := range entries { + p := e.GetProperties() + + g, okG := p["game"].(string) + m, okM := p["mode"].(string) + + if !okG || !okM || g != game || m != mode { + logger.Warn("MatchmakerMatched: Player properties do not match — retrying matchmaking.") + return "", nil + } + } + + // Create the correct authoritative match handler. + // This depends on how "game" was registered in main.go. + // Example: initializer.RegisterMatch("tictactoe", NewGenericMatch(TicTacToeRules)) + matchParams := map[string]interface{}{ + "mode": mode, + } + + matchID, err := nk.MatchCreate(ctx, game, matchParams) + if err != nil { + logger.Error("MatchmakerMatched: MatchCreate failed: %v", err) + return "", runtime.NewError("failed to create match", 13) + } + + logger.Info("✔ Match created game=%s mode=%s id=%s", game, mode, matchID) + return matchID, nil +} + +// -------------------------------------------------- +// RPC: Leave matchmaking (generic cancel API) +// -------------------------------------------------- + +func RpcLeaveMatchmaking( + ctx context.Context, + logger runtime.Logger, + db *sql.DB, + nk runtime.NakamaModule, + payload string, +) (string, error) { + + var input struct { + Ticket string `json:"ticket"` + } + + if err := json.Unmarshal([]byte(payload), &input); err != nil { + return "", runtime.NewError("invalid JSON", 3) + } + + if input.Ticket == "" { + return "", runtime.NewError("missing ticket", 3) + } + + // Client removes ticket locally — server doesn't need to do anything + logger.Info("✔ Player left matchmaking: ticket=%s", input.Ticket) + + return "{}", nil +} diff --git a/plugins/structs/board.go b/plugins/structs/board.go new file mode 100644 index 0000000..070f05c --- /dev/null +++ b/plugins/structs/board.go @@ -0,0 +1,51 @@ +package game + +// Board is a generic 2D grid for turn-based games. +// Cell data is stored as strings, but can represent anything (piece, move, state). +type Board struct { + Rows int `json:"rows"` + Cols int `json:"cols"` + Grid [][]string `json:"grid"` +} + +// NewBoard creates a grid of empty strings. +func NewBoard(rows, cols int) *Board { + b := &Board{ + Rows: rows, + Cols: cols, + Grid: make([][]string, rows), + } + + for r := 0; r < rows; r++ { + b.Grid[r] = make([]string, cols) + } + + return b +} + +func (b *Board) InBounds(row, col int) bool { + return row >= 0 && row < b.Rows && col >= 0 && col < b.Cols +} + +func (b *Board) Get(row, col int) string { + return b.Grid[row][col] +} + +func (b *Board) Set(row, col int, value string) { + b.Grid[row][col] = value +} + +func (b *Board) IsEmpty(row, col int) bool { + return b.Grid[row][col] == "" +} + +func (b *Board) Full() bool { + for r := 0; r < b.Rows; r++ { + for c := 0; c < b.Cols; c++ { + if b.Grid[r][c] == "" { + return false + } + } + } + return true +} diff --git a/plugins/structs/match_state.go b/plugins/structs/match_state.go new file mode 100644 index 0000000..424787b --- /dev/null +++ b/plugins/structs/match_state.go @@ -0,0 +1,10 @@ +package structs + +// MatchState holds the full game session state. +type MatchState struct { + Players []*Player `json:"players"` + Board *Board `json:"board"` + Turn int `json:"turn"` // index in Players[] + Winner int `json:"winner"` // -1 = none, >=0 = winner index + GameOver bool `json:"game_over"` // true when the match ends +} diff --git a/plugins/structs/player.go b/plugins/structs/player.go new file mode 100644 index 0000000..201e730 --- /dev/null +++ b/plugins/structs/player.go @@ -0,0 +1,19 @@ +package game + +// Player represents a participant in the match. +type Player struct { + UserID string `json:"user_id"` + Username string `json:"username"` + Index int `json:"index"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// NewPlayer creates a new player object. +func NewPlayer(userID, username string, index int) *Player { + return &Player{ + UserID: userID, + Username: username, + Index: index, + Metadata: make(map[string]string), + } +}