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:
2025-12-01 15:28:54 +05:30
parent 70669fc856
commit eeb0a8175f
12 changed files with 1038 additions and 508 deletions

51
plugins/structs/board.go Normal file
View 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
}

View 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
View 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),
}
}