feat(core): migrate to multi-board architecture and implement per-game InitBoards

### Major changes
- Replace single-board MatchState (`Board`) with multi-board map (`Boards`)
- Update GenericMatch to initialize empty Boards and populate them via GameRules.InitBoards
- Remove legacy `newEmptyBoard` helper from match.go
- Update .gitignore to include *BKP* patterns

### GameRules interface
- Add InitBoards(players, cfg) to allow games to construct their own board sets
- Add detailed documentation explaining method responsibilities and usage
- Improve MovePayload comment clarity

### TicTacToe updates
- Implement InitBoards to produce a single `"tictactoe"` board
- Replace all old references to `state.Board` with `state.Boards["tictactoe"]`
- Make CheckGameOver, ValidateMove, and ApplyMove multi-board compatible

### Battleship updates
- Implement InitBoards generating per-player ships + shots boards:
  - p0_ships, p0_shots
  - p1_ships, p1_shots

### Match flow updates
- Boards are now created only when all players have joined
- Initial state broadcast now includes `boards` instead of `board`

This completes the backend migration for multi-board games and prepares the architecture
for Battleship and other complex board-based games.
This commit is contained in:
2025-12-03 17:52:27 +05:30
parent 1c31c489c7
commit bcdc5faea5
5 changed files with 69 additions and 24 deletions

View File

@@ -62,6 +62,21 @@ type BattleshipRules struct{}
func (b *BattleshipRules) MaxPlayers() int { return 2 } func (b *BattleshipRules) MaxPlayers() int { return 2 }
func (b *BattleshipRules) InitBoards(players []*structs.Player, cfg GameConfiguration) map[string]*structs.Board {
boards := make(map[string]*structs.Board)
// One ships board and one shots board per player
for _, p := range players {
pid := fmt.Sprintf("p%d", p.Index)
// Player's fleet board (ships placement)
boards[pid+"_ships"] = structs.NewBoard(cfg.Board.Rows, cfg.Board.Cols)
// Player's attack tracking board (shots fired at opponent)
boards[pid+"_shots"] = structs.NewBoard(cfg.Board.Rows, cfg.Board.Cols)
}
return boards
}
// ------------------------------ // ------------------------------
// Assign player boards // Assign player boards
// ------------------------------ // ------------------------------

View File

@@ -2,11 +2,17 @@ package games
import "localrepo/plugins/structs" import "localrepo/plugins/structs"
// MovePayload is used for incoming move data from clients. // MovePayload is the decoded payload sent from clients.
// It is intentionally untyped (map[string]interface{}) so each game
// can define its own move structure (e.g., row/col, coordinate, action type, etc.)
type MovePayload struct { type MovePayload struct {
Data map[string]interface{} `json:"data"` Data map[string]interface{} `json:"data"`
} }
// GameRules defines a generic interface for match logic.
//
// Each game (TicTacToe, Battleship, Chess, etc.) must implement this interface.
// The Nakama match handler delegates all game-specific behavior to these methods.
type GameRules interface { type GameRules interface {
// Number of players needed to start. // Number of players needed to start.
MaxPlayers() int MaxPlayers() int
@@ -24,4 +30,17 @@ type GameRules interface {
// -1 → draw // -1 → draw
// -2 → invalid // -2 → invalid
ForfeitWinner(state *structs.MatchState, leaverIndex int) int ForfeitWinner(state *structs.MatchState, leaverIndex int) int
// InitBoards initializes all the boards required for the game.
//
// This is called AFTER all players have joined the match.
//
// Examples:
// - TicTacToe → 1 board shared by both players: {"tictactoe": 3x3}
// - Battleship → 2 boards per player:
// {"p0_ships":10x10, "p0_shots":10x10, "p1_ships":..., "p1_shots":...}
//
// The returned map is stored in MatchState.Boards.
InitBoards(players []*structs.Player, cfg GameConfiguration) map[string]*structs.Board
} }

View File

@@ -15,6 +15,12 @@ func (t *TicTacToeRules) MaxPlayers() int {
return 2 return 2
} }
func (t *TicTacToeRules) InitBoards(players []*structs.Player, cfg GameConfiguration) map[string]*structs.Board {
return map[string]*structs.Board{
"tictactoe": structs.NewBoard(cfg.Board.Rows, cfg.Board.Cols),
}
}
// Assign player symbols: X and O // Assign player symbols: X and O
func (t *TicTacToeRules) AssignPlayerSymbols(players []*structs.Player) { func (t *TicTacToeRules) AssignPlayerSymbols(players []*structs.Player) {
if len(players) < 2 { if len(players) < 2 {
@@ -45,13 +51,18 @@ func (t *TicTacToeRules) ValidateMove(state *structs.MatchState, playerIdx int,
r := int(row) r := int(row)
c := int(col) c := int(col)
b := state.Boards["tictactoe"]
if b == nil {
return false
}
// bounds // bounds
if !state.Board.InBounds(r, c) { if !b.InBounds(r, c) {
return false return false
} }
// empty? // empty?
return state.Board.IsEmpty(r, c) return b.IsEmpty(r, c)
} }
// ApplyMove writes X or O to the board. // ApplyMove writes X or O to the board.
@@ -60,12 +71,17 @@ func (t *TicTacToeRules) ApplyMove(
playerIdx int, playerIdx int,
payload MovePayload, payload MovePayload,
) (bool, bool, int) { ) (bool, bool, int) {
b := state.Boards["tictactoe"]
if b == nil {
return false, false, -1
}
symbol := state.Players[playerIdx].Metadata["symbol"] symbol := state.Players[playerIdx].Metadata["symbol"]
r := int(payload.Data["row"].(float64)) r := int(payload.Data["row"].(float64))
c := int(payload.Data["col"].(float64)) c := int(payload.Data["col"].(float64))
state.Board.Set(r, c, symbol) b.Set(r, c, symbol)
over, winner := t.CheckGameOver(state) over, winner := t.CheckGameOver(state)
@@ -75,8 +91,12 @@ func (t *TicTacToeRules) ApplyMove(
// CheckGameOver determines win/draw state. // CheckGameOver determines win/draw state.
func (t *TicTacToeRules) CheckGameOver(state *structs.MatchState) (bool, int) { func (t *TicTacToeRules) CheckGameOver(state *structs.MatchState) (bool, int) {
b := state.Boards["tictactoe"]
if b == nil {
return true, -1 // fallback safety
}
winnerSymbol := t.findWinner(state.Board) winnerSymbol := t.findWinner(b)
if winnerSymbol != "" { if winnerSymbol != "" {
// find the player with this symbol // find the player with this symbol
@@ -88,7 +108,7 @@ func (t *TicTacToeRules) CheckGameOver(state *structs.MatchState) (bool, int) {
return true, -1 return true, -1
} }
if state.Board.Full() { if b.Full() {
return true, -1 // draw return true, -1 // draw
} }

View File

@@ -63,18 +63,6 @@ func indexOfPlayerByID(players []*structs.Player, userID string) int {
return -1 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 // Match interface methods
// ------------------------- // -------------------------
@@ -136,7 +124,7 @@ func (m *GenericMatch) MatchInit(
// ---- 6. create initial state (board from config) ---- // ---- 6. create initial state (board from config) ----
state := &structs.MatchState{ state := &structs.MatchState{
Players: []*structs.Player{}, Players: []*structs.Player{},
Board: newEmptyBoard(cfg.Board.Rows, cfg.Board.Cols), Boards: map[string]*structs.Board{}, // empty, will be filled later
Turn: 0, Turn: 0,
Winner: -1, Winner: -1,
GameOver: false, GameOver: false,
@@ -217,6 +205,9 @@ func (m *GenericMatch) MatchJoin(
// Assign player symbols/colors/etc. Pass structs.Player directly. // Assign player symbols/colors/etc. Pass structs.Player directly.
m.Rules.AssignPlayerSymbols(s.Players) m.Rules.AssignPlayerSymbols(s.Players)
// Initialize boards using game rules
s.Boards = m.Rules.InitBoards(s.Players, m.Config)
// Broadcast initial state // Broadcast initial state
if data, err := json.Marshal(s); err == nil { if data, err := json.Marshal(s); err == nil {
if err := dispatcher.BroadcastMessage(OpState, data, nil, nil, true); err != nil { if err := dispatcher.BroadcastMessage(OpState, data, nil, nil, true); err != nil {

View File

@@ -2,9 +2,9 @@ package structs
// MatchState holds the full game session state. // MatchState holds the full game session state.
type MatchState struct { type MatchState struct {
Players []*Player `json:"players"` Players []*Player `json:"players"`
Board *Board `json:"board"` Boards map[string]*Board `json:"boards"` // Multiple named boards:
Turn int `json:"turn"` // index in Players[] Turn int `json:"turn"` // index in Players[]
Winner int `json:"winner"` // -1 = none, >=0 = winner index Winner int `json:"winner"` // -1 = none, >=0 = winner index
GameOver bool `json:"game_over"` // true when the match ends GameOver bool `json:"game_over"` // true when the match ends
} }