### 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.
440 lines
12 KiB
Go
440 lines
12 KiB
Go
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, ""
|
|
}
|