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:
@@ -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
23
plugins/games/config.go
Normal 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
27
plugins/games/rules.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user