// 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"` 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 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 } } 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)) // 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 if s.WhiteID == userID || s.BlackID == userID { logger.Info("Player %s left the match", username) // If waiting for opponent, don't terminate immediately if s.WhiteID != "" && s.BlackID == "" && userID == s.WhiteID { logger.Info("White player disconnected from waiting challenge") return s } // If game in progress, opponent wins by forfeit if !s.GameOver && s.WhiteID != "" && s.BlackID != "" { isWhiteLeaving := userID == s.WhiteID s.GameOver = true if isWhiteLeaving { s.Result = "0-1" } else { s.Result = "1-0" } var winnerID, winnerName, loserName string if isWhiteLeaving { 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, userID, loserName, false) msg, _ := json.Marshal(map[string]interface{}{ "result": s.Result, "reason": "forfeit", }) dispatcher.BroadcastMessage(OpCodeGameOver, msg, nil, nil, true) // Update match label to finished so it's not detected as active 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 forfeited: %s", s.Result) } } } 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) var result []map[string]interface{} for _, match := range matches { var label MatchLabel if err := json.Unmarshal([]byte(match.Label.Value), &label); err != nil { continue } if label.Game != "chess960" { continue } // 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 }