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:
178
plugins/games/battleship.go
Normal file
178
plugins/games/battleship.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"localrepo/plugins/structs"
|
||||
)
|
||||
|
||||
//
|
||||
// BATTLESHIP RULES IMPLEMENTATION
|
||||
//
|
||||
// NOTES:
|
||||
// - 2 players
|
||||
// - Each player has 2 boards:
|
||||
// 1. Their own ship board (state.Board is not reused here)
|
||||
// 2. Their "shots" board (hits/misses on opponent)
|
||||
// - We store boards in Player.Metadata as JSON strings
|
||||
// (simplest method without changing your structs).
|
||||
//
|
||||
|
||||
// ShipBoard and ShotBoard are encoded inside Metadata:
|
||||
//
|
||||
// Metadata["ship_board"] = JSON string of [][]string
|
||||
// Metadata["shot_board"] = JSON string of [][]string
|
||||
//
|
||||
|
||||
// ------------------------------
|
||||
// Helpers: encode/decode
|
||||
// ------------------------------
|
||||
|
||||
func encodeBoard(b [][]string) string {
|
||||
out := "["
|
||||
for i, row := range b {
|
||||
out += "["
|
||||
for j, col := range row {
|
||||
out += fmt.Sprintf("%q", col)
|
||||
if j < len(row)-1 {
|
||||
out += ","
|
||||
}
|
||||
}
|
||||
out += "]"
|
||||
if i < len(b)-1 {
|
||||
out += ","
|
||||
}
|
||||
}
|
||||
out += "]"
|
||||
return out
|
||||
}
|
||||
|
||||
func decodeBoard(s string) [][]string {
|
||||
var out [][]string
|
||||
// should never fail; safe fallback
|
||||
_ = json.Unmarshal([]byte(s), &out)
|
||||
return out
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// BattleshipRules
|
||||
// ------------------------------
|
||||
|
||||
type BattleshipRules struct{}
|
||||
|
||||
func (b *BattleshipRules) MaxPlayers() int { return 2 }
|
||||
|
||||
// ------------------------------
|
||||
// Assign player boards
|
||||
// ------------------------------
|
||||
func (b *BattleshipRules) AssignPlayerSymbols(players []*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)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// ValidateMove
|
||||
// payload.data = { "row": int, "col": int }
|
||||
// ------------------------------
|
||||
|
||||
func (b *BattleshipRules) ValidateMove(state *structs.MatchState, playerIdx int, payload MovePayload) bool {
|
||||
rF, ok1 := payload.Data["row"].(float64)
|
||||
cF, ok2 := payload.Data["col"].(float64)
|
||||
if !ok1 || !ok2 {
|
||||
return false
|
||||
}
|
||||
|
||||
r := int(rF)
|
||||
c := int(cF)
|
||||
|
||||
if r < 0 || r > 9 || c < 0 || c > 9 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if this spot was already shot before
|
||||
shotBoard := decodeBoard(state.Players[playerIdx].Metadata["shot_board"])
|
||||
return shotBoard[r][c] == ""
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// ApplyMove
|
||||
// ------------------------------
|
||||
func (b *BattleshipRules) ApplyMove(state *structs.MatchState, playerIdx int, payload MovePayload) {
|
||||
|
||||
attacker := state.Players[playerIdx]
|
||||
defenderIdx := 1 - playerIdx
|
||||
defender := state.Players[defenderIdx]
|
||||
|
||||
r := int(payload.Data["row"].(float64))
|
||||
c := int(payload.Data["col"].(float64))
|
||||
|
||||
shotBoard := decodeBoard(attacker.Metadata["shot_board"])
|
||||
shipBoard := decodeBoard(defender.Metadata["ship_board"])
|
||||
|
||||
if shipBoard[r][c] == "S" {
|
||||
// hit
|
||||
shotBoard[r][c] = "H"
|
||||
shipBoard[r][c] = "X" // ship cell destroyed
|
||||
} else {
|
||||
// miss
|
||||
shotBoard[r][c] = "M"
|
||||
}
|
||||
|
||||
// Save back
|
||||
attacker.Metadata["shot_board"] = encodeBoard(shotBoard)
|
||||
defender.Metadata["ship_board"] = encodeBoard(shipBoard)
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// CheckGameOver
|
||||
// ------------------------------
|
||||
func (b *BattleshipRules) CheckGameOver(state *structs.MatchState) (bool, int) {
|
||||
|
||||
for i, p := range state.Players {
|
||||
ships := decodeBoard(p.Metadata["ship_board"])
|
||||
|
||||
alive := false
|
||||
for r := range ships {
|
||||
for c := range ships[r] {
|
||||
if ships[r][c] == "S" {
|
||||
alive = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !alive {
|
||||
// this player has no ships left → opponent wins
|
||||
return true, 1 - i
|
||||
}
|
||||
}
|
||||
|
||||
return false, -1
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Forfeit Winner
|
||||
// ------------------------------
|
||||
func (b *BattleshipRules) ForfeitWinner(state *structs.MatchState, leaverIndex int) int {
|
||||
|
||||
// If player leaves, opponent automatically wins.
|
||||
if leaverIndex == 0 {
|
||||
return 1
|
||||
}
|
||||
if leaverIndex == 1 {
|
||||
return 0
|
||||
}
|
||||
return -1
|
||||
}
|
||||
Reference in New Issue
Block a user