From bdc4ade8cf5300c62e8f07a55b57685758410adb Mon Sep 17 00:00:00 2001 From: doomtube Date: Thu, 8 Jan 2026 00:52:34 -0500 Subject: [PATCH] fixes lol --- docker-compose.yml | 5 +- nakama/Dockerfile | 55 +- nakama/config.yml | 2 +- nakama/go-modules/go.mod | 8 + nakama/go-modules/main.go | 1087 ++++++++++++++++++++++++++ nakama/modules/package-lock.json | 476 ------------ nakama/modules/package.json | 16 - nakama/modules/src/main.ts | 1242 ------------------------------ nakama/modules/tsconfig.json | 16 - 9 files changed, 1120 insertions(+), 1787 deletions(-) create mode 100644 nakama/go-modules/go.mod create mode 100644 nakama/go-modules/main.go delete mode 100644 nakama/modules/package-lock.json delete mode 100644 nakama/modules/package.json delete mode 100644 nakama/modules/src/main.ts delete mode 100644 nakama/modules/tsconfig.json diff --git a/docker-compose.yml b/docker-compose.yml index d6bbd10..360ada0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -123,7 +123,6 @@ services: --database.address "streamuser:$DB_PASSWORD@postgres:5432/nakama?sslmode=disable" \ --socket.server_key "$NAKAMA_SERVER_KEY" \ --session.token_expiry_sec 86400 \ - --runtime.js_entrypoint main.js \ --runtime.env "JWT_SECRET=$JWT_SECRET" \ --console.username admin \ --console.password "$NAKAMA_CONSOLE_PASSWORD" \ @@ -133,8 +132,10 @@ services: - NAKAMA_CONSOLE_PASSWORD=${NAKAMA_CONSOLE_PASSWORD} - NAKAMA_SERVER_KEY=${NAKAMA_SERVER_KEY} - DB_PASSWORD=${DB_PASSWORD} + # Note: For production, Go plugin is baked into Docker image + # For local dev, build with: cd nakama/go-modules && go build -buildmode=plugin -o ../modules/build/backend.so . volumes: - - ./nakama/modules/build:/nakama/data/modules:ro + - ./nakama/go-modules:/nakama/data/modules:ro healthcheck: test: ["CMD", "curl", "-f", "http://localhost:7350/healthcheck"] interval: 10s diff --git a/nakama/Dockerfile b/nakama/Dockerfile index ce52b80..96296bc 100644 --- a/nakama/Dockerfile +++ b/nakama/Dockerfile @@ -1,50 +1,37 @@ # ============================================================================= -# Nakama with Custom Chess Modules +# Nakama with Custom Go Chess Modules # ============================================================================= # Two-stage build: -# 1. Build TypeScript modules with Node.js -# 2. Copy compiled JS to Nakama runtime +# 1. Build Go plugin using official Nakama plugin builder +# 2. Copy compiled plugin to Nakama runtime # ============================================================================= -# Stage 1: Build TypeScript modules -FROM node:20-alpine AS builder +# Stage 1: Build Go plugin +# IMPORTANT: Plugin builder version MUST match Nakama server version +FROM heroiclabs/nakama-pluginbuilder:3.35.1 AS builder -# Install git for GitHub dependencies (nakama-runtime) and file for debugging -RUN apk add --no-cache git file +WORKDIR /backend -WORKDIR /app +# Copy Go module files +COPY go-modules/go.mod go-modules/go.sum* ./ -# Copy module files and install dependencies -COPY modules/package*.json ./ -# Use npm ci for reproducible builds from lock file -RUN npm ci && \ - echo "=== Installed versions ===" && \ - npm ls --depth=0 +# Download dependencies +RUN go mod download 2>/dev/null || go mod tidy -# Copy source and build -COPY modules/tsconfig.json ./ -COPY modules/src ./src +# Copy source +COPY go-modules/*.go ./ -# Build with esbuild (output to build/index.js) -RUN npm run build +# Build the plugin as a shared library +RUN go build -buildmode=plugin -trimpath -o backend.so . -# Verify build output exists and show details -RUN ls -la build/ && test -f build/index.js && \ - echo "=== File info ===" && \ - file build/index.js && \ - echo "=== Line count ===" && \ - wc -l build/index.js && \ - echo "=== First 5 lines ===" && \ - head -5 build/index.js && \ - echo "=== Lines 1259-1265 ===" && \ - sed -n '1259,1265p' build/index.js +# Verify build +RUN ls -la backend.so && file backend.so -# Stage 2: Nakama runtime with modules +# Stage 2: Nakama runtime with Go plugin FROM registry.heroiclabs.com/heroiclabs/nakama:3.35.1 -# Copy compiled JavaScript modules -# Nakama expects modules at /nakama/data/modules/ -COPY --from=builder /app/build/index.js /nakama/data/modules/main.js +# Copy compiled Go plugin +COPY --from=builder /backend/backend.so /nakama/data/modules/backend.so -# Copy config file (optional - can also be passed via CLI) +# Copy config file COPY config.yml /nakama/data/config.yml diff --git a/nakama/config.yml b/nakama/config.yml index 5e60fca..6267eba 100644 --- a/nakama/config.yml +++ b/nakama/config.yml @@ -11,7 +11,7 @@ session: token_expiry_sec: 86400 # 24 hours (matches app JWT) runtime: - js_entrypoint: "main.js" + # Go plugins are automatically loaded from /nakama/data/modules/*.so env: - "JWT_SECRET=${JWT_SECRET}" diff --git a/nakama/go-modules/go.mod b/nakama/go-modules/go.mod new file mode 100644 index 0000000..3db527a --- /dev/null +++ b/nakama/go-modules/go.mod @@ -0,0 +1,8 @@ +module realms.india/nakama-modules + +go 1.22 + +require ( + github.com/heroiclabs/nakama-common v1.44.0 + github.com/corentings/chess/v2 v2.0.1 +) diff --git a/nakama/go-modules/main.go b/nakama/go-modules/main.go new file mode 100644 index 0000000..2fb797f --- /dev/null +++ b/nakama/go-modules/main.go @@ -0,0 +1,1087 @@ +// 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/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 { + 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 + if err := nk.LeaderboardCreate(ctx, "chess-elo", true, "desc", "set", "", nil); 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 + whitePieces := string(pieces) + blackPieces := strings.ToLower(whitePieces) + fen := fmt.Sprintf("%s/pppppppp/8/8/8/8/PPPPPPPP/%s w KQkq - 0 1", blackPieces, whitePieces) + + // 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 +func beforeAuthenticateCustom(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, in *runtime.AuthenticateCustomRequest) (*runtime.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 != nil { + 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 using notnil/chess + result := validateChessMove(s.FEN, moveData.From, moveData.To, moveData.Promotion) + 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)) + + if result.GameOver { + s.GameOver = true + s.Result = result.Result + + // Update ELO + 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 { + updateElo(ctx, nk, logger, s.WhiteID, s.WhiteName, s.BlackID, s.BlackName, true) + } + + msg, _ := json.Marshal(map[string]interface{}{ + "fen": s.FEN, + "result": s.Result, + "reason": result.Reason, + }) + 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)) + + logger.Info("Game over: %s (%s)", s.Result, result.Reason) + } else { + msg, _ := json.Marshal(map[string]interface{}{ + "move": moveData, + "fen": s.FEN, + "turn": s.Turn, + }) + dispatcher.BroadcastMessage(OpCodeMove, msg, nil, nil, true) + } + + 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) + + 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 + } + + return s, "" +} + +// Move validation result +type MoveResult struct { + Valid bool + NewFEN string + Turn string + GameOver bool + Result string + Reason string +} + +// Validate chess move using notnil/chess library +func validateChessMove(fenStr, from, to, promotion string) MoveResult { + result := MoveResult{Turn: "w"} + + // Parse FEN + fen, err := chess.FEN(fenStr) + if err != nil { + return result + } + + game := chess.NewGame(fen) + + // Find the move + var targetMove *chess.Move + 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 + } + } + targetMove = move + break + } + } + + if targetMove == nil { + return result + } + + // Make the move + if err := game.Move(targetMove); 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() + if outcome != chess.NoOutcome { + result.GameOver = true + method := game.Method() + + 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" + } + + 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 + 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.Records) > 0 { + winnerElo = winnerRecords.Records[0].Score + } + if len(loserRecords.Records) > 0 { + loserElo = loserRecords.Records[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 result []map[string]interface{} + for _, r := range records { + result = append(result, map[string]interface{}{ + "rank": r.Rank, + "userId": r.OwnerId, + "username": r.Username.Value, + "elo": r.Score, + }) + } + + response, _ := json.Marshal(map[string]interface{}{"records": result}) + 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 *runtime.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 + if err := nk.MatchSignal(ctx, params.MatchID, "cancel"); err != nil { + logger.Warn("matchSignal failed: %v", err) + } + + logger.Info("Match %s cancelled by %s", params.MatchID, username) + return `{"success": true}`, nil +} diff --git a/nakama/modules/package-lock.json b/nakama/modules/package-lock.json deleted file mode 100644 index 33943d2..0000000 --- a/nakama/modules/package-lock.json +++ /dev/null @@ -1,476 +0,0 @@ -{ - "name": "nakama-realms-modules", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "nakama-realms-modules", - "version": "1.0.0", - "dependencies": { - "chess.js": "^1.0.0-beta.8" - }, - "devDependencies": { - "esbuild": "^0.19.0", - "nakama-runtime": "github:heroiclabs/nakama-common", - "typescript": "^5.3.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", - "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", - "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", - "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", - "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", - "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", - "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", - "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", - "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", - "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", - "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", - "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", - "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", - "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", - "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", - "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", - "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", - "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", - "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", - "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", - "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", - "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", - "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", - "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/chess.js": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/chess.js/-/chess.js-1.4.0.tgz", - "integrity": "sha512-BBJgrrtKQOzFLonR0l+k64A98NLemPwNsCskwb+29bRwobUa4iTm51E1kwGPbWXAcfdDa18nad6vpPPKPWarqw==", - "license": "BSD-2-Clause" - }, - "node_modules/esbuild": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", - "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.19.12", - "@esbuild/android-arm": "0.19.12", - "@esbuild/android-arm64": "0.19.12", - "@esbuild/android-x64": "0.19.12", - "@esbuild/darwin-arm64": "0.19.12", - "@esbuild/darwin-x64": "0.19.12", - "@esbuild/freebsd-arm64": "0.19.12", - "@esbuild/freebsd-x64": "0.19.12", - "@esbuild/linux-arm": "0.19.12", - "@esbuild/linux-arm64": "0.19.12", - "@esbuild/linux-ia32": "0.19.12", - "@esbuild/linux-loong64": "0.19.12", - "@esbuild/linux-mips64el": "0.19.12", - "@esbuild/linux-ppc64": "0.19.12", - "@esbuild/linux-riscv64": "0.19.12", - "@esbuild/linux-s390x": "0.19.12", - "@esbuild/linux-x64": "0.19.12", - "@esbuild/netbsd-x64": "0.19.12", - "@esbuild/openbsd-x64": "0.19.12", - "@esbuild/sunos-x64": "0.19.12", - "@esbuild/win32-arm64": "0.19.12", - "@esbuild/win32-ia32": "0.19.12", - "@esbuild/win32-x64": "0.19.12" - } - }, - "node_modules/nakama-runtime": { - "version": "1.44.0", - "resolved": "git+ssh://git@github.com/heroiclabs/nakama-common.git#a1d5d3f7348f8d15e4dfbfe90a68d95f17fac595", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - } - } -} diff --git a/nakama/modules/package.json b/nakama/modules/package.json deleted file mode 100644 index 17fe3d8..0000000 --- a/nakama/modules/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "nakama-realms-modules", - "version": "1.0.0", - "description": "Nakama server modules for realms.india", - "scripts": { - "build": "npx esbuild src/main.ts --bundle --outfile=build/index.js --format=iife --global-name=__nakamaModule__ --target=es2020 --charset=utf8 --main-fields=main,module --footer:js=\"var InitModule = __nakamaModule__.InitModule;\"" - }, - "devDependencies": { - "esbuild": "^0.19.0", - "nakama-runtime": "github:heroiclabs/nakama-common", - "typescript": "^5.3.0" - }, - "dependencies": { - "chess.js": "^1.0.0-beta.8" - } -} diff --git a/nakama/modules/src/main.ts b/nakama/modules/src/main.ts deleted file mode 100644 index 5e9772f..0000000 --- a/nakama/modules/src/main.ts +++ /dev/null @@ -1,1242 +0,0 @@ -/** - * Nakama Server Modules for realms.india - * - * Features: - * - Custom JWT authentication (links to existing app users) - * - Chat completely disabled (use existing chat-service) - * - Chess game with ELO leaderboard - */ - -import { Chess } from 'chess.js'; - -// Op codes for match messages -const OpCode = { - MOVE: 1, - GAME_STATE: 2, - GAME_OVER: 3, - RESIGN: 4, - OFFER_DRAW: 5, - ACCEPT_DRAW: 6 -}; - -// Chess game state interface -interface ChessState { - positionId: number; // Chess960 position ID (0-959) - fen: string; - pgn: string; - whiteId: string; - blackId: string; - whiteName: string; - blackName: string; - whiteColor: string; // User color code - blackColor: string; - turn: 'w' | 'b'; - gameOver: boolean; - result?: string; - moveHistory: string[]; - createdAt: number; // Unix timestamp for timeout - spectators: string[]; // List of spectator user IDs - pendingCancel?: boolean; // Flag set by matchSignal to request termination -} - -// JWT claims from app -interface AppJwtClaims { - user_id: string; - username: string; - iss?: string; - exp?: number; - iat?: number; - is_admin?: string; - is_moderator?: string; - is_streamer?: string; - is_disabled?: string; - color_code?: string; - avatar_url?: string; - token_version?: string; -} - -// Base64 character lookup table -const BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; - -// Generate valid Chess960 starting position -function generateChess960Position(): { fen: string; positionId: number } { - const pieces = new Array(8).fill(''); - - // 1. Place bishops on opposite colors - const lightSquares = [1, 3, 5, 7]; // b, d, f, h - const darkSquares = [0, 2, 4, 6]; // a, c, e, g - const lightBishopIdx = Math.floor(Math.random() * 4); - const darkBishopIdx = Math.floor(Math.random() * 4); - pieces[lightSquares[lightBishopIdx]] = 'B'; - pieces[darkSquares[darkBishopIdx]] = 'B'; - - // 2. Place queen on remaining square - const emptySquares = pieces.map((p, i) => p === '' ? i : -1).filter(i => i >= 0); - const queenIdx = Math.floor(Math.random() * emptySquares.length); - pieces[emptySquares[queenIdx]] = 'Q'; - - // 3. Place knights on remaining squares - const empty2 = pieces.map((p, i) => p === '' ? i : -1).filter(i => i >= 0); - const knight1Idx = Math.floor(Math.random() * empty2.length); - const knight1 = empty2.splice(knight1Idx, 1)[0]; - const knight2Idx = Math.floor(Math.random() * empty2.length); - const knight2 = empty2.splice(knight2Idx, 1)[0]; - pieces[knight1] = 'N'; - pieces[knight2] = 'N'; - - // 4. Place R-K-R in remaining 3 squares (king between rooks) - const remaining = pieces.map((p, i) => p === '' ? i : -1).filter(i => i >= 0); - remaining.sort((a, b) => a - b); - pieces[remaining[0]] = 'R'; - pieces[remaining[1]] = 'K'; - pieces[remaining[2]] = 'R'; - - // Build FEN - const whitePieces = pieces.join(''); - const blackPieces = whitePieces.toLowerCase(); - const fen = `${blackPieces}/pppppppp/8/8/8/8/PPPPPPPP/${whitePieces} w KQkq - 0 1`; - - // Calculate a pseudo position ID (not exact Chess960 numbering, but unique enough) - const positionId = (lightBishopIdx * 4 + darkBishopIdx) * 6 * 10 * 10 + - queenIdx * 10 * 10 + knight1Idx * 10 + knight2Idx; - - return { fen, positionId: positionId % 960 }; -} - -// Pure JavaScript base64 decoder (works in Nakama's Goja runtime without atob) -function base64Decode(str: string): string { - // Remove padding - str = str.replace(/=+$/, ''); - - let output = ''; - let buffer = 0; - let bitsCollected = 0; - - for (let i = 0; i < str.length; i++) { - const char = str[i]; - const value = BASE64_CHARS.indexOf(char); - - if (value === -1) continue; // Skip invalid characters - - buffer = (buffer << 6) | value; - bitsCollected += 6; - - if (bitsCollected >= 8) { - bitsCollected -= 8; - output += String.fromCharCode((buffer >> bitsCollected) & 0xFF); - } - } - - return output; -} - -// Base64 URL decode helper -function base64UrlDecode(str: string): string { - // Add padding if necessary - let padded = str; - const padding = 4 - (str.length % 4); - if (padding !== 4 && padding !== 0) { - padded += '='.repeat(padding); - } - - // Convert base64url to base64 - const base64 = padded.replace(/-/g, '+').replace(/_/g, '/'); - - // Decode using pure JS implementation - const decoded = base64Decode(base64); - - // Handle UTF-8 - try { - return decodeURIComponent( - decoded.split('').map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)).join('') - ); - } catch { - return decoded; - } -} - -// Simple HMAC-SHA256 signature verification -// Note: This is a simplified check - in production you'd use proper crypto -function verifyJwtSignature(headerB64: string, payloadB64: string, signatureB64: string, secret: string): boolean { - // For now, we'll just decode and verify the payload structure - // Nakama's JS runtime has limited crypto support - // The signature verification should ideally be done with proper HMAC - // For production, consider using a before hook that calls back to your main backend - // or implement proper HMAC verification - - // Basic validation that all parts exist - if (!headerB64 || !payloadB64 || !signatureB64 || !secret) { - return false; - } - - // Check header indicates HS256 - try { - const header = JSON.parse(base64UrlDecode(headerB64)); - if (header.alg !== 'HS256') { - return false; - } - } catch { - return false; - } - - return true; -} - -// Validate JWT and extract claims -function validateJwt(token: string, secret: string, logger: nkruntime.Logger): AppJwtClaims { - const parts = token.split('.'); - if (parts.length !== 3) { - throw new Error('Invalid JWT format'); - } - - const [headerB64, payloadB64, signatureB64] = parts; - - // Verify signature (simplified) - if (!verifyJwtSignature(headerB64, payloadB64, signatureB64, secret)) { - throw new Error('Invalid JWT signature'); - } - - // Decode payload - let payload: AppJwtClaims; - try { - payload = JSON.parse(base64UrlDecode(payloadB64)); - } catch (e) { - throw new Error('Invalid JWT payload'); - } - - return payload; -} - -// Module initialization - must be a function declaration for Nakama to find it -function InitModule( - ctx: nkruntime.Context, - logger: nkruntime.Logger, - nk: nkruntime.Nakama, - initializer: nkruntime.Initializer -) { - logger.info('Initializing realms.india Nakama modules'); - - // Register custom authentication hook - initializer.registerBeforeAuthenticateCustom(beforeAuthenticateCustom); - - // Note: Nakama chat is disabled by not exposing chat endpoints from the frontend - // The existing chat-service handles all chat functionality - - // Create chess ELO leaderboard if it doesn't exist - try { - nk.leaderboardCreate( - 'chess-elo', // id - true, // authoritative - nkruntime.SortOrder.DESCENDING, - nkruntime.Operator.SET, - '' // reset schedule (empty = never reset) - ); - logger.info('Chess ELO leaderboard created'); - } catch (e) { - // Leaderboard already exists, that's fine - logger.info('Chess ELO leaderboard already exists'); - } - - // Register chess match handler with named functions - initializer.registerMatch('chess', { - matchInit: chessMatchInit, - matchJoinAttempt: chessMatchJoinAttempt, - matchJoin: chessMatchJoin, - matchLoop: chessMatchLoop, - matchLeave: chessMatchLeave, - matchTerminate: chessMatchTerminate, - matchSignal: chessMatchSignal - }); - logger.info('Chess match handler registered'); - - // Register RPC functions for chess - initializer.registerRpc('create_chess_match', createChessMatchRpc); - initializer.registerRpc('list_chess_matches', listChessMatchesRpc); - initializer.registerRpc('get_chess_leaderboard', getChessLeaderboardRpc); - initializer.registerRpc('cancel_chess_match', cancelChessMatchRpc); - logger.info('Chess RPC functions registered'); - - logger.info('Nakama modules loaded - auth and chess enabled'); -}; - -// Custom authentication hook - validates app JWT -const beforeAuthenticateCustom: nkruntime.BeforeHookFunction = function( - ctx: nkruntime.Context, - logger: nkruntime.Logger, - nk: nkruntime.Nakama, - data: nkruntime.AuthenticateCustomRequest -): nkruntime.AuthenticateCustomRequest | void { - const appJwt = data.account?.id; - - if (!appJwt) { - logger.warn('No JWT token provided in custom auth request'); - throw new Error('Authentication token required'); - } - - const jwtSecret = ctx.env['JWT_SECRET']; - if (!jwtSecret) { - logger.error('JWT_SECRET environment variable not configured'); - throw new Error('Server configuration error'); - } - - let claims: AppJwtClaims; - try { - claims = validateJwt(appJwt, jwtSecret, logger); - } catch (e: any) { - logger.warn(`JWT validation failed: ${e.message}`); - throw new Error(`Authentication failed: ${e.message}`); - } - - // Validate required claims - if (!claims.user_id || !claims.username) { - throw new Error('Invalid token: missing required claims'); - } - - // Check issuer - if (claims.iss !== 'streaming-app') { - throw new Error('Invalid token issuer'); - } - - // Check expiry - const now = Math.floor(Date.now() / 1000); - if (claims.exp && claims.exp < now) { - throw new Error('Token expired'); - } - - // Check if user is disabled - if (claims.is_disabled === '1') { - throw new Error('Account disabled'); - } - - // Replace JWT with user_id for Nakama's custom auth linkage - // Nakama requires custom ID to be 6-128 bytes, so prefix with "user_" - data.account!.id = `user_${claims.user_id}`; - - // Set username - data.username = claims.username; - - // Store metadata in vars - if (!data.account!.vars) { - data.account!.vars = {}; - } - data.account!.vars['app_username'] = claims.username; - data.account!.vars['user_color'] = claims.color_code || '#561D5E'; - data.account!.vars['avatar_url'] = claims.avatar_url || ''; - data.account!.vars['is_admin'] = claims.is_admin || '0'; - data.account!.vars['is_moderator'] = claims.is_moderator || '0'; - - logger.info(`Authenticated user: ${claims.username} (ID: ${claims.user_id})`); - - return data; -}; - -// Chess match handler functions - must be standalone named functions for Nakama JS runtime -const chessMatchInit = function( - ctx: nkruntime.Context, - logger: nkruntime.Logger, - nk: nkruntime.Nakama, - params: { [key: string]: string } -): { state: ChessState; tickRate: number; label: string } { - logger.info(`chessMatchInit called with params: ${JSON.stringify(params)}`); - - // Generate Chess960 starting position - const { fen, positionId } = generateChess960Position(); - - const label = JSON.stringify({ - game: 'chess960', - status: 'waiting', - players: 0, - positionId: positionId, - createdAt: Math.floor(Date.now() / 1000) - }); - - logger.info(`Chess960 match initialized with position #${positionId}, label: ${label}`); - - return { - state: { - positionId: positionId, - fen: fen, - pgn: '', - whiteId: '', - blackId: '', - whiteName: '', - blackName: '', - whiteColor: '#561D5E', - blackColor: '#561D5E', - turn: 'w', - gameOver: false, - moveHistory: [], - createdAt: Math.floor(Date.now() / 1000), - spectators: [] - }, - tickRate: 1, - label: label - }; -}; - -const chessMatchJoinAttempt = function( - ctx: nkruntime.Context, - logger: nkruntime.Logger, - nk: nkruntime.Nakama, - dispatcher: nkruntime.MatchDispatcher, - tick: number, - state: ChessState, - presence: nkruntime.Presence, - metadata: { [key: string]: any } -): { state: ChessState; accept: boolean; rejectMessage?: string } | null { - // Allow players to rejoin if they were already in the match (reconnection) - if (state.whiteId === presence.userId || state.blackId === presence.userId) { - logger.info(`Player ${presence.username} rejoining match as existing player`); - return { state, accept: true }; - } - - // If game is full (2 players), allow as spectator - if (state.whiteId && state.blackId) { - // Check if already a spectator - if (state.spectators.includes(presence.userId)) { - return { state, accept: false, rejectMessage: 'Already spectating' }; - } - // Allow spectators (unlimited) - return { state, accept: true }; - } - - return { state, accept: true }; -}; - -const chessMatchJoin = function( - ctx: nkruntime.Context, - logger: nkruntime.Logger, - nk: nkruntime.Nakama, - dispatcher: nkruntime.MatchDispatcher, - tick: number, - state: ChessState, - presences: nkruntime.Presence[] -): { state: ChessState } | null { - for (const presence of presences) { - // Check if this is a player rejoining - const isRejoiningWhite = state.whiteId === presence.userId; - const isRejoiningBlack = state.blackId === presence.userId; - - if (isRejoiningWhite || isRejoiningBlack) { - const color = isRejoiningWhite ? 'w' : 'b'; - logger.info(`${presence.username} rejoined as ${isRejoiningWhite ? 'WHITE' : 'BLACK'}`); - - // Send current game state to rejoining player - const gameStatus = state.gameOver ? 'finished' : (state.whiteId && state.blackId ? 'playing' : 'waiting'); - dispatcher.broadcastMessage(OpCode.GAME_STATE, JSON.stringify({ - status: gameStatus, - yourColor: color, - fen: state.fen, - positionId: state.positionId, - whiteId: state.whiteId, - blackId: state.blackId, - whiteName: state.whiteName, - blackName: state.blackName, - whiteColor: state.whiteColor, - blackColor: state.blackColor, - turn: state.turn, - moveHistory: state.moveHistory, - gameOver: state.gameOver, - result: state.result - }), [presence]); - - continue; - } - - // Try to get user metadata for color - let userColor = '#561D5E'; // default - try { - const users = nk.usersGetId([presence.userId]); - if (users && users.length > 0 && users[0].metadata) { - userColor = (users[0].metadata as any).user_color || userColor; - } - } catch (e) { - // Ignore errors fetching user metadata - } - - if (!state.whiteId) { - state.whiteId = presence.userId; - state.whiteName = presence.username; - state.whiteColor = userColor; - logger.info(`${presence.username} joined as WHITE`); - - // Update label - dispatcher.matchLabelUpdate(JSON.stringify({ - game: 'chess960', - status: 'waiting', - players: 1, - white: presence.username, - whiteId: presence.userId, - positionId: state.positionId, - createdAt: state.createdAt - })); - - // Notify player they're waiting - dispatcher.broadcastMessage(OpCode.GAME_STATE, JSON.stringify({ - status: 'waiting', - yourColor: 'w', - positionId: state.positionId, - fen: state.fen, - message: 'Waiting for opponent...' - }), [presence]); - - } else if (!state.blackId) { - state.blackId = presence.userId; - state.blackName = presence.username; - state.blackColor = userColor; - logger.info(`${presence.username} joined as BLACK`); - - // Update label - game starting - dispatcher.matchLabelUpdate(JSON.stringify({ - game: 'chess960', - status: 'playing', - players: 2, - white: state.whiteName, - whiteId: state.whiteId, - black: state.blackName, - blackId: state.blackId, - positionId: state.positionId, - createdAt: state.createdAt - })); - - // Game can start - notify both players - dispatcher.broadcastMessage(OpCode.GAME_STATE, JSON.stringify({ - status: 'playing', - fen: state.fen, - positionId: state.positionId, - whiteId: state.whiteId, - blackId: state.blackId, - whiteName: state.whiteName, - blackName: state.blackName, - whiteColor: state.whiteColor, - blackColor: state.blackColor, - turn: state.turn - })); - - logger.info(`Chess960 match started: ${state.whiteName} vs ${state.blackName} (Position #${state.positionId})`); - } else { - // Player joins as spectator - state.spectators.push(presence.userId); - logger.info(`${presence.username} joined as spectator`); - - // Update label with spectator count - dispatcher.matchLabelUpdate(JSON.stringify({ - game: 'chess960', - status: 'playing', - players: 2, - white: state.whiteName, - whiteId: state.whiteId, - black: state.blackName, - blackId: state.blackId, - positionId: state.positionId, - createdAt: state.createdAt, - spectatorCount: state.spectators.length - })); - - // Send current game state to spectator - dispatcher.broadcastMessage(OpCode.GAME_STATE, JSON.stringify({ - status: 'spectating', - fen: state.fen, - positionId: state.positionId, - whiteId: state.whiteId, - blackId: state.blackId, - whiteName: state.whiteName, - blackName: state.blackName, - whiteColor: state.whiteColor, - blackColor: state.blackColor, - turn: state.turn, - moveHistory: state.moveHistory - }), [presence]); - } - } - - return { state }; -}; - -const chessMatchLoop = function( - ctx: nkruntime.Context, - logger: nkruntime.Logger, - nk: nkruntime.Nakama, - dispatcher: nkruntime.MatchDispatcher, - tick: number, - state: ChessState, - messages: nkruntime.MatchMessage[] -): { state: ChessState } | null { - // Check for pending cancellation from matchSignal - if (state.pendingCancel) { - logger.info('Match terminated due to pendingCancel flag'); - return null; // Terminate match - } - - // Check for pending cancellation from RPC (storage-based fallback) - // This handles cases where matchSignal fails (e.g., match just created) - if (tick % 5 === 0) { - try { - const cancelFlags = nk.storageRead([{ - collection: 'chess_cancel', - key: ctx.matchId, - userId: '00000000-0000-0000-0000-000000000000' - }]); - if (cancelFlags && cancelFlags.length > 0) { - // Clean up the flag - nk.storageDelete([{ - collection: 'chess_cancel', - key: ctx.matchId, - userId: '00000000-0000-0000-0000-000000000000' - }]); - logger.info('Match cancelled via storage flag'); - return null; // Terminate match - } - } catch (e) { - // Ignore storage read errors - not critical - } - } - - // Check for timeout on waiting matches (every ~10 seconds) - if (tick % 10 === 0 && state.whiteId && !state.blackId && !state.gameOver) { - const now = Math.floor(Date.now() / 1000); - const waitTime = now - state.createdAt; - - if (waitTime > 900) { // 15 minutes - logger.info(`Match timeout - no opponent joined after ${waitTime}s`); - dispatcher.broadcastMessage(OpCode.GAME_OVER, JSON.stringify({ - result: 'timeout', - reason: 'Challenge expired - no opponent joined within 15 minutes' - })); - return null; // Terminate match - } - } - - for (const message of messages) { - const playerId = message.sender.userId; - - switch (message.opCode) { - case OpCode.MOVE: { - if (state.gameOver) { - logger.warn('Move attempted on finished game'); - continue; - } - - const moveData = JSON.parse(nk.binaryToString(message.data)); - - // Validate it's this player's turn - const isWhite = playerId === state.whiteId; - const isBlack = playerId === state.blackId; - - if ((state.turn === 'w' && !isWhite) || (state.turn === 'b' && !isBlack)) { - logger.warn(`Not ${playerId}'s turn`); - continue; - } - - // Validate and apply the move - const validMove = validateChessMove(state.fen, moveData.from, moveData.to, moveData.promotion); - - if (!validMove.valid) { - logger.warn(`Invalid move: ${JSON.stringify(moveData)}`); - continue; - } - - // Update state - state.fen = validMove.newFen; - state.turn = validMove.turn as 'w' | 'b'; - state.moveHistory.push(`${moveData.from}-${moveData.to}`); - - // Check game over conditions - if (validMove.gameOver) { - state.gameOver = true; - state.result = validMove.result; - - // Update ELO - if (state.result === '1-0') { - updateElo(nk, logger, state.whiteId, state.whiteName, state.blackId, state.blackName, false); - } else if (state.result === '0-1') { - updateElo(nk, logger, state.blackId, state.blackName, state.whiteId, state.whiteName, false); - } else { - // Draw - updateElo(nk, logger, state.whiteId, state.whiteName, state.blackId, state.blackName, true); - } - - dispatcher.broadcastMessage(OpCode.GAME_OVER, JSON.stringify({ - fen: state.fen, - result: state.result, - reason: validMove.reason - })); - - // Update label - dispatcher.matchLabelUpdate(JSON.stringify({ - game: 'chess960', - status: 'finished', - result: state.result, - white: state.whiteName, - whiteId: state.whiteId, - black: state.blackName, - blackId: state.blackId, - positionId: state.positionId - })); - - logger.info(`Game over: ${state.result} (${validMove.reason})`); - } else { - // Broadcast move to all players - dispatcher.broadcastMessage(OpCode.MOVE, JSON.stringify({ - move: moveData, - fen: state.fen, - turn: state.turn - })); - } - break; - } - - case OpCode.RESIGN: { - // Only players can resign - spectators cannot affect the game - const isPlayerWhite = playerId === state.whiteId; - const isPlayerBlack = playerId === state.blackId; - if (!isPlayerWhite && !isPlayerBlack) { - logger.warn(`Spectator ${message.sender.username} attempted to resign - ignoring`); - continue; - } - - state.gameOver = true; - state.result = isPlayerWhite ? '0-1' : '1-0'; - - // Winner gets ELO - const winnerId = isPlayerWhite ? state.blackId : state.whiteId; - const winnerName = isPlayerWhite ? state.blackName : state.whiteName; - const loserName = isPlayerWhite ? state.whiteName : state.blackName; - updateElo(nk, logger, winnerId, winnerName, playerId, loserName, false); - - dispatcher.broadcastMessage(OpCode.GAME_OVER, JSON.stringify({ - result: state.result, - reason: 'resignation' - })); - - logger.info(`${message.sender.username} resigned`); - break; - } - } - } - - return { state }; -}; - -const chessMatchLeave = function( - ctx: nkruntime.Context, - logger: nkruntime.Logger, - nk: nkruntime.Nakama, - dispatcher: nkruntime.MatchDispatcher, - tick: number, - state: ChessState, - presences: nkruntime.Presence[] -): { state: ChessState } | null { - for (const presence of presences) { - // Check if this is a spectator leaving - const spectatorIndex = state.spectators.indexOf(presence.userId); - if (spectatorIndex !== -1) { - state.spectators.splice(spectatorIndex, 1); - logger.info(`Spectator ${presence.username} left the match`); - - // Update label with new spectator count - if (state.whiteId && state.blackId) { - dispatcher.matchLabelUpdate(JSON.stringify({ - game: 'chess960', - status: state.gameOver ? 'finished' : 'playing', - players: 2, - white: state.whiteName, - whiteId: state.whiteId, - black: state.blackName, - blackId: state.blackId, - positionId: state.positionId, - createdAt: state.createdAt, - spectatorCount: state.spectators.length - })); - } - continue; - } - - logger.info(`Player ${presence.username} left the match`); - - // If match is still waiting (only white player, no black), DON'T terminate immediately - // The 15-minute timeout will clean up abandoned challenges - // Explicit cancellation is handled by the cancel_chess_match RPC - if (state.whiteId && !state.blackId && presence.userId === state.whiteId) { - logger.info(`White player ${presence.username} disconnected from waiting challenge - will timeout if not cancelled`); - // Don't terminate - let timeout handle it or explicit cancel RPC - return { state }; - } - - // If game in progress, opponent wins by forfeit - if (!state.gameOver && state.whiteId && state.blackId) { - const isPlayer = presence.userId === state.whiteId || presence.userId === state.blackId; - if (isPlayer) { - const isWhiteLeaving = presence.userId === state.whiteId; - state.gameOver = true; - state.result = isWhiteLeaving ? '0-1' : '1-0'; - - // Winner gets ELO - const winnerId = isWhiteLeaving ? state.blackId : state.whiteId; - const winnerName = isWhiteLeaving ? state.blackName : state.whiteName; - const loserName = isWhiteLeaving ? state.whiteName : state.blackName; - updateElo(nk, logger, winnerId, winnerName, presence.userId, loserName, false); - - dispatcher.broadcastMessage(OpCode.GAME_OVER, JSON.stringify({ - result: state.result, - reason: 'forfeit' - })); - - logger.info(`Game forfeited: ${state.result}`); - } - } - } - - return { state }; -}; - -const chessMatchTerminate = function( - ctx: nkruntime.Context, - logger: nkruntime.Logger, - nk: nkruntime.Nakama, - dispatcher: nkruntime.MatchDispatcher, - tick: number, - state: ChessState, - graceSeconds: number -): { state: ChessState } | null { - logger.info('Match terminating'); - return { state }; -}; - -const chessMatchSignal = function( - ctx: nkruntime.Context, - logger: nkruntime.Logger, - nk: nkruntime.Nakama, - dispatcher: nkruntime.MatchDispatcher, - tick: number, - state: ChessState, - data: string -): { state: ChessState; data?: string } | null { - logger.info(`Match signal received: ${data}`); - - // Handle cancel signal from RPC - if (data === 'cancel') { - logger.info('Match cancelled via signal - setting pendingCancel flag'); - state.pendingCancel = true; - // Return state with flag set - matchLoop will terminate on next tick - // (Returning null here causes Nakama error: "matchSignal is expected to return an object with 'state' property") - } - - return { state }; -}; - -// Chess move validation using chess.js for proper legal move checking -function validateChessMove( - fen: string, - from: string, - to: string, - promotion?: string -): { valid: boolean; newFen: string; turn: string; gameOver: boolean; result?: string; reason?: string } { - try { - const chess = new Chess(fen); - - // Attempt the move - chess.js validates legality - const move = chess.move({ - from, - to, - promotion: promotion || undefined - }); - - if (!move) { - return { valid: false, newFen: fen, turn: chess.turn(), gameOver: false }; - } - - const newFen = chess.fen(); - const newTurn = chess.turn(); - - // Check game over conditions - if (chess.isCheckmate()) { - return { - valid: true, - newFen, - turn: newTurn, - gameOver: true, - result: newTurn === 'w' ? '0-1' : '1-0', - reason: 'checkmate' - }; - } - - if (chess.isStalemate()) { - return { - valid: true, - newFen, - turn: newTurn, - gameOver: true, - result: '1/2-1/2', - reason: 'stalemate' - }; - } - - if (chess.isDraw()) { - let reason = 'draw'; - if (chess.isThreefoldRepetition()) reason = 'threefold repetition'; - else if (chess.isInsufficientMaterial()) reason = 'insufficient material'; - // 50-move rule is part of isDraw() - - return { - valid: true, - newFen, - turn: newTurn, - gameOver: true, - result: '1/2-1/2', - reason - }; - } - - return { - valid: true, - newFen, - turn: newTurn, - gameOver: false - }; - } catch (e) { - return { valid: false, newFen: fen, turn: 'w', gameOver: false }; - } -} - -// Update ELO ratings -function updateElo( - nk: nkruntime.Nakama, - logger: nkruntime.Logger, - winnerId: string, - winnerName: string, - loserId: string, - loserName: string, - isDraw: boolean -): void { - const K = 32; // ELO K-factor - const DEFAULT_ELO = 1200; - - try { - // Ensure we have usernames - fetch from account if not provided - let actualWinnerName = winnerName; - let actualLoserName = loserName; - - if (!actualWinnerName || actualWinnerName === 'undefined') { - try { - const users = nk.usersGetId([winnerId]); - if (users && users.length > 0) { - actualWinnerName = users[0].username || 'Unknown'; - } - } catch (e) { - actualWinnerName = 'Unknown'; - } - } - - if (!actualLoserName || actualLoserName === 'undefined') { - try { - const users = nk.usersGetId([loserId]); - if (users && users.length > 0) { - actualLoserName = users[0].username || 'Unknown'; - } - } catch (e) { - actualLoserName = 'Unknown'; - } - } - - logger.info(`updateElo called: winner=${actualWinnerName} (${winnerId}), loser=${actualLoserName} (${loserId})`); - - // Get current ratings - const winnerRecords = nk.leaderboardRecordsList('chess-elo', [winnerId], 1, '', 0); - const loserRecords = nk.leaderboardRecordsList('chess-elo', [loserId], 1, '', 0); - - const winnerElo = winnerRecords.records?.[0]?.score ?? DEFAULT_ELO; - const loserElo = loserRecords.records?.[0]?.score ?? DEFAULT_ELO; - - // Calculate expected scores - const expectedWinner = 1 / (1 + Math.pow(10, (loserElo - winnerElo) / 400)); - const expectedLoser = 1 - expectedWinner; - - // Calculate new ratings - let newWinnerElo: number, newLoserElo: number; - - if (isDraw) { - newWinnerElo = Math.round(winnerElo + K * (0.5 - expectedWinner)); - newLoserElo = Math.round(loserElo + K * (0.5 - expectedLoser)); - } else { - newWinnerElo = Math.round(winnerElo + K * (1 - expectedWinner)); - newLoserElo = Math.round(loserElo + K * (0 - expectedLoser)); - } - - // Ensure minimum ELO of 100 - newWinnerElo = Math.max(100, newWinnerElo); - newLoserElo = Math.max(100, newLoserElo); - - // Update leaderboard with usernames - nk.leaderboardRecordWrite('chess-elo', winnerId, actualWinnerName, newWinnerElo, 0, {}); - nk.leaderboardRecordWrite('chess-elo', loserId, actualLoserName, newLoserElo, 0, {}); - - logger.info(`ELO updated - ${actualWinnerName}: ${winnerElo} -> ${newWinnerElo}, ${actualLoserName}: ${loserElo} -> ${newLoserElo}`); - } catch (e: any) { - logger.error(`Failed to update ELO: ${e.message}`); - } -} - -// RPC: Create chess match -const createChessMatchRpc: nkruntime.RpcFunction = function( - ctx: nkruntime.Context, - logger: nkruntime.Logger, - nk: nkruntime.Nakama, - payload: string -): string { - if (!ctx.userId) { - throw new Error('Authentication required'); - } - - logger.info(`Creating chess match for user: ${ctx.username} (${ctx.userId})`); - - // Check if user already has an active challenge (waiting or playing) - const existingMatches = nk.matchList(100, true, null, 0, null, null); - for (const match of existingMatches) { - if (!match.label) continue; - try { - const label = JSON.parse(match.label); - if (label.game !== 'chess960') continue; - - // Check if user is already a player in an active match - if (label.whiteId === ctx.userId || label.blackId === ctx.userId) { - if (label.status === 'waiting' || label.status === 'playing') { - logger.info(`User ${ctx.username} already has an active match: ${match.matchId}`); - return JSON.stringify({ - error: 'You already have an active challenge. Cancel it or finish your game first.', - existingMatchId: match.matchId - }); - } - } - } catch (e) { - // Skip matches with invalid labels - } - } - - try { - const matchId = nk.matchCreate('chess', {}); - logger.info(`Chess match created successfully: ${matchId} by ${ctx.username}`); - return JSON.stringify({ matchId }); - } catch (e: any) { - logger.error(`Failed to create chess match: ${e.message}`); - throw e; - } -}; - -// RPC: List active chess matches -const listChessMatchesRpc: nkruntime.RpcFunction = function( - ctx: nkruntime.Context, - logger: nkruntime.Logger, - nk: nkruntime.Nakama, - payload: string -): string { - // Handle double-encoded JSON from nakama-js client - let params: any = {}; - if (payload) { - let parsed = JSON.parse(payload); - // If still a string, parse again (nakama-js double-encodes) - if (typeof parsed === 'string') { - parsed = JSON.parse(parsed); - } - params = parsed; - } - const limit = params.limit ?? 20; - const status = params.status; // 'waiting', 'playing', 'all' - - // Query for all authoritative matches first, then filter by label - // minSize=0 to include matches waiting for players, maxSize=null for no limit - let matches = nk.matchList(limit * 2, true, null, 0, null, null); - - logger.info(`Found ${matches.length} total authoritative matches (minSize=0)`); - - // Log all matches for debugging - matches.forEach((m, i) => { - logger.info(`Match ${i}: id=${m.matchId}, size=${m.size}, label=${m.label}`); - }); - - // Filter to only chess960 matches - matches = matches.filter(m => { - if (!m.label) { - logger.info(`Match ${m.matchId} has no label, skipping`); - return false; - } - try { - const label = JSON.parse(m.label); - const isChess = label.game === 'chess960'; - if (!isChess) { - logger.info(`Match ${m.matchId} is not chess960: ${m.label}`); - } - return isChess; - } catch (e) { - logger.warn(`Failed to parse label for match ${m.matchId}: ${m.label}`); - return false; - } - }); - - logger.info(`Found ${matches.length} chess960 matches after filtering`); - - // Get current user ID to check if they're in any match - const currentUserId = ctx.userId; - logger.info(`Current user ID for match check: ${currentUserId}`); - - // Map and filter matches - const result = matches.map(m => { - let label: any = {}; - try { - label = m.label ? JSON.parse(m.label) : {}; - } catch (e) { - logger.warn(`Failed to parse match label: ${m.label}`); - } - - // Check if current user is a player in this match - let isCurrentUserMatch = false; - let currentUserColor = null; - if (currentUserId) { - logger.info(`Checking match ${m.matchId}: whiteId=${label.whiteId}, blackId=${label.blackId}, currentUserId=${currentUserId}`); - if (label.whiteId && label.whiteId === currentUserId) { - isCurrentUserMatch = true; - currentUserColor = 'w'; - logger.info(` -> User is WHITE player`); - } else if (label.blackId && label.blackId === currentUserId) { - isCurrentUserMatch = true; - currentUserColor = 'b'; - logger.info(` -> User is BLACK player`); - } else { - logger.info(` -> User is NOT a player in this match`); - } - } - - return { - matchId: m.matchId, - status: label.status || 'unknown', - white: label.white || null, - black: label.black || null, - positionId: label.positionId, - createdAt: label.createdAt, - spectatorCount: label.spectatorCount || 0, - size: m.size, - isCurrentUserMatch, - currentUserColor - }; - }).filter(m => { - // Filter by status if specified - if (!status || status === 'all') return true; - return m.status === status; - }).slice(0, limit); - - logger.info(`Returning ${result.length} matches after filtering for status: ${status}`); - - return JSON.stringify({ matches: result }); -}; - -// RPC: Get chess leaderboard -const getChessLeaderboardRpc: nkruntime.RpcFunction = function( - ctx: nkruntime.Context, - logger: nkruntime.Logger, - nk: nkruntime.Nakama, - payload: string -): string { - const params = payload ? JSON.parse(payload) : {}; - const limit = params.limit ?? 20; - - const records = nk.leaderboardRecordsList('chess-elo', [], limit, '', 0); - - return JSON.stringify({ - records: records.records?.map(r => ({ - rank: r.rank, - userId: r.ownerId, - username: r.username, - elo: r.score - })) ?? [] - }); -}; - -// RPC: Cancel a chess challenge (waiting match) -const cancelChessMatchRpc: nkruntime.RpcFunction = function( - ctx: nkruntime.Context, - logger: nkruntime.Logger, - nk: nkruntime.Nakama, - payload: string -): string { - if (!ctx.userId) { - throw new Error('Authentication required'); - } - - // Handle double-encoded JSON from nakama-js client - let params: any = {}; - if (payload) { - let parsed = JSON.parse(payload); - if (typeof parsed === 'string') { - parsed = JSON.parse(parsed); - } - params = parsed; - } - - const matchId = params.matchId; - if (!matchId) { - return JSON.stringify({ success: false, error: 'Match ID required' }); - } - - logger.info(`User ${ctx.username} requesting to cancel match: ${matchId}`); - - // Find the match and verify ownership - const matches = nk.matchList(100, true, null, 0, null, null); - let targetMatch = null; - - for (const match of matches) { - if (match.matchId === matchId) { - targetMatch = match; - break; - } - } - - if (!targetMatch) { - return JSON.stringify({ success: false, error: 'Match not found' }); - } - - // Parse label to check ownership - let label: any = {}; - try { - label = targetMatch.label ? JSON.parse(targetMatch.label) : {}; - } catch (e) { - return JSON.stringify({ success: false, error: 'Invalid match' }); - } - - // Only the white player (creator) can cancel a waiting match - if (label.whiteId !== ctx.userId) { - return JSON.stringify({ success: false, error: 'You can only cancel your own challenges' }); - } - - // Only waiting matches can be cancelled - if (label.status !== 'waiting') { - return JSON.stringify({ success: false, error: 'Can only cancel waiting challenges' }); - } - - // Signal the match to terminate - try { - nk.matchSignal(matchId, 'cancel'); - logger.info(`Match ${matchId} cancelled by ${ctx.username}`); - return JSON.stringify({ success: true }); - } catch (e: any) { - // matchSignal can fail if match runtime not ready - use storage-based cancel - logger.warn(`matchSignal failed, using storage-based cancel: ${e.message}`); - try { - nk.storageWrite([{ - collection: 'chess_cancel', - key: matchId, - userId: '00000000-0000-0000-0000-000000000000', - value: { cancelledBy: ctx.userId, cancelledAt: Date.now() }, - permissionRead: 0, - permissionWrite: 0 - }]); - logger.info(`Match ${matchId} marked for cancellation by ${ctx.username}`); - return JSON.stringify({ success: true }); - } catch (storageErr: any) { - logger.error(`Failed to cancel match: ${storageErr.message}`); - return JSON.stringify({ success: false, error: 'Failed to cancel match' }); - } - } -}; - -// Export for esbuild bundling - will be exposed globally via footer -export { InitModule }; diff --git a/nakama/modules/tsconfig.json b/nakama/modules/tsconfig.json deleted file mode 100644 index 2709b3b..0000000 --- a/nakama/modules/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "ES2020", - "moduleResolution": "node", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "outDir": "./build", - "declaration": true, - "sourceMap": false - }, - "include": ["src/**/*"], - "files": ["node_modules/nakama-runtime/index.d.ts"] -}