feat: refactor Nakama plugin into generic multi-game match engine
### 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.
This commit is contained in:
4
go.mod
4
go.mod
@@ -1,7 +1,7 @@
|
|||||||
module git.aetoskia.com/lila-games/tic-tac-toe
|
module localrepo
|
||||||
|
|
||||||
go 1.21
|
go 1.21
|
||||||
|
|
||||||
require github.com/heroiclabs/nakama-common v1.31.0
|
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
|
||||||
|
|||||||
27
plugins/common/game.go
Normal file
27
plugins/common/game.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
178
plugins/games/battleship.go
Normal file
178
plugins/games/battleship.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
143
plugins/games/tic_tac_toe.go
Normal file
143
plugins/games/tic_tac_toe.go
Normal file
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -5,21 +5,12 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
|
|
||||||
"github.com/heroiclabs/nakama-common/runtime"
|
"github.com/heroiclabs/nakama-common/runtime"
|
||||||
|
|
||||||
|
// Adjust these imports to match your project structure
|
||||||
|
"localrepo/plugins/modules"
|
||||||
|
"localrepo/plugins/games"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Example RPC
|
|
||||||
func HelloWorld(
|
|
||||||
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(
|
func InitModule(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
logger runtime.Logger,
|
logger runtime.Logger,
|
||||||
@@ -27,39 +18,74 @@ func InitModule(
|
|||||||
nk runtime.NakamaModule,
|
nk runtime.NakamaModule,
|
||||||
initializer runtime.Initializer,
|
initializer runtime.Initializer,
|
||||||
) error {
|
) error {
|
||||||
|
|
||||||
|
//--------------------------------------------------------
|
||||||
|
// 1. Register RPCs
|
||||||
|
//--------------------------------------------------------
|
||||||
if err := initializer.RegisterRpc("hello_world", HelloWorld); err != nil {
|
if err := initializer.RegisterRpc("hello_world", HelloWorld); err != nil {
|
||||||
logger.Error("Failed to register RPC: %v", err)
|
logger.Error("Failed to register RPC hello_world: %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)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := initializer.RegisterRpc("leave_matchmaking", modules.RpcLeaveMatchmaking); err != nil {
|
||||||
|
logger.Error("Failed to register RPC leave_matchmaking: %v", err)
|
||||||
|
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(
|
err := nk.LeaderboardCreate(
|
||||||
ctx,
|
ctx,
|
||||||
"tictactoe", // id
|
lb, // leaderboard ID
|
||||||
true, // authoritative
|
true, // authoritative
|
||||||
"desc", // sortOrder
|
"desc", // sort order
|
||||||
"incr", // operator
|
"incr", // operator
|
||||||
"", // resetSchedule
|
"", // reset schedule (none)
|
||||||
map[string]interface{}{}, // metadata
|
map[string]interface{}{}, // metadata
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil && err.Error() != "Leaderboard ID already exists" {
|
if err != nil && err.Error() != "Leaderboard ID already exists" {
|
||||||
|
logger.Error("Failed to create leaderboard %s: %v", lb, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Info("Leaderboard tictactoe ready")
|
logger.Info("Leaderboard ready: %s", lb)
|
||||||
|
}
|
||||||
|
|
||||||
logger.Info("Go module loaded successfully!")
|
logger.Info("Go module loaded successfully!")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
378
plugins/match.go
378
plugins/match.go
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
439
plugins/modules/match.go
Normal file
439
plugins/modules/match.go
Normal file
@@ -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, ""
|
||||||
|
}
|
||||||
101
plugins/modules/matchmaking.go
Normal file
101
plugins/modules/matchmaking.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
51
plugins/structs/board.go
Normal file
51
plugins/structs/board.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
10
plugins/structs/match_state.go
Normal file
10
plugins/structs/match_state.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
19
plugins/structs/player.go
Normal file
19
plugins/structs/player.go
Normal file
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user