Compare commits
5 Commits
main
...
multi-game
| Author | SHA1 | Date | |
|---|---|---|---|
| f1e85a72dd | |||
| 0562d1e0c9 | |||
| 10c7933aca | |||
| d75dcd3c74 | |||
| bcdc5faea5 |
@@ -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.
|
||||
// nothing needed for battleship
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
// ------------------------------
|
||||
// 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
|
||||
}
|
||||
|
||||
if !shotBoard.IsEmpty(r, c) { // already shot
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// ApplyMove
|
||||
// ------------------------------
|
||||
func (b *BattleshipRules) ApplyMove(
|
||||
// -----------------------------
|
||||
// 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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user