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.
This commit is contained in:
2025-12-03 18:09:49 +05:30
parent bcdc5faea5
commit d75dcd3c74
4 changed files with 58 additions and 54 deletions

View File

@@ -81,22 +81,7 @@ func (b *BattleshipRules) InitBoards(players []*structs.Player, cfg GameConfigur
// 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)
}
// 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)
}
// nothing needed for classic mode
}
// ------------------------------
@@ -105,72 +90,87 @@ 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)
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
// ------------------------------
// can't shoot same cell twice
if !shotBoard.IsEmpty(r, c) {
return false
}
return true
}
// -----------------------------
// APPLY MOVE (MODE B — CLASSIC)
// -----------------------------
func (b *BattleshipRules) ApplyMove(
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) {
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
}
// ------------------------------
// 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

@@ -22,7 +22,11 @@ type GameRules interface {
// 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:

View File

@@ -70,10 +70,10 @@ 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
return false, false, -1, false
}
symbol := state.Players[playerIdx].Metadata["symbol"]
@@ -85,7 +85,7 @@ func (t *TicTacToeRules) ApplyMove(
over, winner := t.CheckGameOver(state)
return true, over, winner
return true, over, winner, false
}

View File

@@ -318,7 +318,7 @@ func (m *GenericMatch) MatchLoop(
}
// 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
@@ -328,7 +328,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)
}
}