### 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.
381 lines
9.1 KiB
Go
381 lines
9.1 KiB
Go
package modules
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"github.com/heroiclabs/nakama-common/runtime"
|
|
"localrepo/plugins/structs"
|
|
"localrepo/plugins/games"
|
|
)
|
|
|
|
const (
|
|
OpMove int64 = 1
|
|
OpState int64 = 2
|
|
)
|
|
|
|
// GenericMatch is a match implementation that delegates game-specific logic
|
|
// to a game.GameRules implementation chosen by the match params ("game").
|
|
type GenericMatch struct {
|
|
// Registry provided when creating the match factory. Keeps available rules.
|
|
Registry map[string]games.GameRules
|
|
|
|
GameName string
|
|
Mode string
|
|
Config games.GameConfiguration
|
|
Rules games.GameRules
|
|
}
|
|
|
|
// NewGenericMatch returns a factory function suitable for RegisterMatch.
|
|
// Provide a registry mapping game names (strings) to implementations.
|
|
func NewGenericMatch(
|
|
registry map[string]games.GameRules,
|
|
) func(
|
|
ctx context.Context,
|
|
logger runtime.Logger,
|
|
db *sql.DB,
|
|
nk runtime.NakamaModule,
|
|
) (runtime.Match, error) {
|
|
|
|
return func(
|
|
ctx context.Context,
|
|
logger runtime.Logger,
|
|
db *sql.DB,
|
|
nk runtime.NakamaModule,
|
|
) (runtime.Match, error) {
|
|
// The factory stores the registry on each match instance so MatchInit can use it.
|
|
return &GenericMatch{Registry: registry}, nil
|
|
}
|
|
}
|
|
|
|
// -------------------------
|
|
// Helpers
|
|
// -------------------------
|
|
|
|
func indexOfPlayerByID(players []*structs.Player, userID string) int {
|
|
for i, p := range players {
|
|
if p.UserID == userID {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// -------------------------
|
|
// Match interface methods
|
|
// -------------------------
|
|
|
|
// MatchInit: create initial state. Expects params to include "game" (string) and optionally "mode".
|
|
func (m *GenericMatch) MatchInit(
|
|
ctx context.Context,
|
|
logger runtime.Logger,
|
|
db *sql.DB,
|
|
nk runtime.NakamaModule,
|
|
params map[string]interface{},
|
|
) (interface{}, int, string) {
|
|
|
|
// ---- 1. game REQUIRED ----
|
|
raw, ok := params["game"]
|
|
if !ok {
|
|
logger.Error("MatchInit ERROR: missing param 'game'")
|
|
return nil, 0, ""
|
|
}
|
|
gameName, ok := raw.(string)
|
|
if !ok || gameName == "" {
|
|
logger.Error("MatchInit ERROR: invalid 'game' param")
|
|
return nil, 0, ""
|
|
}
|
|
|
|
// ---- 2. config lookup ----
|
|
cfg, found := games.GameConfig[gameName]
|
|
if !found {
|
|
logger.Error("MatchInit ERROR: game '%s' not in GameConfig", gameName)
|
|
return nil, 0, ""
|
|
}
|
|
|
|
// ---- 3. rules lookup from registry (factory-provided) ----
|
|
var rules games.GameRules
|
|
if m.Registry != nil {
|
|
if r, ok := m.Registry[gameName]; ok {
|
|
rules = r
|
|
}
|
|
}
|
|
|
|
if rules == nil {
|
|
// no rules — abort match creation
|
|
logger.Error("MatchInit ERROR: no rules registered for game '%s'", gameName)
|
|
return nil, 0, ""
|
|
}
|
|
|
|
// ---- 4. mode (optional) ----
|
|
mode := "default"
|
|
if md, ok := params["mode"].(string); ok && md != "" {
|
|
mode = md
|
|
}
|
|
|
|
// ---- 5. build match instance fields ----
|
|
m.GameName = gameName
|
|
m.Mode = mode
|
|
m.Config = cfg
|
|
m.Rules = rules
|
|
|
|
// ---- 6. create initial state (board from config) ----
|
|
state := &structs.MatchState{
|
|
Players: []*structs.Player{},
|
|
Boards: map[string]*structs.Board{}, // empty, will be filled later
|
|
Turn: 0,
|
|
Winner: -1,
|
|
GameOver: false,
|
|
}
|
|
|
|
label := fmt.Sprintf("%s:%s", m.GameName, m.Mode)
|
|
|
|
logger.Info("MatchInit OK — game=%s mode=%s players=%d board=%dx%d", m.GameName, m.Mode, cfg.Players, cfg.Board.Rows, cfg.Board.Cols)
|
|
|
|
// Tick rate 5 (200ms) is a sensible default; can be tuned per game.
|
|
return state, 5, label
|
|
}
|
|
|
|
// MatchJoinAttempt: basic capacity check using config.Players
|
|
func (m *GenericMatch) MatchJoinAttempt(
|
|
ctx context.Context,
|
|
logger runtime.Logger,
|
|
db *sql.DB,
|
|
nk runtime.NakamaModule,
|
|
dispatcher runtime.MatchDispatcher,
|
|
tick int64,
|
|
state interface{},
|
|
presence runtime.Presence,
|
|
metadata map[string]string,
|
|
) (interface{}, bool, string) {
|
|
|
|
s := state.(*structs.MatchState)
|
|
|
|
if m.Config.Players <= 0 {
|
|
// defensive: require init to have populated config
|
|
logger.Error("MatchJoinAttempt ERROR: match config not initialized")
|
|
return s, false, "server error"
|
|
}
|
|
|
|
if len(s.Players) >= m.Config.Players {
|
|
return s, false, "match full"
|
|
}
|
|
|
|
return s, true, ""
|
|
}
|
|
|
|
// MatchJoin: add players, fetch usernames, assign indices; when full, call rules.AssignPlayerSymbols
|
|
func (m *GenericMatch) MatchJoin(
|
|
ctx context.Context,
|
|
logger runtime.Logger,
|
|
db *sql.DB,
|
|
nk runtime.NakamaModule,
|
|
dispatcher runtime.MatchDispatcher,
|
|
tick int64,
|
|
state interface{},
|
|
presences []runtime.Presence,
|
|
) interface{} {
|
|
|
|
s := state.(*structs.MatchState)
|
|
|
|
for _, p := range presences {
|
|
userID := p.GetUserId()
|
|
if indexOfPlayerByID(s.Players, userID) != -1 {
|
|
continue
|
|
}
|
|
|
|
username := ""
|
|
if acc, err := nk.AccountGetId(ctx, userID); err == nil && acc != nil && acc.GetUser() != nil {
|
|
username = acc.GetUser().GetUsername()
|
|
}
|
|
|
|
s.Players = append(s.Players, &structs.Player{
|
|
UserID: userID,
|
|
Username: username,
|
|
Index: len(s.Players),
|
|
Metadata: map[string]string{},
|
|
})
|
|
}
|
|
|
|
logger.Info("MatchJoin: now %d players (need %d)", len(s.Players), m.Config.Players)
|
|
|
|
if m.Rules != nil && len(s.Players) == m.Config.Players {
|
|
// 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 {
|
|
logger.Error("BroadcastMessage (initial state) failed: %v", err)
|
|
}
|
|
} else {
|
|
logger.Error("Failed to marshal initial state: %v", err)
|
|
}
|
|
}
|
|
|
|
return s
|
|
}
|
|
|
|
// MatchLeave: mark forfeit and call game.ForfeitWinner
|
|
func (m *GenericMatch) MatchLeave(
|
|
ctx context.Context,
|
|
logger runtime.Logger,
|
|
db *sql.DB,
|
|
nk runtime.NakamaModule,
|
|
dispatcher runtime.MatchDispatcher,
|
|
tick int64,
|
|
state interface{},
|
|
presences []runtime.Presence,
|
|
) interface{} {
|
|
|
|
s := state.(*structs.MatchState)
|
|
|
|
if s.GameOver {
|
|
return s
|
|
}
|
|
|
|
// determine leaving player index from presence list
|
|
leaverIdx := -1
|
|
if len(presences) > 0 {
|
|
leaverIdx = indexOfPlayerByID(s.Players, presences[0].GetUserId())
|
|
}
|
|
|
|
if m.Rules != nil {
|
|
winner := m.Rules.ForfeitWinner(s, leaverIdx)
|
|
s.Winner = winner
|
|
s.GameOver = true
|
|
} else {
|
|
// fallback: end match as forfeit
|
|
s.GameOver = true
|
|
s.Winner = -1
|
|
}
|
|
|
|
// broadcast final state
|
|
if data, err := json.Marshal(s); err == nil {
|
|
if err := dispatcher.BroadcastMessage(OpState, data, nil, nil, true); err != nil {
|
|
logger.Error("BroadcastMessage (forfeit) failed: %v", err)
|
|
}
|
|
} else {
|
|
logger.Error("Failed to marshal forfeit state: %v", err)
|
|
}
|
|
|
|
return s
|
|
}
|
|
|
|
// MatchLoop: handle incoming move messages, delegate to the GameRules implementation
|
|
func (m *GenericMatch) MatchLoop(
|
|
ctx context.Context,
|
|
logger runtime.Logger,
|
|
db *sql.DB,
|
|
nk runtime.NakamaModule,
|
|
dispatcher runtime.MatchDispatcher,
|
|
tick int64,
|
|
state interface{},
|
|
messages []runtime.MatchData,
|
|
) interface{} {
|
|
|
|
s := state.(*structs.MatchState)
|
|
|
|
if s.GameOver {
|
|
return s
|
|
}
|
|
|
|
changed := false
|
|
|
|
if m.Rules == nil {
|
|
logger.Warn("MatchLoop: no rules present for game '%s' -- ignoring messages", m.GameName)
|
|
return s
|
|
}
|
|
|
|
for _, msg := range messages {
|
|
if msg.GetOpCode() != OpMove {
|
|
continue
|
|
}
|
|
|
|
var payload games.MovePayload
|
|
if err := json.Unmarshal(msg.GetData(), &payload); err != nil {
|
|
logger.Warn("Invalid move payload from %s: %v", msg.GetUserId(), err)
|
|
continue
|
|
}
|
|
|
|
playerID := msg.GetUserId()
|
|
playerIdx := indexOfPlayerByID(s.Players, playerID)
|
|
if playerIdx == -1 {
|
|
logger.Warn("Move rejected: unknown player %s", playerID)
|
|
continue
|
|
}
|
|
|
|
// 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 {
|
|
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, keepTurn := m.Rules.ApplyMove(s, playerIdx, payload)
|
|
|
|
if stateChanged {
|
|
changed = true
|
|
}
|
|
|
|
if gameOver {
|
|
s.GameOver = true
|
|
s.Winner = winnerIdx
|
|
} else {
|
|
if !keepTurn && len(s.Players) > 0 {
|
|
s.Turn = (s.Turn + 1) % len(s.Players)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Optional: handle leaderboard logic here if needed
|
|
|
|
if changed {
|
|
if data, err := json.Marshal(s); err == nil {
|
|
if err := dispatcher.BroadcastMessage(OpState, data, nil, nil, true); err != nil {
|
|
logger.Error("BroadcastMessage failed: %v", err)
|
|
}
|
|
} else {
|
|
logger.Error("Failed to marshal state: %v", err)
|
|
}
|
|
}
|
|
|
|
return s
|
|
}
|
|
|
|
// MatchTerminate
|
|
func (m *GenericMatch) MatchTerminate(
|
|
ctx context.Context,
|
|
logger runtime.Logger,
|
|
db *sql.DB,
|
|
nk runtime.NakamaModule,
|
|
dispatcher runtime.MatchDispatcher,
|
|
tick int64,
|
|
state interface{},
|
|
graceSeconds int,
|
|
) interface{} {
|
|
logger.Info("MatchTerminate: grace=%d", graceSeconds)
|
|
return state
|
|
}
|
|
|
|
// MatchSignal
|
|
func (m *GenericMatch) MatchSignal(
|
|
ctx context.Context,
|
|
logger runtime.Logger,
|
|
db *sql.DB,
|
|
nk runtime.NakamaModule,
|
|
dispatcher runtime.MatchDispatcher,
|
|
tick int64,
|
|
state interface{},
|
|
data string,
|
|
) (interface{}, string) {
|
|
logger.Debug("MatchSignal: %s", data)
|
|
return state, ""
|
|
}
|