beeta/nakama/go-modules/main.go
doomtube ab1dd08225
All checks were successful
Build and Push / build-all (push) Successful in 4m48s
fixes lol
2026-01-09 00:49:07 -05:00

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), &params)
} else {
json.Unmarshal([]byte(payload), &params)
}
}
}
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), &params)
}
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), &params)
} else {
json.Unmarshal([]byte(payload), &params)
}
}
}
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
}