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

379 lines
9.0 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 s.GameOver {
if s.Winner != "" && s.Winner != "draw" && s.Winner != "forfeit" {
// winner = "X" or "O"
winningIndex := 0
if s.Winner == "O" {
winningIndex = 1
}
winnerUserId := s.Players[winningIndex]
account, acc_err := nk.AccountGetId(ctx, winnerUserId)
winnerUsername := ""
if acc_err != nil {
logger.Error("Failed to fetch username for winner %s: %v", winnerUserId, acc_err)
} else {
winnerUsername = account.GetUser().GetUsername()
}
logger.Info("Winner username=%s userId=%s", winnerUsername, winnerUserId)
// Write +1 win
_, err := nk.LeaderboardRecordWrite(
ctx,
"tictactoe", // leaderboard ID
winnerUserId, // owner ID
winnerUsername, // username
int64(1), // score
int64(0), // subscore
map[string]interface{}{"result": "win"},
nil, // overrideOperator
)
if err != nil {
logger.Error("Failed to write leaderboard win: %v", err)
} else {
logger.Info("Leaderboard updated for: %s", winnerUserId)
}
}
}
}
// 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
}