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"
"github.com/heroiclabs/nakama-common/runtime"
// adjust these imports to match your actual module path / packages
"localrepo/plugins/structs"
"localrepo/plugins/games"
)
@@ -21,13 +19,17 @@ const (
// 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
GameName string
Mode string
Config games.GameConfiguration
Rules games.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(
func NewGenericMatch(
registry map[string]games.GameRules
) func(
ctx context.Context,
logger runtime.Logger,
db *sql.DB,
@@ -35,7 +37,8 @@ func NewGenericMatch(registry map[string]game.GameRules) func(
) (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{},
) (interface{}, int, string) {
// Determine requested game
gameName := ""
if g, ok := params["game"].(string); ok {
gameName = g
// ---- 1. game REQUIRED ----
raw, ok := params["game"]
if !ok {
logger.Error("MatchInit ERROR: missing param 'game'")
return nil, 0, ""
}
mode := ""
if md, ok := params["mode"].(string); ok {
gameName, ok := raw.(string)
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
}
// 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)
}
// ---- 3. build match instance fields ----
m.GameName = gameName
m.Mode = mode
m.Config = cfg
m.Rules = rules
// 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
// ---- 4. create initial state ----
state := &structs.MatchState{
Players: []*structs.Player{},
Board: newEmptyBoard(rows, cols),
Board: newEmptyBoard(cfg.Board.Rows, cfg.Board.Cols),
Turn: 0,
Winner: -1,
GameOver: false,
}
label := "generic"
if gameName != "" {
label = fmt.Sprintf("%s:%s", gameName, mode)
}
label := fmt.Sprintf("%s:%s", m.GameName, m.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)
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)
// 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.
@@ -148,16 +149,7 @@ func (m *GenericMatch) MatchJoinAttempt(
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 {
if len(s.Players) >= m.Config.Players {
return s, false, "match full"
}
@@ -180,7 +172,6 @@ func (m *GenericMatch) MatchJoin(
for _, p := range presences {
userID := p.GetUserId()
// avoid duplicates
if indexOfPlayerByID(s.Players, userID) != -1 {
continue
}
@@ -189,74 +180,30 @@ func (m *GenericMatch) MatchJoin(
if acc, err := nk.AccountGetId(ctx, userID); err == nil && acc != nil && acc.GetUser() != nil {
username = acc.GetUser().GetUsername()
}
player := &structs.Player{
s.Players = append(s.Players, &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)
Metadata: map[string]string{},
})
}
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
// 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)
}
}
}
// 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,
@@ -278,33 +225,12 @@ func (m *GenericMatch) MatchLeave(
// determine leaving player index from presence list
leaverIdx := -1
if len(presences) > 0 {
leaverID := presences[0].GetUserId()
leaverIdx = indexOfPlayerByID(s.Players, leaverID)
leaverIdx = indexOfPlayerByID(s.Players, presences[0].GetUserId())
}
// 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
}
winner := m.Rules.ForfeitWinner(s, leaverIdx)
s.Winner = winner
s.GameOver = true
// broadcast final state
if data, err := json.Marshal(s); err == nil {
@@ -316,7 +242,12 @@ func (m *GenericMatch) MatchLeave(
return s
}
// MatchLoop: handle incoming move messages, delegate to the GameRules implementation
//
// ──────────────────────────────────────────────────────────
// MatchLoop
// ──────────────────────────────────────────────────────────
//
func (m *GenericMatch) MatchLoop(
ctx context.Context,
logger runtime.Logger,
@@ -336,27 +267,12 @@ func (m *GenericMatch) MatchLoop(
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
var payload games.MovePayload
if err := json.Unmarshal(msg.GetData(), &payload); err != nil {
logger.Warn("Invalid move payload from %s: %v", msg.GetUserId(), err)
continue
@@ -385,11 +301,9 @@ func (m *GenericMatch) MatchLoop(
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)
}
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.