- 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
387 lines
9.3 KiB
Go
387 lines
9.3 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,
|
|
Metadata: map[string]interface{}{},
|
|
}
|
|
state.Metadata["phase"] = "placement"
|
|
state.Metadata["p0_ready"] = false
|
|
state.Metadata["p1_ready"] = 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.
|
|
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, 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 {
|
|
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)
|
|
}
|
|
} 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, ""
|
|
}
|