287 lines
5.6 KiB
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
|
|
}
|