5 Commits

Author SHA1 Message Date
f1e85a72dd feat(rules): add game-specific metadata attachment and unify match metadata initialization
Added AttachGameMetadata to GameRules interface

Implemented metadata setup for Battleship (phase + readiness flags)

Implemented no-op metadata hook for TicTacToe

Moved generic phase/ready metadata out of MatchInit

Added game/mode metadata to match state

Fixed json:"metadata" tag in MatchState
2025-12-03 22:02:24 +05:30
0562d1e0c9 feat(battleship): add full placement/battle phases + metadata support + action-based moves
- Added Battleship Fleet definition (carrier, battleship, cruiser, submarine, destroyer)
- Implemented action-based MovePayload (`action: "place" | "shoot"`)
- Added placement and shot validation (ValidatePlacementMove, ValidateShotMove)
- Added ApplyPlacement and ApplyShot with correct ship placement + hit/miss logic
- Added pX_placed, pX_ready tracking and phase switching (placement → battle)
- Added Metadata field to MatchState (for phase/ready tracking)
- Updated MatchInit to initialize placement phase and readiness flags
- Updated MatchLoop to enforce turn order only during battle phase
- Added debug logging for state broadcasts
- Fixed protobuf dependency marking as indirect
2025-12-03 21:00:38 +05:30
10c7933aca refactor to tictactoe for standardisation of game names 2025-12-03 18:44:09 +05:30
d75dcd3c74 feat(battleship,tictactoe,engine): add multi-board support and keepTurn logic
### Core Engine
- Updated `GameRules.ApplyMove` to return `(changed, gameOver, winnerIdx, keepTurn)`
- Added keepTurn handling in `MatchLoop` to support Battleship mode B (classic rules)
- Removed old single-board handling from MatchState and MatchInit
- Cleaned go.mod by marking protobuf dependency as indirect

### Battleship
- Implemented board-based state tracking using MatchState.Boards:
  - `p0_ships`, `p0_shots`, `p1_ships`, `p1_shots`
- Removed legacy metadata-based ship/shot board encoding
- Rewrote ValidateMove to use structured boards
- Rewrote ApplyMove for classic Battleship rules (mode B):
  - Hits allow the attacker to keep their turn
  - Miss switches turn
  - Destroyed ship sections marked `X`
- Improved CheckGameOver using structured boards

### TicTacToe
- Updated ApplyMove signature to match new interface
- Ensured TicTacToe always returns `keepTurn = false`
- Updated code paths to use MatchState.Boards instead of Board

### Summary
This commit completes the migration from a single-board architecture to a
multi-board architecture across the engine, TicTacToe, and Battleship, enabling
support for more complex games and multiple modes such as Battleship Mode B.
2025-12-03 18:09:49 +05:30
bcdc5faea5 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.
2025-12-03 17:52:27 +05:30
5 changed files with 300 additions and 78 deletions

View File

@@ -6,6 +6,14 @@ import (
"localrepo/plugins/structs"
)
var Fleet = map[string]int{
"carrier": 5,
"battleship": 4,
"cruiser": 3,
"submarine": 3,
"destroyer": 2,
}
//
// BATTLESHIP RULES IMPLEMENTATION
//
@@ -62,26 +70,35 @@ type BattleshipRules struct{}
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
// ------------------------------
func (b *BattleshipRules) AssignPlayerSymbols(players []*structs.Player) {
// Battleship has no symbols like X/O,
// but we use this hook to initialize per-player boards.
for _, p := range players {
// 10x10 boards
empty := make([][]string, 10)
for r := range empty {
empty[r] = make([]string, 10)
// nothing needed for battleship
}
// ship board → players place ships manually via a "setup" phase
p.Metadata["ship_board"] = encodeBoard(empty)
// shot board → empty grid that tracks hits/misses
p.Metadata["shot_board"] = encodeBoard(empty)
}
// ------------------------------
// Attach Game Metadata
// ------------------------------
func (b *BattleshipRules) AttachGameMetadata(state *structs.MatchState) {
state.Metadata["phase"] = "placement"
state.Metadata["p0_ready"] = false
state.Metadata["p1_ready"] = false
}
// ------------------------------
@@ -90,72 +107,226 @@ func (b *BattleshipRules) AssignPlayerSymbols(players []*structs.Player) {
// ------------------------------
func (b *BattleshipRules) ValidateMove(state *structs.MatchState, playerIdx int, payload MovePayload) bool {
rF, ok1 := payload.Data["row"].(float64)
cF, ok2 := payload.Data["col"].(float64)
switch payload.Action {
case "place":
return b.ValidatePlacementMove(state, playerIdx, payload)
case "shoot":
return b.ValidateShotMove(state, playerIdx, payload)
default:
return false
}
}
func (b *BattleshipRules) ValidatePlacementMove(state *structs.MatchState, playerIdx int, payload MovePayload) bool {
// Allow placement until player placed all ships
if state.Metadata["phase"] != "placement" {
return false
}
key := fmt.Sprintf("p%d_placed", playerIdx)
placed := 0
if state.Metadata[key] != nil {
placed = state.Metadata[key].(int)
}
return placed < len(Fleet)
}
func (b *BattleshipRules) ValidateShotMove(state *structs.MatchState, playerIdx int, payload MovePayload) bool {
if state.Metadata["phase"] != "battle" {
return false
}
rf, ok1 := payload.Data["row"].(float64)
cf, ok2 := payload.Data["col"].(float64)
if !ok1 || !ok2 {
return false
}
r := int(rF)
c := int(cF)
r := int(rf)
c := int(cf)
if r < 0 || r > 9 || c < 0 || c > 9 {
shotKey := fmt.Sprintf("p%d_shots", playerIdx)
shotBoard := state.Boards[shotKey]
if shotBoard == nil {
return false
}
// Check if this spot was already shot before
shotBoard := decodeBoard(state.Players[playerIdx].Metadata["shot_board"])
return shotBoard[r][c] == ""
if !shotBoard.InBounds(r, c) {
return false
}
// ------------------------------
// ApplyMove
// ------------------------------
func (b *BattleshipRules) ApplyMove(
if !shotBoard.IsEmpty(r, c) { // already shot
return false
}
return true
}
// -----------------------------
// APPLY MOVE (MODE B — CLASSIC)
// -----------------------------
func (b *BattleshipRules) ApplyShot(
state *structs.MatchState,
playerIdx int,
payload MovePayload,
) (bool, bool, int) {
attacker := state.Players[playerIdx]
defenderIdx := 1 - playerIdx
defender := state.Players[defenderIdx]
) (bool, bool, int, bool) {
if !b.bothPlayersReady(state) {
return false, false, -1, false
}
r := int(payload.Data["row"].(float64))
c := int(payload.Data["col"].(float64))
shotBoard := decodeBoard(attacker.Metadata["shot_board"])
shipBoard := decodeBoard(defender.Metadata["ship_board"])
shotKey := fmt.Sprintf("p%d_shots", playerIdx)
shipKey := fmt.Sprintf("p%d_ships", 1-playerIdx)
if shipBoard[r][c] == "S" {
shotBoard[r][c] = "H"
shipBoard[r][c] = "X"
shots := state.Boards[shotKey]
ships := state.Boards[shipKey]
hit := false
if ships.Get(r, c) == "S" {
// hit
hit = true
shots.Set(r, c, "H")
ships.Set(r, c, "X") // mark destroyed section
} else {
shotBoard[r][c] = "M"
shots.Set(r, c, "M")
}
attacker.Metadata["shot_board"] = encodeBoard(shotBoard)
defender.Metadata["ship_board"] = encodeBoard(shipBoard)
// check game over
over, winner := b.CheckGameOver(state)
return true, over, winner
// keepTurn = hit (classic rule)
return true, over, winner, hit
}
func (b *BattleshipRules) ApplyMove(
state *structs.MatchState,
playerIdx int,
payload MovePayload,
) (bool, bool, int, bool) {
switch payload.Action {
case "place":
return b.ApplyPlacement(state, playerIdx, payload)
case "shoot":
return b.ApplyShot(state, playerIdx, payload)
default:
return false, false, -1, false
}
}
func (b *BattleshipRules) ApplyPlacement(
state *structs.MatchState,
playerIdx int,
payload MovePayload,
) (bool, bool, int, bool) {
shipName, _ := payload.Data["ship"].(string)
rf, _ := payload.Data["row"].(float64)
cf, _ := payload.Data["col"].(float64)
dir, _ := payload.Data["dir"].(string)
r := int(rf)
c := int(cf)
size, ok := Fleet[shipName]
if !ok {
return false, false, -1, false // invalid ship name
}
shipKey := fmt.Sprintf("p%d_ships", playerIdx)
shipBoard := state.Boards[shipKey]
// Validate placement
if !b.validatePlacement(shipBoard, r, c, size, dir) {
return false, false, -1, false
}
// Place the ship
if dir == "h" {
for i := 0; i < size; i++ {
shipBoard.Set(r, c+i, "S")
}
} else { // vertical
for i := 0; i < size; i++ {
shipBoard.Set(r+i, c, "S")
}
}
// Track ships placed by player
placedCountKey := fmt.Sprintf("p%d_placed", playerIdx)
count := state.Metadata[placedCountKey]
if count == nil {
state.Metadata[placedCountKey] = 1
} else {
state.Metadata[placedCountKey] = count.(int) + 1
}
// If all 5 ships placed → ready
if state.Metadata[placedCountKey].(int) == len(Fleet) {
readyKey := fmt.Sprintf("p%d_ready", playerIdx)
state.Metadata[readyKey] = true
}
// Check if both players are ready
if b.bothPlayersReady(state) {
state.Metadata["phase"] = "battle"
}
return true, false, -1, false
}
func (b *BattleshipRules) validatePlacement(board *structs.Board, r, c, size int, dir string) bool {
rows, cols := board.Rows, board.Cols
if dir == "h" {
if c+size > cols {
return false
}
for i := 0; i < size; i++ {
if board.Get(r, c+i) != "" {
return false
}
}
} else {
if r+size > rows {
return false
}
for i := 0; i < size; i++ {
if board.Get(r+i, c) != "" {
return false
}
}
}
return true
}
func (b *BattleshipRules) bothPlayersReady(state *structs.MatchState) bool {
r0 := state.Metadata["p0_ready"]
r1 := state.Metadata["p1_ready"]
return r0 == true && r1 == true
}
// ------------------------------
// CheckGameOver
// ------------------------------
func (b *BattleshipRules) CheckGameOver(state *structs.MatchState) (bool, int) {
for i, p := range state.Players {
ships := decodeBoard(p.Metadata["ship_board"])
for i := range state.Players {
shipKey := fmt.Sprintf("p%d_ships", i)
ships := state.Boards[shipKey]
alive := false
for r := range ships {
for c := range ships[r] {
if ships[r][c] == "S" {
for r := 0; r < ships.Rows; r++ {
for c := 0; c < ships.Cols; c++ {
if ships.Get(r, c) == "S" {
alive = true
break
}
}
if alive {
break
}
}
if !alive {

View File

@@ -2,11 +2,18 @@ package games
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 {
Action string `json:"action"` // "place" or "shoot"
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 {
// Number of players needed to start.
MaxPlayers() int
@@ -14,9 +21,15 @@ type GameRules interface {
// Assign symbols/colors/pieces at start.
AssignPlayerSymbols(players []*structs.Player)
// Attach Game Metadata
AttachGameMetadata(state *structs.MatchState)
// Apply a move.
// Returns: (changed, gameOver, winnerIndex)
ApplyMove(state *structs.MatchState, playerIdx int, payload MovePayload) (bool, bool, int)
ApplyMove(
state *structs.MatchState,
playerIdx int,
payload MovePayload,
) (changed bool, gameOver bool, winnerIdx int, keepTurn bool)
// If a player leaves, who wins?
// Return:
@@ -24,4 +37,17 @@ type GameRules interface {
// -1 → draw
// -2 → invalid
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
}
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
func (t *TicTacToeRules) AssignPlayerSymbols(players []*structs.Player) {
if len(players) < 2 {
@@ -25,6 +31,13 @@ func (t *TicTacToeRules) AssignPlayerSymbols(players []*structs.Player) {
players[1].Metadata["symbol"] = "O"
}
// ------------------------------
// Attach Game Metadata
// ------------------------------
func (b *TicTacToeRules) AttachGameMetadata(state *structs.MatchState) {
// nothing needed for tictactoe
}
// ValidateMove checks bounds and empty cell.
func (t *TicTacToeRules) ValidateMove(state *structs.MatchState, playerIdx int, payload MovePayload) bool {
@@ -45,13 +58,18 @@ func (t *TicTacToeRules) ValidateMove(state *structs.MatchState, playerIdx int,
r := int(row)
c := int(col)
b := state.Boards["tictactoe"]
if b == nil {
return false
}
// bounds
if !state.Board.InBounds(r, c) {
if !b.InBounds(r, c) {
return false
}
// empty?
return state.Board.IsEmpty(r, c)
return b.IsEmpty(r, c)
}
// ApplyMove writes X or O to the board.
@@ -59,24 +77,33 @@ func (t *TicTacToeRules) ApplyMove(
state *structs.MatchState,
playerIdx int,
payload MovePayload,
) (bool, bool, int) {
) (bool, bool, int, bool) {
b := state.Boards["tictactoe"]
if b == nil {
return false, false, -1, false
}
symbol := state.Players[playerIdx].Metadata["symbol"]
r := int(payload.Data["row"].(float64))
c := int(payload.Data["col"].(float64))
state.Board.Set(r, c, symbol)
b.Set(r, c, symbol)
over, winner := t.CheckGameOver(state)
return true, over, winner
return true, over, winner, false
}
// CheckGameOver determines win/draw state.
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 != "" {
// find the player with this symbol
@@ -88,7 +115,7 @@ func (t *TicTacToeRules) CheckGameOver(state *structs.MatchState) (bool, int) {
return true, -1
}
if state.Board.Full() {
if b.Full() {
return true, -1 // draw
}

View File

@@ -63,18 +63,6 @@ func indexOfPlayerByID(players []*structs.Player, userID string) int {
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
// -------------------------
@@ -136,11 +124,15 @@ func (m *GenericMatch) MatchInit(
// ---- 6. create initial state (board from config) ----
state := &structs.MatchState{
Players: []*structs.Player{},
Board: newEmptyBoard(cfg.Board.Rows, cfg.Board.Cols),
Boards: map[string]*structs.Board{}, // empty, will be filled later
Turn: 0,
Winner: -1,
GameOver: false,
Metadata: map[string]interface{}{},
}
state.Metadata["game"] = m.GameName
state.Metadata["mode"] = m.Mode
m.Rules.AttachGameMetadata(state)
label := fmt.Sprintf("%s:%s", m.GameName, m.Mode)
@@ -217,6 +209,9 @@ func (m *GenericMatch) MatchJoin(
// Assign player symbols/colors/etc. Pass structs.Player directly.
m.Rules.AssignPlayerSymbols(s.Players)
// Initialize boards using game rules
s.Boards = m.Rules.InitBoards(s.Players, m.Config)
// Broadcast initial state
if data, err := json.Marshal(s); err == nil {
if err := dispatcher.BroadcastMessage(OpState, data, nil, nil, true); err != nil {
@@ -321,13 +316,14 @@ func (m *GenericMatch) MatchLoop(
// Turn enforcement — keep this here for turn-based games. If you want per-game control,
// move this check into the game's ApplyMove implementation or toggle via config.
if playerIdx != s.Turn {
phase := s.Metadata["phase"]
if phase == "battle" && playerIdx != s.Turn {
logger.Warn("Move rejected: not player's turn (idx=%d turn=%d)", playerIdx, s.Turn)
continue
}
// Delegate to rules.ApplyMove which returns (changed, gameOver, winnerIndex)
stateChanged, gameOver, winnerIdx := m.Rules.ApplyMove(s, playerIdx, payload)
stateChanged, gameOver, winnerIdx, keepTurn := m.Rules.ApplyMove(s, playerIdx, payload)
if stateChanged {
changed = true
@@ -337,7 +333,7 @@ func (m *GenericMatch) MatchLoop(
s.GameOver = true
s.Winner = winnerIdx
} else {
if len(s.Players) > 0 {
if !keepTurn && len(s.Players) > 0 {
s.Turn = (s.Turn + 1) % len(s.Players)
}
}
@@ -347,6 +343,7 @@ func (m *GenericMatch) MatchLoop(
if changed {
if data, err := json.Marshal(s); err == nil {
logger.Info("Broadcasting state update (op=%d): %v", OpState, data)
if err := dispatcher.BroadcastMessage(OpState, data, nil, nil, true); err != nil {
logger.Error("BroadcastMessage failed: %v", err)
}

View File

@@ -3,8 +3,9 @@ package structs
// MatchState holds the full game session state.
type MatchState struct {
Players []*Player `json:"players"`
Board *Board `json:"board"`
Boards map[string]*Board `json:"boards"` // Multiple named boards:
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
Metadata map[string]interface{} `json:"metadata"` // metadata
}