Files
tic-tac-toe/plugins/match.go

287 lines
5.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))
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")
}
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
}
for _, msg := range messages {
if msg.GetOpCode() != OpMove {
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: %v", err)
continue
}
playerID := msg.GetUserId()
playerIdx := indexOf(s.Players, playerID)
if playerIdx != s.Turn {
// not your turn
continue
}
if move.Row < 0 || move.Row > 2 || move.Col < 0 || move.Col > 2 {
continue
}
if s.Board[move.Row][move.Col] != "" {
continue
}
symbols := []string{"X", "O"}
if playerIdx < 0 || playerIdx >= len(symbols) {
continue
}
s.Board[move.Row][move.Col] = symbols[playerIdx]
if winner := checkWinner(s.Board); winner != "" {
s.Winner = winner
s.GameOver = true
} else if fullBoard(s.Board) {
s.Winner = "draw"
s.GameOver = true
} else {
s.Turn = 1 - s.Turn
}
}
// Broadcast updated state to everyone
stateJSON, _ := json.Marshal(s)
if err := dispatcher.BroadcastMessage(OpState, stateJSON, nil, nil, true); err != nil {
logger.Error("BroadcastMessage failed: %v", err)
}
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
}