Files
tic-tac-toe/plugins/modules/match.go
Vishesh 'ironeagle' Bangotra bcdc5faea5 feat(core): migrate to multi-board architecture and implement per-game InitBoards
### Major changes
- Replace single-board MatchState (`Board`) with multi-board map (`Boards`)
- Update GenericMatch to initialize empty Boards and populate them via GameRules.InitBoards
- Remove legacy `newEmptyBoard` helper from match.go
- Update .gitignore to include *BKP* patterns

### GameRules interface
- Add InitBoards(players, cfg) to allow games to construct their own board sets
- Add detailed documentation explaining method responsibilities and usage
- Improve MovePayload comment clarity

### TicTacToe updates
- Implement InitBoards to produce a single `"tictactoe"` board
- Replace all old references to `state.Board` with `state.Boards["tictactoe"]`
- Make CheckGameOver, ValidateMove, and ApplyMove multi-board compatible

### Battleship updates
- Implement InitBoards generating per-player ships + shots boards:
  - p0_ships, p0_shots
  - p1_ships, p1_shots

### Match flow updates
- Boards are now created only when all players have joined
- Initial state broadcast now includes `boards` instead of `board`

This completes the backend migration for multi-board games and prepares the architecture
for Battleship and other complex board-based games.
2025-12-03 17:52:27 +05:30

381 lines
9.0 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 := m.Rules.ApplyMove(s, playerIdx, payload)
if stateChanged {
changed = true
}
if gameOver {
s.GameOver = true
s.Winner = winnerIdx
} else {
if 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, ""
}