- Added matchmaker configuration to local.yml with support for string property "mode" - Enabled faster matchmaking via interval_ms and large ticket limit - Broadcast initial match state when both players join - Added detailed validation and logging for move processing - Broadcast game-over and forfeit states immediately on player leave - Improved MatchLoop robustness with change tracking and clearer diagnostics
343 lines
7.6 KiB
Go
343 lines
7.6 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
|
|
"github.com/heroiclabs/nakama-common/runtime"
|
|
)
|
|
|
|
const (
|
|
OpMove int64 = 1
|
|
OpState int64 = 2
|
|
)
|
|
|
|
// Server-side game state
|
|
type MatchState struct {
|
|
Board [3][3]string `json:"board"`
|
|
Players []string `json:"players"`
|
|
Turn int `json:"turn"` // index in Players
|
|
Winner string `json:"winner"` // "X", "O", "draw", "forfeit"
|
|
GameOver bool `json:"game_over"` // true when finished
|
|
}
|
|
|
|
// Struct that implements runtime.Match
|
|
type TicTacToeMatch struct{}
|
|
|
|
// Factory for RegisterMatch
|
|
func NewMatch(
|
|
ctx context.Context,
|
|
logger runtime.Logger,
|
|
db *sql.DB,
|
|
nk runtime.NakamaModule,
|
|
) (runtime.Match, error) {
|
|
logger.Info("TicTacToe NewMatch factory called")
|
|
return &TicTacToeMatch{}, nil
|
|
}
|
|
|
|
// ---- MatchInit ----
|
|
// Return initial state, tick rate (ticks/sec), and label
|
|
func (m *TicTacToeMatch) MatchInit(
|
|
ctx context.Context,
|
|
logger runtime.Logger,
|
|
db *sql.DB,
|
|
nk runtime.NakamaModule,
|
|
params map[string]interface{},
|
|
) (interface{}, int, string) {
|
|
|
|
state := &MatchState{
|
|
Board: [3][3]string{},
|
|
Players: []string{},
|
|
Turn: 0,
|
|
Winner: "",
|
|
GameOver: false,
|
|
}
|
|
|
|
tickRate := 5 // 5 ticks per second (~200ms)
|
|
label := "tictactoe"
|
|
|
|
logger.Info("TicTacToe MatchInit: tickRate=%v label=%s", tickRate, label)
|
|
return state, tickRate, label
|
|
}
|
|
|
|
// ---- MatchJoinAttempt ----
|
|
func (m *TicTacToeMatch) 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.(*MatchState)
|
|
|
|
if len(s.Players) >= 2 {
|
|
return s, false, "match full"
|
|
}
|
|
|
|
return s, true, ""
|
|
}
|
|
|
|
// ---- MatchJoin ----
|
|
func (m *TicTacToeMatch) 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.(*MatchState)
|
|
|
|
for _, p := range presences {
|
|
userID := p.GetUserId()
|
|
// avoid duplicates
|
|
if indexOf(s.Players, userID) == -1 {
|
|
s.Players = append(s.Players, userID)
|
|
}
|
|
}
|
|
|
|
logger.Info("MatchJoin: now %d players", len(s.Players))
|
|
|
|
// If we have enough players to start, broadcast initial state immediately
|
|
if len(s.Players) == 2 {
|
|
stateJSON, err := json.Marshal(s)
|
|
if err != nil {
|
|
logger.Error("Failed to marshal state on join: %v", err)
|
|
} else {
|
|
if err := dispatcher.BroadcastMessage(OpState, stateJSON, nil, nil, true); err != nil {
|
|
logger.Error("BroadcastMessage (initial state) failed: %v", err)
|
|
} else {
|
|
logger.Info("Broadcasted initial state to players")
|
|
}
|
|
}
|
|
}
|
|
|
|
return s
|
|
}
|
|
|
|
// ---- MatchLeave ----
|
|
func (m *TicTacToeMatch) 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.(*MatchState)
|
|
|
|
// End the game if anyone leaves
|
|
if !s.GameOver {
|
|
s.GameOver = true
|
|
s.Winner = "forfeit"
|
|
logger.Info("MatchLeave: game ended by forfeit")
|
|
|
|
// broadcast final state so clients see the forfeit
|
|
stateJSON, err := json.Marshal(s)
|
|
if err != nil {
|
|
logger.Error("Failed to marshal state on leave: %v", err)
|
|
} else {
|
|
if err := dispatcher.BroadcastMessage(OpState, stateJSON, nil, nil, true); err != nil {
|
|
logger.Error("BroadcastMessage (forfeit) failed: %v", err)
|
|
} else {
|
|
logger.Info("Broadcasted forfeit state to remaining players")
|
|
}
|
|
}
|
|
}
|
|
|
|
return s
|
|
}
|
|
|
|
// ---- MatchLoop ----
|
|
func (m *TicTacToeMatch) 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.(*MatchState)
|
|
|
|
if s.GameOver {
|
|
return s
|
|
}
|
|
|
|
changed := false
|
|
|
|
for _, msg := range messages {
|
|
if msg.GetOpCode() != OpMove {
|
|
logger.Debug("Ignoring non-move opcode: %d", msg.GetOpCode())
|
|
continue
|
|
}
|
|
|
|
var move struct {
|
|
Row int `json:"row"`
|
|
Col int `json:"col"`
|
|
}
|
|
|
|
if err := json.Unmarshal(msg.GetData(), &move); err != nil {
|
|
logger.Warn("Invalid move payload from %s: %v", msg.GetUserId(), err)
|
|
continue
|
|
}
|
|
|
|
playerID := msg.GetUserId()
|
|
playerIdx := indexOf(s.Players, playerID)
|
|
logger.Info("Received move from %s (playerIdx=%d): row=%d col=%d", playerID, playerIdx, move.Row, move.Col)
|
|
|
|
if playerIdx == -1 {
|
|
logger.Warn("Move rejected: player %s not in player list", playerID)
|
|
continue
|
|
}
|
|
|
|
if playerIdx != s.Turn {
|
|
logger.Warn("Move rejected: not player's turn (playerIdx=%d turn=%d)", playerIdx, s.Turn)
|
|
continue
|
|
}
|
|
|
|
if move.Row < 0 || move.Row > 2 || move.Col < 0 || move.Col > 2 {
|
|
logger.Warn("Move rejected: out of bounds (%d,%d)", move.Row, move.Col)
|
|
continue
|
|
}
|
|
|
|
if s.Board[move.Row][move.Col] != "" {
|
|
logger.Warn("Move rejected: cell already occupied (%d,%d)", move.Row, move.Col)
|
|
continue
|
|
}
|
|
|
|
symbols := []string{"X", "O"}
|
|
if playerIdx < 0 || playerIdx >= len(symbols) {
|
|
logger.Warn("Move rejected: invalid player index %d", playerIdx)
|
|
continue
|
|
}
|
|
|
|
// Apply move
|
|
s.Board[move.Row][move.Col] = symbols[playerIdx]
|
|
changed = true
|
|
logger.Info("Move applied for player %s -> %s at (%d,%d)", playerID, symbols[playerIdx], move.Row, move.Col)
|
|
|
|
// Check win/draw
|
|
if winner := checkWinner(s.Board); winner != "" {
|
|
s.Winner = winner
|
|
s.GameOver = true
|
|
logger.Info("Game over! Winner: %s", winner)
|
|
} else if fullBoard(s.Board) {
|
|
s.Winner = "draw"
|
|
s.GameOver = true
|
|
logger.Info("Game over! Draw")
|
|
} else {
|
|
s.Turn = 1 - s.Turn
|
|
logger.Info("Turn advanced to %d", s.Turn)
|
|
}
|
|
}
|
|
|
|
// If anything changed (or periodically if you want), broadcast updated state to everyone
|
|
if changed {
|
|
stateJSON, err := json.Marshal(s)
|
|
if err != nil {
|
|
logger.Error("Failed to marshal state: %v", err)
|
|
} else {
|
|
if err := dispatcher.BroadcastMessage(OpState, stateJSON, nil, nil, true); err != nil {
|
|
logger.Error("BroadcastMessage failed: %v", err)
|
|
} else {
|
|
logger.Info("Broadcasted updated state to players")
|
|
}
|
|
}
|
|
}
|
|
|
|
return s
|
|
}
|
|
|
|
// ---- MatchTerminate ----
|
|
func (m *TicTacToeMatch) 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 (not used, but required) ----
|
|
func (m *TicTacToeMatch) 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.Info("MatchSignal: %s", data)
|
|
// no-op; just echo back
|
|
return state, ""
|
|
}
|
|
|
|
// ---- Helpers ----
|
|
func indexOf(arr []string, v string) int {
|
|
for i, s := range arr {
|
|
if s == v {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func checkWinner(b [3][3]string) string {
|
|
lines := [][][2]int{
|
|
{{0, 0}, {0, 1}, {0, 2}},
|
|
{{1, 0}, {1, 1}, {1, 2}},
|
|
{{2, 0}, {2, 1}, {2, 2}},
|
|
{{0, 0}, {1, 0}, {2, 0}},
|
|
{{0, 1}, {1, 1}, {2, 1}},
|
|
{{0, 2}, {1, 2}, {2, 2}},
|
|
{{0, 0}, {1, 1}, {2, 2}},
|
|
{{0, 2}, {1, 1}, {2, 0}},
|
|
}
|
|
|
|
for _, l := range lines {
|
|
a, b2, c := l[0], l[1], l[2]
|
|
if b[a[0]][a[1]] != "" &&
|
|
b[a[0]][a[1]] == b[b2[0]][b2[1]] &&
|
|
b[a[0]][a[1]] == b[c[0]][c[1]] {
|
|
return b[a[0]][a[1]]
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func fullBoard(b [3][3]string) bool {
|
|
for _, row := range b {
|
|
for _, v := range row {
|
|
if v == "" {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|