1204 lines
35 KiB
Go
1204 lines
35 KiB
Go
// Nakama Go Runtime Module for realms.india
|
|
// Features:
|
|
// - Custom JWT authentication (links to existing app users)
|
|
// - Chess960 game with ELO leaderboard
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"math/rand"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/heroiclabs/nakama-common/api"
|
|
"github.com/heroiclabs/nakama-common/runtime"
|
|
"github.com/corentings/chess/v2"
|
|
)
|
|
|
|
const (
|
|
OpCodeMove = 1
|
|
OpCodeGameState = 2
|
|
OpCodeGameOver = 3
|
|
OpCodeResign = 4
|
|
OpCodeOfferDraw = 5
|
|
OpCodeAcceptDraw = 6
|
|
)
|
|
|
|
// ChessState represents the match state
|
|
type ChessState struct {
|
|
PositionID int `json:"positionId"`
|
|
FEN string `json:"fen"`
|
|
PGN string `json:"pgn"`
|
|
WhiteID string `json:"whiteId"`
|
|
BlackID string `json:"blackId"`
|
|
WhiteName string `json:"whiteName"`
|
|
BlackName string `json:"blackName"`
|
|
WhiteColor string `json:"whiteColor"`
|
|
BlackColor string `json:"blackColor"`
|
|
Turn string `json:"turn"`
|
|
GameOver bool `json:"gameOver"`
|
|
Result string `json:"result,omitempty"`
|
|
MoveHistory []string `json:"moveHistory"`
|
|
CreatedAt int64 `json:"createdAt"`
|
|
LastMoveAt int64 `json:"lastMoveAt"`
|
|
Spectators []string `json:"spectators"`
|
|
PendingCancel bool `json:"pendingCancel,omitempty"`
|
|
}
|
|
|
|
// AppJwtClaims from the main app
|
|
type AppJwtClaims struct {
|
|
UserID string `json:"user_id"`
|
|
Username string `json:"username"`
|
|
Issuer string `json:"iss"`
|
|
ExpiresAt int64 `json:"exp"`
|
|
IssuedAt int64 `json:"iat"`
|
|
IsAdmin string `json:"is_admin"`
|
|
IsModerator string `json:"is_moderator"`
|
|
IsStreamer string `json:"is_streamer"`
|
|
IsDisabled string `json:"is_disabled"`
|
|
ColorCode string `json:"color_code"`
|
|
AvatarURL string `json:"avatar_url"`
|
|
}
|
|
|
|
// MatchLabel for listing matches
|
|
type MatchLabel struct {
|
|
Game string `json:"game"`
|
|
Status string `json:"status"`
|
|
Players int `json:"players"`
|
|
White string `json:"white,omitempty"`
|
|
WhiteID string `json:"whiteId,omitempty"`
|
|
Black string `json:"black,omitempty"`
|
|
BlackID string `json:"blackId,omitempty"`
|
|
PositionID int `json:"positionId"`
|
|
CreatedAt int64 `json:"createdAt"`
|
|
SpectatorCount int `json:"spectatorCount,omitempty"`
|
|
Result string `json:"result,omitempty"`
|
|
}
|
|
|
|
func InitModule(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, initializer runtime.Initializer) error {
|
|
// Seed random number generator for Chess960 positions
|
|
rand.Seed(time.Now().UnixNano())
|
|
|
|
logger.Info("Initializing realms.india Nakama Go modules")
|
|
|
|
// Register custom authentication hook
|
|
if err := initializer.RegisterBeforeAuthenticateCustom(beforeAuthenticateCustom); err != nil {
|
|
return fmt.Errorf("failed to register before authenticate custom: %w", err)
|
|
}
|
|
|
|
// Create chess ELO leaderboard (8 args for v1.44.0)
|
|
if err := nk.LeaderboardCreate(ctx, "chess-elo", true, "desc", "set", "", nil, true); err != nil {
|
|
logger.Info("Chess ELO leaderboard already exists or created")
|
|
}
|
|
|
|
// Register chess match handler
|
|
if err := initializer.RegisterMatch("chess", func(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule) (runtime.Match, error) {
|
|
return &ChessMatch{}, nil
|
|
}); err != nil {
|
|
return fmt.Errorf("failed to register chess match: %w", err)
|
|
}
|
|
|
|
// Register RPC functions
|
|
if err := initializer.RegisterRpc("create_chess_match", createChessMatchRpc); err != nil {
|
|
return fmt.Errorf("failed to register create_chess_match RPC: %w", err)
|
|
}
|
|
if err := initializer.RegisterRpc("list_chess_matches", listChessMatchesRpc); err != nil {
|
|
return fmt.Errorf("failed to register list_chess_matches RPC: %w", err)
|
|
}
|
|
if err := initializer.RegisterRpc("get_chess_leaderboard", getChessLeaderboardRpc); err != nil {
|
|
return fmt.Errorf("failed to register get_chess_leaderboard RPC: %w", err)
|
|
}
|
|
if err := initializer.RegisterRpc("cancel_chess_match", cancelChessMatchRpc); err != nil {
|
|
return fmt.Errorf("failed to register cancel_chess_match RPC: %w", err)
|
|
}
|
|
|
|
logger.Info("Nakama Go modules loaded - auth and chess enabled")
|
|
return nil
|
|
}
|
|
|
|
// Generate Chess960 starting position
|
|
func generateChess960Position() (string, int) {
|
|
pieces := make([]rune, 8)
|
|
for i := range pieces {
|
|
pieces[i] = ' '
|
|
}
|
|
|
|
// 1. Place bishops on opposite colors
|
|
lightSquares := []int{1, 3, 5, 7}
|
|
darkSquares := []int{0, 2, 4, 6}
|
|
lightBishopIdx := rand.Intn(4)
|
|
darkBishopIdx := rand.Intn(4)
|
|
pieces[lightSquares[lightBishopIdx]] = 'B'
|
|
pieces[darkSquares[darkBishopIdx]] = 'B'
|
|
|
|
// 2. Place queen on remaining square
|
|
var emptySquares []int
|
|
for i, p := range pieces {
|
|
if p == ' ' {
|
|
emptySquares = append(emptySquares, i)
|
|
}
|
|
}
|
|
queenIdx := rand.Intn(len(emptySquares))
|
|
pieces[emptySquares[queenIdx]] = 'Q'
|
|
|
|
// 3. Place knights on remaining squares
|
|
emptySquares = nil
|
|
for i, p := range pieces {
|
|
if p == ' ' {
|
|
emptySquares = append(emptySquares, i)
|
|
}
|
|
}
|
|
knight1Idx := rand.Intn(len(emptySquares))
|
|
knight1 := emptySquares[knight1Idx]
|
|
emptySquares = append(emptySquares[:knight1Idx], emptySquares[knight1Idx+1:]...)
|
|
knight2Idx := rand.Intn(len(emptySquares))
|
|
knight2 := emptySquares[knight2Idx]
|
|
pieces[knight1] = 'N'
|
|
pieces[knight2] = 'N'
|
|
|
|
// 4. Place R-K-R in remaining 3 squares (king between rooks)
|
|
var remaining []int
|
|
for i, p := range pieces {
|
|
if p == ' ' {
|
|
remaining = append(remaining, i)
|
|
}
|
|
}
|
|
pieces[remaining[0]] = 'R'
|
|
pieces[remaining[1]] = 'K'
|
|
pieces[remaining[2]] = 'R'
|
|
|
|
// Build FEN with Chess960 castling rights
|
|
whitePieces := string(pieces)
|
|
blackPieces := strings.ToLower(whitePieces)
|
|
|
|
// Find rook positions for Chess960 castling notation
|
|
// remaining[0] is queenside rook, remaining[2] is kingside rook
|
|
castling := fmt.Sprintf("%c%c%c%c",
|
|
'A'+remaining[2], 'A'+remaining[0], // White kingside, queenside
|
|
'a'+remaining[2], 'a'+remaining[0]) // Black kingside, queenside
|
|
fen := fmt.Sprintf("%s/pppppppp/8/8/8/8/PPPPPPPP/%s w %s - 0 1", blackPieces, whitePieces, castling)
|
|
|
|
// Calculate position ID
|
|
positionID := (lightBishopIdx*4+darkBishopIdx)*6*10*10 + queenIdx*10*10 + knight1Idx*10 + knight2Idx
|
|
positionID = positionID % 960
|
|
|
|
return fen, positionID
|
|
}
|
|
|
|
// Base64 URL decode
|
|
func base64UrlDecode(s string) ([]byte, error) {
|
|
// Add padding if necessary
|
|
switch len(s) % 4 {
|
|
case 2:
|
|
s += "=="
|
|
case 3:
|
|
s += "="
|
|
}
|
|
s = strings.ReplaceAll(s, "-", "+")
|
|
s = strings.ReplaceAll(s, "_", "/")
|
|
return base64.StdEncoding.DecodeString(s)
|
|
}
|
|
|
|
// Validate JWT and extract claims
|
|
func validateJwt(token string, secret string) (*AppJwtClaims, error) {
|
|
parts := strings.Split(token, ".")
|
|
if len(parts) != 3 {
|
|
return nil, errors.New("invalid JWT format")
|
|
}
|
|
|
|
// Decode payload
|
|
payloadBytes, err := base64UrlDecode(parts[1])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode JWT payload: %w", err)
|
|
}
|
|
|
|
var claims AppJwtClaims
|
|
if err := json.Unmarshal(payloadBytes, &claims); err != nil {
|
|
return nil, fmt.Errorf("failed to parse JWT claims: %w", err)
|
|
}
|
|
|
|
return &claims, nil
|
|
}
|
|
|
|
// Before authenticate custom hook - uses api.AuthenticateCustomRequest
|
|
func beforeAuthenticateCustom(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, in *api.AuthenticateCustomRequest) (*api.AuthenticateCustomRequest, error) {
|
|
appJwt := in.GetAccount().GetId()
|
|
if appJwt == "" {
|
|
return nil, errors.New("authentication token required")
|
|
}
|
|
|
|
// Get JWT secret from environment
|
|
env := ctx.Value(runtime.RUNTIME_CTX_ENV).(map[string]string)
|
|
jwtSecret := env["JWT_SECRET"]
|
|
if jwtSecret == "" {
|
|
logger.Error("JWT_SECRET environment variable not configured")
|
|
return nil, errors.New("server configuration error")
|
|
}
|
|
|
|
claims, err := validateJwt(appJwt, jwtSecret)
|
|
if err != nil {
|
|
logger.Warn("JWT validation failed: %s", err.Error())
|
|
return nil, fmt.Errorf("authentication failed: %w", err)
|
|
}
|
|
|
|
// Validate required claims
|
|
if claims.UserID == "" || claims.Username == "" {
|
|
return nil, errors.New("invalid token: missing required claims")
|
|
}
|
|
|
|
// Check issuer
|
|
if claims.Issuer != "streaming-app" {
|
|
return nil, errors.New("invalid token issuer")
|
|
}
|
|
|
|
// Check expiry
|
|
now := time.Now().Unix()
|
|
if claims.ExpiresAt > 0 && claims.ExpiresAt < now {
|
|
return nil, errors.New("token expired")
|
|
}
|
|
|
|
// Check if user is disabled
|
|
if claims.IsDisabled == "1" {
|
|
return nil, errors.New("account disabled")
|
|
}
|
|
|
|
// Update the request
|
|
in.Account.Id = fmt.Sprintf("user_%s", claims.UserID)
|
|
in.Username = claims.Username
|
|
if in.Account.Vars == nil {
|
|
in.Account.Vars = make(map[string]string)
|
|
}
|
|
in.Account.Vars["app_username"] = claims.Username
|
|
in.Account.Vars["user_color"] = claims.ColorCode
|
|
if in.Account.Vars["user_color"] == "" {
|
|
in.Account.Vars["user_color"] = "#561D5E"
|
|
}
|
|
in.Account.Vars["avatar_url"] = claims.AvatarURL
|
|
in.Account.Vars["is_admin"] = claims.IsAdmin
|
|
in.Account.Vars["is_moderator"] = claims.IsModerator
|
|
|
|
logger.Info("Authenticated user: %s (ID: %s)", claims.Username, claims.UserID)
|
|
return in, nil
|
|
}
|
|
|
|
// Chess Match implementation
|
|
type ChessMatch struct{}
|
|
|
|
func (m *ChessMatch) MatchInit(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, params map[string]interface{}) (interface{}, int, string) {
|
|
logger.Info("chessMatchInit called")
|
|
|
|
fen, positionID := generateChess960Position()
|
|
|
|
label := MatchLabel{
|
|
Game: "chess960",
|
|
Status: "waiting",
|
|
Players: 0,
|
|
PositionID: positionID,
|
|
CreatedAt: time.Now().Unix(),
|
|
}
|
|
labelJSON, _ := json.Marshal(label)
|
|
|
|
state := &ChessState{
|
|
PositionID: positionID,
|
|
FEN: fen,
|
|
WhiteColor: "#561D5E",
|
|
BlackColor: "#561D5E",
|
|
Turn: "w",
|
|
GameOver: false,
|
|
MoveHistory: []string{},
|
|
CreatedAt: time.Now().Unix(),
|
|
Spectators: []string{},
|
|
}
|
|
|
|
logger.Info("Chess960 match initialized with position #%d", positionID)
|
|
return state, 1, string(labelJSON)
|
|
}
|
|
|
|
func (m *ChessMatch) 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.(*ChessState)
|
|
|
|
// Allow players to rejoin
|
|
if s.WhiteID == presence.GetUserId() || s.BlackID == presence.GetUserId() {
|
|
logger.Info("Player %s rejoining match", presence.GetUsername())
|
|
return s, true, ""
|
|
}
|
|
|
|
// If game is full, allow as spectator
|
|
if s.WhiteID != "" && s.BlackID != "" {
|
|
for _, spectatorID := range s.Spectators {
|
|
if spectatorID == presence.GetUserId() {
|
|
return s, false, "Already spectating"
|
|
}
|
|
}
|
|
return s, true, ""
|
|
}
|
|
|
|
return s, true, ""
|
|
}
|
|
|
|
func (m *ChessMatch) 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.(*ChessState)
|
|
|
|
for _, presence := range presences {
|
|
userID := presence.GetUserId()
|
|
username := presence.GetUsername()
|
|
|
|
// Check if rejoining
|
|
if s.WhiteID == userID || s.BlackID == userID {
|
|
color := "w"
|
|
if s.BlackID == userID {
|
|
color = "b"
|
|
}
|
|
logger.Info("%s rejoined as %s", username, map[string]string{"w": "WHITE", "b": "BLACK"}[color])
|
|
|
|
status := "playing"
|
|
if s.GameOver {
|
|
status = "finished"
|
|
} else if s.WhiteID == "" || s.BlackID == "" {
|
|
status = "waiting"
|
|
}
|
|
|
|
msg, _ := json.Marshal(map[string]interface{}{
|
|
"status": status,
|
|
"yourColor": color,
|
|
"fen": s.FEN,
|
|
"positionId": s.PositionID,
|
|
"whiteId": s.WhiteID,
|
|
"blackId": s.BlackID,
|
|
"whiteName": s.WhiteName,
|
|
"blackName": s.BlackName,
|
|
"whiteColor": s.WhiteColor,
|
|
"blackColor": s.BlackColor,
|
|
"turn": s.Turn,
|
|
"moveHistory": s.MoveHistory,
|
|
"gameOver": s.GameOver,
|
|
"result": s.Result,
|
|
})
|
|
dispatcher.BroadcastMessage(OpCodeGameState, msg, []runtime.Presence{presence}, nil, true)
|
|
continue
|
|
}
|
|
|
|
// Get user color from metadata
|
|
userColor := "#561D5E"
|
|
users, err := nk.UsersGetId(ctx, []string{userID}, nil)
|
|
if err == nil && len(users) > 0 && users[0].Metadata != "" {
|
|
var meta map[string]string
|
|
if err := json.Unmarshal([]byte(users[0].Metadata), &meta); err == nil {
|
|
if c, ok := meta["user_color"]; ok && c != "" {
|
|
userColor = c
|
|
}
|
|
}
|
|
}
|
|
|
|
if s.WhiteID == "" {
|
|
s.WhiteID = userID
|
|
s.WhiteName = username
|
|
s.WhiteColor = userColor
|
|
logger.Info("%s joined as WHITE", username)
|
|
|
|
label := MatchLabel{
|
|
Game: "chess960",
|
|
Status: "waiting",
|
|
Players: 1,
|
|
White: username,
|
|
WhiteID: userID,
|
|
PositionID: s.PositionID,
|
|
CreatedAt: s.CreatedAt,
|
|
}
|
|
labelJSON, _ := json.Marshal(label)
|
|
dispatcher.MatchLabelUpdate(string(labelJSON))
|
|
|
|
msg, _ := json.Marshal(map[string]interface{}{
|
|
"status": "waiting",
|
|
"yourColor": "w",
|
|
"positionId": s.PositionID,
|
|
"fen": s.FEN,
|
|
"message": "Waiting for opponent...",
|
|
})
|
|
dispatcher.BroadcastMessage(OpCodeGameState, msg, []runtime.Presence{presence}, nil, true)
|
|
|
|
} else if s.BlackID == "" {
|
|
s.BlackID = userID
|
|
s.BlackName = username
|
|
s.BlackColor = userColor
|
|
s.LastMoveAt = time.Now().Unix() // Game starts - white has 3 days to move
|
|
logger.Info("%s joined as BLACK", username)
|
|
|
|
label := MatchLabel{
|
|
Game: "chess960",
|
|
Status: "playing",
|
|
Players: 2,
|
|
White: s.WhiteName,
|
|
WhiteID: s.WhiteID,
|
|
Black: username,
|
|
BlackID: userID,
|
|
PositionID: s.PositionID,
|
|
CreatedAt: s.CreatedAt,
|
|
}
|
|
labelJSON, _ := json.Marshal(label)
|
|
dispatcher.MatchLabelUpdate(string(labelJSON))
|
|
|
|
msg, _ := json.Marshal(map[string]interface{}{
|
|
"status": "playing",
|
|
"fen": s.FEN,
|
|
"positionId": s.PositionID,
|
|
"whiteId": s.WhiteID,
|
|
"blackId": s.BlackID,
|
|
"whiteName": s.WhiteName,
|
|
"blackName": s.BlackName,
|
|
"whiteColor": s.WhiteColor,
|
|
"blackColor": s.BlackColor,
|
|
"turn": s.Turn,
|
|
})
|
|
dispatcher.BroadcastMessage(OpCodeGameState, msg, nil, nil, true)
|
|
|
|
logger.Info("Chess960 match started: %s vs %s (Position #%d)", s.WhiteName, s.BlackName, s.PositionID)
|
|
} else {
|
|
// Spectator
|
|
s.Spectators = append(s.Spectators, userID)
|
|
logger.Info("%s joined as spectator", username)
|
|
|
|
label := MatchLabel{
|
|
Game: "chess960",
|
|
Status: "playing",
|
|
Players: 2,
|
|
White: s.WhiteName,
|
|
WhiteID: s.WhiteID,
|
|
Black: s.BlackName,
|
|
BlackID: s.BlackID,
|
|
PositionID: s.PositionID,
|
|
CreatedAt: s.CreatedAt,
|
|
SpectatorCount: len(s.Spectators),
|
|
}
|
|
labelJSON, _ := json.Marshal(label)
|
|
dispatcher.MatchLabelUpdate(string(labelJSON))
|
|
|
|
msg, _ := json.Marshal(map[string]interface{}{
|
|
"status": "spectating",
|
|
"fen": s.FEN,
|
|
"positionId": s.PositionID,
|
|
"whiteId": s.WhiteID,
|
|
"blackId": s.BlackID,
|
|
"whiteName": s.WhiteName,
|
|
"blackName": s.BlackName,
|
|
"whiteColor": s.WhiteColor,
|
|
"blackColor": s.BlackColor,
|
|
"turn": s.Turn,
|
|
"moveHistory": s.MoveHistory,
|
|
})
|
|
dispatcher.BroadcastMessage(OpCodeGameState, msg, []runtime.Presence{presence}, nil, true)
|
|
}
|
|
}
|
|
|
|
return s
|
|
}
|
|
|
|
func (m *ChessMatch) 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.(*ChessState)
|
|
|
|
if s.PendingCancel {
|
|
logger.Info("Match terminated due to pendingCancel flag")
|
|
return nil
|
|
}
|
|
|
|
// Check for timeout on waiting matches
|
|
if tick%10 == 0 && s.WhiteID != "" && s.BlackID == "" && !s.GameOver {
|
|
now := time.Now().Unix()
|
|
waitTime := now - s.CreatedAt
|
|
if waitTime > 900 { // 15 minutes
|
|
logger.Info("Match timeout - no opponent joined after %ds", waitTime)
|
|
msg, _ := json.Marshal(map[string]interface{}{
|
|
"result": "timeout",
|
|
"reason": "Challenge expired - no opponent joined within 15 minutes",
|
|
})
|
|
dispatcher.BroadcastMessage(OpCodeGameOver, msg, nil, nil, true)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Check for move timeout (3 days without a move = forfeit)
|
|
if tick%60 == 0 && !s.GameOver && s.WhiteID != "" && s.BlackID != "" && s.LastMoveAt > 0 {
|
|
now := time.Now().Unix()
|
|
const moveTimeout = 3 * 24 * 60 * 60 // 3 days in seconds
|
|
|
|
if (now - s.LastMoveAt) > moveTimeout {
|
|
// Current player (whose turn it is) forfeits
|
|
if s.Turn == "w" {
|
|
logger.Info("White player forfeit due to move timeout (3 days)")
|
|
s.GameOver = true
|
|
s.Result = "0-1"
|
|
updateElo(ctx, nk, logger, s.BlackID, s.BlackName, s.WhiteID, s.WhiteName, false)
|
|
} else {
|
|
logger.Info("Black player forfeit due to move timeout (3 days)")
|
|
s.GameOver = true
|
|
s.Result = "1-0"
|
|
updateElo(ctx, nk, logger, s.WhiteID, s.WhiteName, s.BlackID, s.BlackName, false)
|
|
}
|
|
|
|
msg, _ := json.Marshal(map[string]interface{}{
|
|
"result": s.Result,
|
|
"reason": "timeout - no move in 3 days",
|
|
})
|
|
dispatcher.BroadcastMessage(OpCodeGameOver, msg, nil, nil, true)
|
|
|
|
label := MatchLabel{
|
|
Game: "chess960",
|
|
Status: "finished",
|
|
Result: s.Result,
|
|
White: s.WhiteName,
|
|
WhiteID: s.WhiteID,
|
|
Black: s.BlackName,
|
|
BlackID: s.BlackID,
|
|
PositionID: s.PositionID,
|
|
}
|
|
labelJSON, _ := json.Marshal(label)
|
|
dispatcher.MatchLabelUpdate(string(labelJSON))
|
|
}
|
|
}
|
|
|
|
for _, message := range messages {
|
|
playerID := message.GetUserId()
|
|
|
|
switch message.GetOpCode() {
|
|
case OpCodeMove:
|
|
if s.GameOver {
|
|
logger.Warn("Move attempted on finished game")
|
|
continue
|
|
}
|
|
|
|
var moveData struct {
|
|
From string `json:"from"`
|
|
To string `json:"to"`
|
|
Promotion string `json:"promotion"`
|
|
}
|
|
if err := json.Unmarshal(message.GetData(), &moveData); err != nil {
|
|
logger.Warn("Failed to parse move data: %v", err)
|
|
continue
|
|
}
|
|
|
|
// Validate it's this player's turn
|
|
isWhite := playerID == s.WhiteID
|
|
isBlack := playerID == s.BlackID
|
|
if (s.Turn == "w" && !isWhite) || (s.Turn == "b" && !isBlack) {
|
|
logger.Warn("Not %s's turn", playerID)
|
|
continue
|
|
}
|
|
|
|
// Validate and apply the move
|
|
result := validateChessMove(s.FEN, moveData.From, moveData.To, moveData.Promotion, logger)
|
|
if !result.Valid {
|
|
logger.Warn("Invalid move: %+v", moveData)
|
|
continue
|
|
}
|
|
|
|
s.FEN = result.NewFEN
|
|
s.Turn = result.Turn
|
|
s.MoveHistory = append(s.MoveHistory, fmt.Sprintf("%s-%s", moveData.From, moveData.To))
|
|
s.LastMoveAt = time.Now().Unix() // Reset move timer
|
|
|
|
// ALWAYS broadcast the move to all players/spectators first
|
|
moveMsg, _ := json.Marshal(map[string]interface{}{
|
|
"move": moveData,
|
|
"fen": s.FEN,
|
|
"turn": s.Turn,
|
|
})
|
|
dispatcher.BroadcastMessage(OpCodeMove, moveMsg, nil, nil, true)
|
|
|
|
// Then handle game-over if applicable
|
|
if result.GameOver {
|
|
s.GameOver = true
|
|
s.Result = result.Result
|
|
|
|
// Update ELO only for valid results
|
|
if s.Result == "1-0" {
|
|
updateElo(ctx, nk, logger, s.WhiteID, s.WhiteName, s.BlackID, s.BlackName, false)
|
|
} else if s.Result == "0-1" {
|
|
updateElo(ctx, nk, logger, s.BlackID, s.BlackName, s.WhiteID, s.WhiteName, false)
|
|
} else if s.Result == "1/2-1/2" {
|
|
updateElo(ctx, nk, logger, s.WhiteID, s.WhiteName, s.BlackID, s.BlackName, true)
|
|
}
|
|
|
|
gameOverMsg, _ := json.Marshal(map[string]interface{}{
|
|
"fen": s.FEN,
|
|
"result": s.Result,
|
|
"reason": result.Reason,
|
|
})
|
|
dispatcher.BroadcastMessage(OpCodeGameOver, gameOverMsg, nil, nil, true)
|
|
|
|
label := MatchLabel{
|
|
Game: "chess960",
|
|
Status: "finished",
|
|
Result: s.Result,
|
|
White: s.WhiteName,
|
|
WhiteID: s.WhiteID,
|
|
Black: s.BlackName,
|
|
BlackID: s.BlackID,
|
|
PositionID: s.PositionID,
|
|
}
|
|
labelJSON, _ := json.Marshal(label)
|
|
dispatcher.MatchLabelUpdate(string(labelJSON))
|
|
|
|
logger.Info("Game over: %s (%s)", s.Result, result.Reason)
|
|
}
|
|
|
|
case OpCodeResign:
|
|
isPlayerWhite := playerID == s.WhiteID
|
|
isPlayerBlack := playerID == s.BlackID
|
|
if !isPlayerWhite && !isPlayerBlack {
|
|
logger.Warn("Spectator attempted to resign - ignoring")
|
|
continue
|
|
}
|
|
|
|
s.GameOver = true
|
|
if isPlayerWhite {
|
|
s.Result = "0-1"
|
|
} else {
|
|
s.Result = "1-0"
|
|
}
|
|
|
|
// Winner gets ELO
|
|
var winnerID, winnerName, loserName string
|
|
if isPlayerWhite {
|
|
winnerID = s.BlackID
|
|
winnerName = s.BlackName
|
|
loserName = s.WhiteName
|
|
} else {
|
|
winnerID = s.WhiteID
|
|
winnerName = s.WhiteName
|
|
loserName = s.BlackName
|
|
}
|
|
updateElo(ctx, nk, logger, winnerID, winnerName, playerID, loserName, false)
|
|
|
|
msg, _ := json.Marshal(map[string]interface{}{
|
|
"result": s.Result,
|
|
"reason": "resignation",
|
|
})
|
|
dispatcher.BroadcastMessage(OpCodeGameOver, msg, nil, nil, true)
|
|
|
|
logger.Info("Player resigned - %s", s.Result)
|
|
}
|
|
}
|
|
|
|
return s
|
|
}
|
|
|
|
func (m *ChessMatch) 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.(*ChessState)
|
|
|
|
for _, presence := range presences {
|
|
userID := presence.GetUserId()
|
|
username := presence.GetUsername()
|
|
|
|
// Check if spectator
|
|
for i, spectatorID := range s.Spectators {
|
|
if spectatorID == userID {
|
|
s.Spectators = append(s.Spectators[:i], s.Spectators[i+1:]...)
|
|
logger.Info("Spectator %s left the match", username)
|
|
break
|
|
}
|
|
}
|
|
|
|
// Player leaving (disconnect/refresh)
|
|
if s.WhiteID == userID || s.BlackID == userID {
|
|
logger.Info("Player %s disconnected from match (can rejoin anytime)", username)
|
|
// Games persist indefinitely - player can rejoin anytime
|
|
// No forfeit on disconnect - only manual resign ends the game
|
|
}
|
|
}
|
|
|
|
return s
|
|
}
|
|
|
|
func (m *ChessMatch) 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("Match terminating")
|
|
return state
|
|
}
|
|
|
|
func (m *ChessMatch) MatchSignal(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, data string) (interface{}, string) {
|
|
s := state.(*ChessState)
|
|
logger.Info("Match signal received: %s", data)
|
|
|
|
if data == "cancel" {
|
|
logger.Info("Match cancelled via signal")
|
|
s.PendingCancel = true
|
|
|
|
// Broadcast cancellation to any connected clients
|
|
msg, _ := json.Marshal(map[string]interface{}{
|
|
"result": "cancelled",
|
|
"reason": "Challenge cancelled by creator",
|
|
})
|
|
dispatcher.BroadcastMessage(OpCodeGameOver, msg, nil, nil, true)
|
|
|
|
// Update match label to cancelled status so it's not detected as active
|
|
label := MatchLabel{
|
|
Game: "chess960",
|
|
Status: "cancelled",
|
|
White: s.WhiteName,
|
|
WhiteID: s.WhiteID,
|
|
PositionID: s.PositionID,
|
|
}
|
|
labelJSON, _ := json.Marshal(label)
|
|
dispatcher.MatchLabelUpdate(string(labelJSON))
|
|
}
|
|
|
|
return s, ""
|
|
}
|
|
|
|
// Move validation result
|
|
type MoveResult struct {
|
|
Valid bool
|
|
NewFEN string
|
|
Turn string
|
|
GameOver bool
|
|
Result string
|
|
Reason string
|
|
}
|
|
|
|
// Convert Chess960 FEN castling notation to standard notation for chess library compatibility
|
|
func convertChess960FenForLibrary(fenStr string) string {
|
|
parts := strings.Split(fenStr, " ")
|
|
if len(parts) < 3 {
|
|
return fenStr
|
|
}
|
|
|
|
castling := parts[2]
|
|
// Check if it's already standard notation or empty
|
|
if castling == "-" || castling == "KQkq" || castling == "KQ" || castling == "kq" ||
|
|
castling == "K" || castling == "Q" || castling == "k" || castling == "q" ||
|
|
castling == "Kq" || castling == "Qk" || castling == "Kk" || castling == "Qq" {
|
|
return fenStr
|
|
}
|
|
|
|
// Convert Chess960 file-based notation (like HBhb) to standard KQkq
|
|
newCastling := ""
|
|
hasWhiteKingside := false
|
|
hasWhiteQueenside := false
|
|
hasBlackKingside := false
|
|
hasBlackQueenside := false
|
|
|
|
for _, c := range castling {
|
|
if c >= 'A' && c <= 'H' {
|
|
// White castling - higher file = kingside, lower = queenside
|
|
if c >= 'E' {
|
|
hasWhiteKingside = true
|
|
} else {
|
|
hasWhiteQueenside = true
|
|
}
|
|
} else if c >= 'a' && c <= 'h' {
|
|
// Black castling
|
|
if c >= 'e' {
|
|
hasBlackKingside = true
|
|
} else {
|
|
hasBlackQueenside = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if hasWhiteKingside {
|
|
newCastling += "K"
|
|
}
|
|
if hasWhiteQueenside {
|
|
newCastling += "Q"
|
|
}
|
|
if hasBlackKingside {
|
|
newCastling += "k"
|
|
}
|
|
if hasBlackQueenside {
|
|
newCastling += "q"
|
|
}
|
|
|
|
if newCastling == "" {
|
|
newCastling = "-"
|
|
}
|
|
|
|
parts[2] = newCastling
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
// Validate chess move using corentings/chess/v2 library
|
|
func validateChessMove(fenStr, from, to, promotion string, logger runtime.Logger) MoveResult {
|
|
result := MoveResult{Turn: "w"}
|
|
|
|
// Convert Chess960 castling notation to standard for library compatibility
|
|
convertedFen := convertChess960FenForLibrary(fenStr)
|
|
logger.Info("validateChessMove: original FEN=%s, converted=%s, move=%s%s", fenStr, convertedFen, from, to)
|
|
|
|
// Parse FEN
|
|
fen, err := chess.FEN(convertedFen)
|
|
if err != nil {
|
|
logger.Error("validateChessMove: FEN parse error: %v", err)
|
|
return result
|
|
}
|
|
|
|
game := chess.NewGame(fen)
|
|
logger.Info("validateChessMove: Game created, valid moves count: %d", len(game.ValidMoves()))
|
|
|
|
// Verify move is valid first
|
|
found := false
|
|
for _, move := range game.ValidMoves() {
|
|
if move.S1().String() == from && move.S2().String() == to {
|
|
// Check promotion
|
|
if promotion != "" {
|
|
promo := move.Promo()
|
|
promoStr := strings.ToLower(promo.String())
|
|
if promoStr != promotion {
|
|
continue
|
|
}
|
|
}
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
return result
|
|
}
|
|
|
|
// Make the move using UCI notation (e.g., "e2e4" or "e7e8q")
|
|
uciMove := from + to
|
|
if promotion != "" {
|
|
uciMove += promotion
|
|
}
|
|
if err := game.PushNotationMove(uciMove, chess.UCINotation{}, nil); err != nil {
|
|
return result
|
|
}
|
|
|
|
result.Valid = true
|
|
result.NewFEN = game.Position().String()
|
|
|
|
// Determine turn
|
|
if game.Position().Turn() == chess.White {
|
|
result.Turn = "w"
|
|
} else {
|
|
result.Turn = "b"
|
|
}
|
|
|
|
// Check game over
|
|
outcome := game.Outcome()
|
|
method := game.Method()
|
|
logger.Info("validateChessMove: outcome=%v, method=%v", outcome, method)
|
|
|
|
if outcome != chess.NoOutcome {
|
|
result.GameOver = true
|
|
|
|
switch outcome {
|
|
case chess.WhiteWon:
|
|
result.Result = "1-0"
|
|
case chess.BlackWon:
|
|
result.Result = "0-1"
|
|
case chess.Draw:
|
|
result.Result = "1/2-1/2"
|
|
}
|
|
logger.Info("validateChessMove: GAME OVER detected! result=%s, reason will be set based on method", result.Result)
|
|
|
|
switch method {
|
|
case chess.Checkmate:
|
|
result.Reason = "checkmate"
|
|
case chess.Stalemate:
|
|
result.Reason = "stalemate"
|
|
case chess.ThreefoldRepetition:
|
|
result.Reason = "threefold repetition"
|
|
case chess.InsufficientMaterial:
|
|
result.Reason = "insufficient material"
|
|
case chess.FiftyMoveRule:
|
|
result.Reason = "fifty-move rule"
|
|
default:
|
|
result.Reason = "draw"
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// Update ELO ratings
|
|
func updateElo(ctx context.Context, nk runtime.NakamaModule, logger runtime.Logger, winnerID, winnerName, loserID, loserName string, isDraw bool) {
|
|
const K = 32
|
|
const defaultElo = 1200
|
|
|
|
logger.Info("updateElo called: winner=%s (%s), loser=%s (%s)", winnerName, winnerID, loserName, loserID)
|
|
|
|
// Get current ratings (5 return values)
|
|
winnerRecords, _, _, _, err := nk.LeaderboardRecordsList(ctx, "chess-elo", []string{winnerID}, 1, "", 0)
|
|
if err != nil {
|
|
logger.Error("Failed to get winner ELO: %v", err)
|
|
return
|
|
}
|
|
loserRecords, _, _, _, err := nk.LeaderboardRecordsList(ctx, "chess-elo", []string{loserID}, 1, "", 0)
|
|
if err != nil {
|
|
logger.Error("Failed to get loser ELO: %v", err)
|
|
return
|
|
}
|
|
|
|
winnerElo := int64(defaultElo)
|
|
loserElo := int64(defaultElo)
|
|
if len(winnerRecords) > 0 {
|
|
winnerElo = winnerRecords[0].Score
|
|
}
|
|
if len(loserRecords) > 0 {
|
|
loserElo = loserRecords[0].Score
|
|
}
|
|
|
|
// Calculate expected scores
|
|
expectedWinner := 1.0 / (1.0 + math.Pow(10, float64(loserElo-winnerElo)/400))
|
|
expectedLoser := 1.0 - expectedWinner
|
|
|
|
// Calculate new ratings
|
|
var newWinnerElo, newLoserElo int64
|
|
if isDraw {
|
|
newWinnerElo = int64(math.Round(float64(winnerElo) + K*(0.5-expectedWinner)))
|
|
newLoserElo = int64(math.Round(float64(loserElo) + K*(0.5-expectedLoser)))
|
|
} else {
|
|
newWinnerElo = int64(math.Round(float64(winnerElo) + K*(1-expectedWinner)))
|
|
newLoserElo = int64(math.Round(float64(loserElo) + K*(0-expectedLoser)))
|
|
}
|
|
|
|
// Ensure minimum ELO of 100
|
|
if newWinnerElo < 100 {
|
|
newWinnerElo = 100
|
|
}
|
|
if newLoserElo < 100 {
|
|
newLoserElo = 100
|
|
}
|
|
|
|
// Update leaderboard
|
|
_, err = nk.LeaderboardRecordWrite(ctx, "chess-elo", winnerID, winnerName, newWinnerElo, 0, nil, nil)
|
|
if err != nil {
|
|
logger.Error("Failed to write winner ELO: %v", err)
|
|
}
|
|
_, err = nk.LeaderboardRecordWrite(ctx, "chess-elo", loserID, loserName, newLoserElo, 0, nil, nil)
|
|
if err != nil {
|
|
logger.Error("Failed to write loser ELO: %v", err)
|
|
}
|
|
|
|
logger.Info("ELO updated - %s: %d -> %d, %s: %d -> %d", winnerName, winnerElo, newWinnerElo, loserName, loserElo, newLoserElo)
|
|
}
|
|
|
|
// RPC: Create chess match
|
|
func createChessMatchRpc(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
|
|
userID, ok := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)
|
|
if !ok || userID == "" {
|
|
return "", errors.New("authentication required")
|
|
}
|
|
username, _ := ctx.Value(runtime.RUNTIME_CTX_USERNAME).(string)
|
|
|
|
logger.Info("Creating chess match for user: %s (%s)", username, userID)
|
|
|
|
// Check if user already has an active challenge
|
|
matches, err := nk.MatchList(ctx, 100, true, "", nil, nil, "")
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to list matches: %w", err)
|
|
}
|
|
|
|
for _, match := range matches {
|
|
var label MatchLabel
|
|
if err := json.Unmarshal([]byte(match.Label.Value), &label); err != nil {
|
|
continue
|
|
}
|
|
if label.Game != "chess960" {
|
|
continue
|
|
}
|
|
if label.WhiteID == userID || label.BlackID == userID {
|
|
if label.Status == "waiting" || label.Status == "playing" {
|
|
logger.Info("User %s already has an active match: %s", username, match.MatchId)
|
|
return `{"error": "You already have an active challenge. Cancel it or finish your game first.", "existingMatchId": "` + match.MatchId + `"}`, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
matchID, err := nk.MatchCreate(ctx, "chess", nil)
|
|
if err != nil {
|
|
logger.Error("Failed to create chess match: %v", err)
|
|
return "", err
|
|
}
|
|
|
|
logger.Info("Chess match created successfully: %s by %s", matchID, username)
|
|
return `{"matchId": "` + matchID + `"}`, nil
|
|
}
|
|
|
|
// RPC: List chess matches
|
|
func listChessMatchesRpc(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
|
|
var params struct {
|
|
Limit int `json:"limit"`
|
|
Status string `json:"status"`
|
|
}
|
|
params.Limit = 20
|
|
|
|
if payload != "" {
|
|
// Handle double-encoded JSON
|
|
var temp interface{}
|
|
if err := json.Unmarshal([]byte(payload), &temp); err == nil {
|
|
if str, ok := temp.(string); ok {
|
|
json.Unmarshal([]byte(str), ¶ms)
|
|
} else {
|
|
json.Unmarshal([]byte(payload), ¶ms)
|
|
}
|
|
}
|
|
}
|
|
|
|
matches, err := nk.MatchList(ctx, params.Limit*2, true, "", nil, nil, "")
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to list matches: %w", err)
|
|
}
|
|
|
|
userID, _ := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)
|
|
logger.Info("listChessMatches: found %d total matches, filtering by status=%s", len(matches), params.Status)
|
|
|
|
var result []map[string]interface{}
|
|
for _, match := range matches {
|
|
var label MatchLabel
|
|
if err := json.Unmarshal([]byte(match.Label.Value), &label); err != nil {
|
|
logger.Warn("listChessMatches: failed to parse label for match %s", match.MatchId)
|
|
continue
|
|
}
|
|
if label.Game != "chess960" {
|
|
continue
|
|
}
|
|
|
|
logger.Info("listChessMatches: match %s has status=%s", match.MatchId, label.Status)
|
|
|
|
// Filter by status
|
|
if params.Status != "" && params.Status != "all" && label.Status != params.Status {
|
|
continue
|
|
}
|
|
|
|
isCurrentUserMatch := false
|
|
var currentUserColor string
|
|
if userID != "" {
|
|
if label.WhiteID == userID {
|
|
isCurrentUserMatch = true
|
|
currentUserColor = "w"
|
|
} else if label.BlackID == userID {
|
|
isCurrentUserMatch = true
|
|
currentUserColor = "b"
|
|
}
|
|
}
|
|
|
|
result = append(result, map[string]interface{}{
|
|
"matchId": match.MatchId,
|
|
"status": label.Status,
|
|
"white": label.White,
|
|
"black": label.Black,
|
|
"positionId": label.PositionID,
|
|
"createdAt": label.CreatedAt,
|
|
"spectatorCount": label.SpectatorCount,
|
|
"size": match.Size,
|
|
"isCurrentUserMatch": isCurrentUserMatch,
|
|
"currentUserColor": currentUserColor,
|
|
})
|
|
|
|
if len(result) >= params.Limit {
|
|
break
|
|
}
|
|
}
|
|
|
|
response, _ := json.Marshal(map[string]interface{}{"matches": result})
|
|
return string(response), nil
|
|
}
|
|
|
|
// RPC: Get chess leaderboard
|
|
func getChessLeaderboardRpc(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
|
|
var params struct {
|
|
Limit int `json:"limit"`
|
|
}
|
|
params.Limit = 20
|
|
|
|
if payload != "" {
|
|
json.Unmarshal([]byte(payload), ¶ms)
|
|
}
|
|
|
|
records, _, _, _, err := nk.LeaderboardRecordsList(ctx, "chess-elo", nil, params.Limit, "", 0)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to list leaderboard: %w", err)
|
|
}
|
|
|
|
var resultList []map[string]interface{}
|
|
for _, r := range records {
|
|
resultList = append(resultList, map[string]interface{}{
|
|
"rank": r.Rank,
|
|
"userId": r.OwnerId,
|
|
"username": r.Username.Value,
|
|
"elo": r.Score,
|
|
})
|
|
}
|
|
|
|
response, _ := json.Marshal(map[string]interface{}{"records": resultList})
|
|
return string(response), nil
|
|
}
|
|
|
|
// RPC: Cancel chess match
|
|
func cancelChessMatchRpc(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
|
|
userID, ok := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)
|
|
if !ok || userID == "" {
|
|
return "", errors.New("authentication required")
|
|
}
|
|
username, _ := ctx.Value(runtime.RUNTIME_CTX_USERNAME).(string)
|
|
|
|
var params struct {
|
|
MatchID string `json:"matchId"`
|
|
}
|
|
|
|
if payload != "" {
|
|
// Handle double-encoded JSON
|
|
var temp interface{}
|
|
if err := json.Unmarshal([]byte(payload), &temp); err == nil {
|
|
if str, ok := temp.(string); ok {
|
|
json.Unmarshal([]byte(str), ¶ms)
|
|
} else {
|
|
json.Unmarshal([]byte(payload), ¶ms)
|
|
}
|
|
}
|
|
}
|
|
|
|
if params.MatchID == "" {
|
|
return `{"success": false, "error": "Match ID required"}`, nil
|
|
}
|
|
|
|
logger.Info("User %s requesting to cancel match: %s", username, params.MatchID)
|
|
|
|
// Find the match and verify ownership
|
|
matches, err := nk.MatchList(ctx, 100, true, "", nil, nil, "")
|
|
if err != nil {
|
|
return `{"success": false, "error": "Failed to find match"}`, nil
|
|
}
|
|
|
|
var targetMatch *api.Match
|
|
for _, match := range matches {
|
|
if match.MatchId == params.MatchID {
|
|
targetMatch = match
|
|
break
|
|
}
|
|
}
|
|
|
|
if targetMatch == nil {
|
|
return `{"success": false, "error": "Match not found"}`, nil
|
|
}
|
|
|
|
var label MatchLabel
|
|
if err := json.Unmarshal([]byte(targetMatch.Label.Value), &label); err != nil {
|
|
return `{"success": false, "error": "Invalid match"}`, nil
|
|
}
|
|
|
|
if label.WhiteID != userID {
|
|
return `{"success": false, "error": "You can only cancel your own challenges"}`, nil
|
|
}
|
|
|
|
if label.Status != "waiting" {
|
|
return `{"success": false, "error": "Can only cancel waiting challenges"}`, nil
|
|
}
|
|
|
|
// Signal the match to terminate (returns 2 values)
|
|
_, err = nk.MatchSignal(ctx, params.MatchID, "cancel")
|
|
if err != nil {
|
|
logger.Warn("matchSignal failed: %v", err)
|
|
}
|
|
|
|
logger.Info("Match %s cancelled by %s", params.MatchID, username)
|
|
return `{"success": true}`, nil
|
|
}
|