refactor(server): move shared game logic into games/ and introduce GameConfig system

- Moved common/game.go → plugins/games/rules.go
  - Contains GameRules interface, MovePayload, Player abstraction
  - Centralizes all reusable game rule contract logic

- Added plugins/games/config.go
  - Introduces GameConfiguration struct (players, board size, etc.)
  - Added global GameConfig and RulesRegistry maps
  - Each game (tictactoe, battleship, etc.) now registers its config + rules

- Updated generic_match.go to use:
  - GameConfig for board/players initialization
  - RulesRegistry for rule lookup during MatchInit
  - Removed hardcoded TicTacToe behavior
  - Clean error returns when game param missing or invalid

- Updated folder structure:
    /plugins/
        /games
            rules.go       (formerly common/game.go)
            config.go
            tictactoe.go
            battleship.go
        /structs
        /modules
        main.go

- Ensures GenericMatch pulls:
    m.GameName, m.Mode, m.Config, m.Rules
  directly from config/registry at MatchInit

- Removes old duplicated logic and simplifies how games are registered
This commit is contained in:
2025-12-01 16:05:53 +05:30
parent eeb0a8175f
commit d9c3ecb252
4 changed files with 121 additions and 184 deletions

View File

@@ -1,27 +0,0 @@
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
}

23
plugins/games/config.go Normal file
View File

@@ -0,0 +1,23 @@
package modules
type BoardConfig struct {
Rows int
Cols int
}
type GameConfiguration struct {
Players int
Board BoardConfig
}
// Static configuration for all supported games.
var GameConfig = map[string]GameConfiguration{
"tictactoe": {
Players: 2,
Board: BoardConfig{Rows: 3, Cols: 3},
},
"battleship": {
Players: 2,
Board: BoardConfig{Rows: 10, Cols: 10},
},
}

27
plugins/games/rules.go Normal file
View File

@@ -0,0 +1,27 @@
package games
import "localrepo/plugins/structs"
// MovePayload is used for incoming move data from clients.
type MovePayload struct {
Data map[string]interface{} `json:"data"`
}
type GameRules interface {
// Number of players needed to start.
MaxPlayers() int
// Assign symbols/colors/pieces at start.
AssignPlayerSymbols(players []*Player)
// Apply a move.
// Returns: (changed, gameOver, winnerIndex)
ApplyMove(state *structs.MatchState, playerIdx int, payload MovePayload) (bool, bool, int)
// If a player leaves, who wins?
// Return:
// >=0 → winner index
// -1 → draw
// -2 → invalid
ForfeitWinner(state *structs.MatchState, leaverIndex int) int
}

View File

@@ -7,8 +7,6 @@ import (
"fmt" "fmt"
"github.com/heroiclabs/nakama-common/runtime" "github.com/heroiclabs/nakama-common/runtime"
// adjust these imports to match your actual module path / packages
"localrepo/plugins/structs" "localrepo/plugins/structs"
"localrepo/plugins/games" "localrepo/plugins/games"
) )
@@ -21,13 +19,17 @@ const (
// GenericMatch is a match implementation that delegates game-specific logic // GenericMatch is a match implementation that delegates game-specific logic
// to a game.GameRules implementation chosen by the match params ("game"). // to a game.GameRules implementation chosen by the match params ("game").
type GenericMatch struct { type GenericMatch struct {
// registry maps game name -> GameRules implementation GameName string
Registry map[string]game.GameRules Mode string
Config games.GameConfiguration
Rules games.GameRules
} }
// NewGenericMatch returns a factory function suitable for RegisterMatch. // NewGenericMatch returns a factory function suitable for RegisterMatch.
// Provide a registry mapping game names (strings) to implementations. // Provide a registry mapping game names (strings) to implementations.
func NewGenericMatch(registry map[string]game.GameRules) func( func NewGenericMatch(
registry map[string]games.GameRules
) func(
ctx context.Context, ctx context.Context,
logger runtime.Logger, logger runtime.Logger,
db *sql.DB, db *sql.DB,
@@ -35,7 +37,8 @@ func NewGenericMatch(registry map[string]game.GameRules) func(
) (runtime.Match, error) { ) (runtime.Match, error) {
return 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 // Empty match instance; MatchInit will fill real config.
return &GenericMatch{}, nil
} }
} }
@@ -77,56 +80,54 @@ func (m *GenericMatch) MatchInit(
params map[string]interface{}, params map[string]interface{},
) (interface{}, int, string) { ) (interface{}, int, string) {
// Determine requested game // ---- 1. game REQUIRED ----
gameName := "" raw, ok := params["game"]
if g, ok := params["game"].(string); ok { if !ok {
gameName = g logger.Error("MatchInit ERROR: missing param 'game'")
return nil, 0, ""
} }
mode := "" gameName, ok := raw.(string)
if md, ok := params["mode"].(string); ok { if !ok || gameName == "" {
logger.Error("MatchInit ERROR: invalid 'game' param")
return nil, 0, ""
}
cfg, found := games.GameConfig[gameName]
if !found {
logger.Error("MatchInit ERROR: game '%s' not in GameConfig", gameName)
return nil, 0, ""
}
rules, found := games.RulesRegistry[gameName]
if !found {
logger.Warn("GenericMatch MatchInit: unknown game '%s' — match will start but no rules attached", gameName)
return nil, 0, ""
}
// ---- 2. mode (optional) ----
mode := "default"
if md, ok := params["mode"].(string); ok && md != "" {
mode = md mode = md
} }
// Pick rules if registered // ---- 3. build match instance fields ----
rules, found := m.Registry[gameName] m.GameName = gameName
if !found { m.Mode = mode
logger.Warn("GenericMatch MatchInit: unknown game '%s' — match will start but no rules attached", gameName) m.Config = cfg
} m.Rules = rules
// board size fallback defaults (3x3). Games can re-initialize in Setup if required. // ---- 4. create initial state ----
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{ state := &structs.MatchState{
Players: []*structs.Player{}, Players: []*structs.Player{},
Board: newEmptyBoard(rows, cols), Board: newEmptyBoard(cfg.Board.Rows, cfg.Board.Cols),
Turn: 0, Turn: 0,
Winner: -1, Winner: -1,
GameOver: false, GameOver: false,
} }
label := "generic" label := fmt.Sprintf("%s:%s", m.GameName, m.Mode)
if gameName != "" {
label = fmt.Sprintf("%s:%s", gameName, mode)
}
// store selected game in metadata so MatchLoop knows which rules to call logger.Info("MatchInit OK — game=%s mode=%s players=%d board=%dx%d", m.GameName, m.Mode, cfg.Players, cfg.Board.Rows, cfg.Board.Cols)
// 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. // 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. // Tick rate 5 (200ms) is a sensible default; can be tuned per game.
@@ -148,16 +149,7 @@ func (m *GenericMatch) MatchJoinAttempt(
s := state.(*structs.MatchState) 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. if len(s.Players) >= m.Config.Players {
// 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, false, "match full"
} }
@@ -180,7 +172,6 @@ func (m *GenericMatch) MatchJoin(
for _, p := range presences { for _, p := range presences {
userID := p.GetUserId() userID := p.GetUserId()
// avoid duplicates
if indexOfPlayerByID(s.Players, userID) != -1 { if indexOfPlayerByID(s.Players, userID) != -1 {
continue continue
} }
@@ -189,74 +180,30 @@ func (m *GenericMatch) MatchJoin(
if acc, err := nk.AccountGetId(ctx, userID); err == nil && acc != nil && acc.GetUser() != nil { if acc, err := nk.AccountGetId(ctx, userID); err == nil && acc != nil && acc.GetUser() != nil {
username = acc.GetUser().GetUsername() username = acc.GetUser().GetUsername()
} }
player := &structs.Player{
s.Players = append(s.Players, &structs.Player{
UserID: userID, UserID: userID,
Username: username, Username: username,
Index: len(s.Players), Index: len(s.Players),
} Metadata: map[string]string{},
// 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)) if len(s.Players) == m.Config.Players {
// Assign player symbols/colors/etc.
m.Rules.AssignPlayerSymbols(s.Players)
// determine game name from dispatcher match label if possible // Broadcast initial state
// 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") if data, err := json.Marshal(s); err == nil {
// The runtime Match object cannot directly access that label; in practice you should pass the chosen game into the match via factory closure. if err := dispatcher.BroadcastMessage(OpState, data, nil, nil, true); err != nil {
// For maximum compatibility, attempt to read a "game" key from the first player's metadata (clients should send it on join) logger.Error("BroadcastMessage (initial state) failed: %v", err)
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 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 // MatchLeave: mark forfeit and call game.ForfeitWinner
func (m *GenericMatch) MatchLeave( func (m *GenericMatch) MatchLeave(
ctx context.Context, ctx context.Context,
@@ -278,33 +225,12 @@ func (m *GenericMatch) MatchLeave(
// determine leaving player index from presence list // determine leaving player index from presence list
leaverIdx := -1 leaverIdx := -1
if len(presences) > 0 { if len(presences) > 0 {
leaverID := presences[0].GetUserId() leaverIdx = indexOfPlayerByID(s.Players, presences[0].GetUserId())
leaverIdx = indexOfPlayerByID(s.Players, leaverID)
} }
// determine game name & rules as in Join (best-effort) winner := m.Rules.ForfeitWinner(s, leaverIdx)
gameName := "tictactoe" s.Winner = winner
if len(s.Players) > 0 { s.GameOver = true
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 // broadcast final state
if data, err := json.Marshal(s); err == nil { if data, err := json.Marshal(s); err == nil {
@@ -316,7 +242,12 @@ func (m *GenericMatch) MatchLeave(
return s return s
} }
// MatchLoop: handle incoming move messages, delegate to the GameRules implementation //
// ──────────────────────────────────────────────────────────
// MatchLoop
// ──────────────────────────────────────────────────────────
//
func (m *GenericMatch) MatchLoop( func (m *GenericMatch) MatchLoop(
ctx context.Context, ctx context.Context,
logger runtime.Logger, logger runtime.Logger,
@@ -336,27 +267,12 @@ func (m *GenericMatch) MatchLoop(
changed := false 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 { for _, msg := range messages {
if msg.GetOpCode() != OpMove { if msg.GetOpCode() != OpMove {
continue continue
} }
// decode generic payload into MovePayload (map[string]interface{}) var payload games.MovePayload
var payload game.MovePayload
if err := json.Unmarshal(msg.GetData(), &payload); err != nil { if err := json.Unmarshal(msg.GetData(), &payload); err != nil {
logger.Warn("Invalid move payload from %s: %v", msg.GetUserId(), err) logger.Warn("Invalid move payload from %s: %v", msg.GetUserId(), err)
continue continue
@@ -385,11 +301,9 @@ func (m *GenericMatch) MatchLoop(
s.GameOver = true s.GameOver = true
s.Winner = winnerIdx s.Winner = winnerIdx
} else { } else {
// rotate to next player if game not over s.Turn = (s.Turn + 1) % len(s.Players)
if len(s.Players) > 0 {
s.Turn = (s.Turn + 1) % len(s.Players)
}
} }
}
// optional: if gameOver and winnerIdx >= 0, write leaderboard here if desired // 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. // This code left intentionally out — you can add leaderboard writes by asking for that feature.