From b74e31e7633a04c67d850f371f6076a8701a74ff Mon Sep 17 00:00:00 2001 From: Harsh16gupta Date: Wed, 20 May 2026 04:20:18 +0530 Subject: [PATCH 1/5] chore: integrate Express and WebSocket servers onto a single port for easy deployment --- backend1/dist/app.js | 4 ++-- backend1/dist/index.js | 21 ++++++++++++--------- backend1/src/app.ts | 4 ++-- backend1/src/index.ts | 26 ++++++++++++++++---------- frontend/src/hooks/useSockets.ts | 2 +- frontend/src/main.tsx | 2 +- frontend/src/screens/Email.tsx | 3 ++- 7 files changed, 36 insertions(+), 26 deletions(-) diff --git a/backend1/dist/app.js b/backend1/dist/app.js index 0f4298a..9e0c2b1 100644 --- a/backend1/dist/app.js +++ b/backend1/dist/app.js @@ -11,8 +11,8 @@ const app = (0, express_1.default)(); // ✅ Use JSON parser first app.use(express_1.default.json()); app.use((0, cors_1.default)({ - origin: 'http://localhost:5173', - credentials: true, // if you're using cookies/auth headers + origin: process.env.CORS_ORIGIN || 'http://localhost:5173', + credentials: true, })); // ✅ Then your routes app.use('/api/auth', auth_route_1.default); diff --git a/backend1/dist/index.js b/backend1/dist/index.js index 56688cd..3ec1b3f 100644 --- a/backend1/dist/index.js +++ b/backend1/dist/index.js @@ -4,18 +4,21 @@ var __importDefault = (this && this.__importDefault) || function (mod) { }; Object.defineProperty(exports, "__esModule", { value: true }); const dotenv_1 = __importDefault(require("dotenv")); -const app_1 = __importDefault(require("./app")); dotenv_1.default.config(); -const PORT = process.env.PORT || 3000; -console.log(PORT); -app_1.default.listen(PORT, () => { - console.log(`Server is running at http://localhost:${PORT}`); -}); +const http_1 = require("http"); const ws_1 = require("ws"); +const app_1 = __importDefault(require("./app")); const GameManager_1 = require("./GameManager"); -const wss = new ws_1.WebSocketServer({ port: 8080 }); +const PORT = process.env.PORT || 3000; +// Create a single HTTP server for both Express and WebSocket +const server = (0, http_1.createServer)(app_1.default); +// Attach WebSocket to the same server (upgrades on ws:// connections) +const wss = new ws_1.WebSocketServer({ server }); const gameManager = new GameManager_1.GameManager(); -wss.on('connection', function connection(ws) { +wss.on('connection', (ws) => { gameManager.addUser(ws); - ws.on("close", () => gameManager.removeUser(ws)); + ws.on('close', () => gameManager.removeUser(ws)); +}); +server.listen(PORT, () => { + console.log(`Server running on port ${PORT} (HTTP + WebSocket)`); }); diff --git a/backend1/src/app.ts b/backend1/src/app.ts index 7e8f2da..4d64da5 100644 --- a/backend1/src/app.ts +++ b/backend1/src/app.ts @@ -8,8 +8,8 @@ const app = express(); // ✅ Use JSON parser first app.use(express.json()); app.use(cors({ - origin: 'http://localhost:5173', - credentials: true, // if you're using cookies/auth headers + origin: process.env.CORS_ORIGIN || 'http://localhost:5173', + credentials: true, })); // ✅ Then your routes diff --git a/backend1/src/index.ts b/backend1/src/index.ts index d92f647..7a8ea56 100644 --- a/backend1/src/index.ts +++ b/backend1/src/index.ts @@ -1,19 +1,25 @@ import dotenv from 'dotenv'; -import app from './app'; dotenv.config(); -const PORT = process.env.PORT || 3000; -console.log(PORT); -app.listen(PORT, () => { - console.log(`Server is running at http://localhost:${PORT}`); -}); +import { createServer } from 'http'; import { WebSocketServer } from 'ws'; +import app from './app'; import { GameManager } from './GameManager'; -const wss = new WebSocketServer({ port: 8080 }); +const PORT = process.env.PORT || 3000; + +// Create a single HTTP server for both Express and WebSocket +const server = createServer(app); + +// Attach WebSocket to the same server (upgrades on ws:// connections) +const wss = new WebSocketServer({ server }); const gameManager = new GameManager(); -wss.on('connection', function connection(ws) { - gameManager.addUser(ws); - ws.on("close", () => gameManager.removeUser(ws)); +wss.on('connection', (ws) => { + gameManager.addUser(ws); + ws.on('close', () => gameManager.removeUser(ws)); +}); + +server.listen(PORT, () => { + console.log(`Server running on port ${PORT} (HTTP + WebSocket)`); }); diff --git a/frontend/src/hooks/useSockets.ts b/frontend/src/hooks/useSockets.ts index 0c5b8d8..143fd17 100644 --- a/frontend/src/hooks/useSockets.ts +++ b/frontend/src/hooks/useSockets.ts @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; -const WS_URL = "ws://localhost:8080"; +const WS_URL = import.meta.env.VITE_WS_URL || "ws://localhost:3000"; /** Max reconnection delay in ms */ const MAX_DELAY = 10_000; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index ad7d249..d9bede1 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -5,7 +5,7 @@ import { GoogleOAuthProvider } from '@react-oauth/google'; ReactDOM.createRoot(document.getElementById('root')!).render( - + diff --git a/frontend/src/screens/Email.tsx b/frontend/src/screens/Email.tsx index 0565bf5..9615953 100644 --- a/frontend/src/screens/Email.tsx +++ b/frontend/src/screens/Email.tsx @@ -16,7 +16,8 @@ export default function Email() { setStatus("loading"); try { - const res = await axios.post('http://localhost:3000/api/auth/signup', { + const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3000'; + const res = await axios.post(`${apiUrl}/api/auth/signup`, { email, password, }); From 7753a9e3f213460fe25763c09bec01ff31ee9a35 Mon Sep 17 00:00:00 2001 From: Harsh16gupta Date: Wed, 20 May 2026 04:20:59 +0530 Subject: [PATCH 2/5] chore: add essential architectural comments explaining server binds, matchmaking lifecycle, and socket reconnect backoffs --- backend1/src/Game.ts | 84 ++++++++++++++++++++++---------- backend1/src/GameManager.ts | 57 ++++++++++++++++++---- backend1/src/app.ts | 18 ++++--- backend1/src/index.ts | 14 +++++- frontend/src/hooks/useSockets.ts | 43 +++++++++++++--- 5 files changed, 163 insertions(+), 53 deletions(-) diff --git a/backend1/src/Game.ts b/backend1/src/Game.ts index a36ab8f..b8f953a 100644 --- a/backend1/src/Game.ts +++ b/backend1/src/Game.ts @@ -4,22 +4,27 @@ import { GAME_OVER, INIT_GAME, MOVE, CHAT_MESSAGE } from "./messages"; type Color = "white" | "black"; +/** + * The Game class controls an active match between two players. + * It manages the chess board state machine (via chess.js), handles move validation, + * synchronizes player timers, and handles live in-game chat messaging. + */ export class Game { - public player1: WebSocket; // white - public player2: WebSocket; // black - public name1: string; - public name2: string; - public board: Chess; - private lastMoveTime: number; - public timeLeft: { white: number; black: number }; - private ended: boolean = false; + public player1: WebSocket; // WebSocket for White + public player2: WebSocket; // WebSocket for Black + public name1: string; // Registered profile or guest name for White + public name2: string; // Registered profile or guest name for Black + public board: Chess; // Chess rule engine instance (chess.js) + private lastMoveTime: number; // Unix timestamp of the last validated move, used for clock math + public timeLeft: { white: number; black: number }; // In-game timers represented in milliseconds + private ended: boolean = false; // Flag to prevent duplicate game-over triggers or moves after game ends constructor( player1: WebSocket, player2: WebSocket, name1: string, name2: string, - initialTimeMs = 5 * 60 * 1000 // default 5 minutes + initialTimeMs = 5 * 60 * 1000 // Defaults to standard Blitz: 5 minutes per player ) { this.player1 = player1; this.player2 = player2; @@ -29,7 +34,8 @@ export class Game { this.lastMoveTime = Date.now(); this.timeLeft = { white: initialTimeMs, black: initialTimeMs }; - // init messages (each player needs their own payload) + // Broadcast the INIT_GAME handshake payloads independently to both clients. + // This establishes their piece colors and sets their opponents. this.safeSend(this.player1, { type: INIT_GAME, payload: { @@ -53,15 +59,22 @@ export class Game { // --- Public API --- + /** + * Commits a chess move if validation checks succeed. + * Ensures the socket matches the player whose turn it is, validates moves for legality, + * performs clock time deduction, updates the board representation, and triggers match end + * if victory or draw conditions are met. + */ makeMove(socket: WebSocket, move: { from: string; to: string }) { + // If the game has already concluded, ignore any subsequent incoming moves if (this.ended) return; - // verify who's making the move matches current player (optional but recommended) const holderOfWhite = this.player1; const holderOfBlack = this.player2; const turnBeforeMove: Color = this.board.turn() === "w" ? "white" : "black"; - // verify socket belongs to the player whose turn it is + // 1. Strict Turn Enforcement + // Verify that the socket proposing the move belongs to the active turn player. const expectedSocket = turnBeforeMove === "white" ? holderOfWhite : holderOfBlack; if (socket !== expectedSocket) { @@ -72,6 +85,8 @@ export class Game { return; } + // 2. Legality Verification + // Retrieve list of all mathematically legal chess moves in the current position. const legalMoves = this.board.moves({ verbose: true }); const isLegal = legalMoves.some( (m) => m.from === move.from && m.to === move.to @@ -85,15 +100,17 @@ export class Game { return; } + // 3. Passive Chess Clock Math + // Note: The backend tracks player clocks passively to minimize active CPU execution. + // When a move is completed, the duration since the last move is calculated and deducted. const now = Date.now(); const elapsed = now - this.lastMoveTime; - // Subtract elapsed time from the player who just moved (turnBeforeMove). - // Example: if turnBeforeMove === 'white', white is about to move and will be the one using elapsed time. this.timeLeft[turnBeforeMove] -= elapsed; this.lastMoveTime = now; - // If the player ran out of time BEFORE making this move, treat as timeout (they lost). + // Verify whether the moving player's clock fell below 0 BEFORE committing this move. + // If a timeout occurred, they forfeit the match immediately. if (this.timeLeft[turnBeforeMove] <= 0) { const winner: Color = turnBeforeMove === "white" ? "black" : "white"; const winnerName = winner === "white" ? this.name1 : this.name2; @@ -106,16 +123,17 @@ export class Game { return; } - // Make the move on the board + // 4. State Update + // Commit the legal move to the chess.js state engine. this.board.move(move); - // Prepare and broadcast move message + // Prepare state update payload and broadcast to both players. const moveMessage = { type: MOVE, payload: { ok: true, move, - board: this.board.fen(), + board: this.board.fen(), // Send board representation in FEN notation turn: this.board.turn() === "w" ? "white" : "black", timeLeft: this.timeLeft, players: { white: this.name1, black: this.name2 }, @@ -124,9 +142,10 @@ export class Game { this.sendToBoth(moveMessage); - // After move, check chess-end conditions + // 5. Game Termination Audits + // A. Checkmate Resolution if (this.board.isCheckmate()) { - // The side to move after the move lost (the side that was put in checkmate) + // The side to move AFTER the current move is checkmated and loses. const loser = this.board.turn() === "w" ? "white" : "black"; const winner: Color = loser === "white" ? "black" : "white"; const winnerName = winner === "white" ? this.name1 : this.name2; @@ -139,7 +158,8 @@ export class Game { return; } - // Draw conditions: stalemate, insufficient material, threefold repetition, or generic draw + // B. Draw Resolution + // Covers: Stalemate, Insufficient Material (e.g. King vs King), Threefold Repetition, 50-move rule if ( this.board.isStalemate() || this.board.isInsufficientMaterial() || @@ -153,15 +173,16 @@ export class Game { }); return; } - - // Note: clocks continue from lastMoveTime; next player's clock will be reduced on their next move or if you implement a periodic checker. } + /** + * Broadcasts sanitized and length-limited real-time chat messages to both clients. + */ sendChatMessage(senderSocket: WebSocket, text: string) { if (this.ended) return; const senderName = senderSocket === this.player1 ? this.name1 : this.name2; - // Basic sanitization: trim and limit length + // Basic sanitization: trim leading/trailing whitespace and truncate at 1000 characters const trimmed = typeof text === "string" ? text.trim().slice(0, 1000) : ""; const message = { @@ -174,6 +195,10 @@ export class Game { // --- Helpers --- + /** + * Ends the match session, locks the ended state to prevent double execution, + * and broadcasts the final result metrics to both players. + */ private endGame(payload: { result: "checkmate" | "draw" | "timeout"; winner: Color | null; @@ -199,20 +224,25 @@ export class Game { this.sendToBoth(gameOverMessage); } + /** + * Helper to write payloads to both players' WebSocket streams. + */ private sendToBoth(payload: any) { this.safeSend(this.player1, payload); this.safeSend(this.player2, payload); } + /** + * Safe socket transmitter which swallows connection-drop faults silently + * to guarantee server thread stability. + */ private safeSend(ws: WebSocket, payload: any) { try { - // WebSocket.OPEN === 1 in 'ws' if ((ws as any).readyState === (WebSocket as any).OPEN) { ws.send(JSON.stringify(payload)); } } catch (err) { - // swallow send errors; optionally log - // console.warn("send failed", err); + // Swallowed: network transport issues do not deserve runtime crash overhead } } } diff --git a/backend1/src/GameManager.ts b/backend1/src/GameManager.ts index 6edb18d..4bdfb23 100644 --- a/backend1/src/GameManager.ts +++ b/backend1/src/GameManager.ts @@ -2,15 +2,23 @@ import { WebSocket } from "ws"; import { INIT_GAME, MOVE, CHAT_MESSAGE } from "./messages"; import { Game } from "./Game"; +/** + * Represents a connected user holding a reference to their active WebSocket connection + * and a profile name (either a database-registered username or temporary Guest string). + */ type User = { socket: WebSocket; name: string; }; +/** + * GameManager handles matchmaking queues, maps active WebSocket connections to active game sessions, + * and handles multiplexing incoming real-time socket actions (moves, chats, matchmaking). + */ export class GameManager { - private games: Set; // track active games - private socketToGame: Map; // quick lookup - private pendingUser: User | null; + private games: Set; // Set of all currently active game sessions + private socketToGame: Map; // Bidirectional map for constant-time game resolution from WebSockets + private pendingUser: User | null; // Matchmaking queue holding the single waiting player constructor() { this.games = new Set(); @@ -18,16 +26,24 @@ export class GameManager { this.pendingUser = null; } + /** + * Register a newly opened socket connection and bind its message listeners + */ addUser(socket: WebSocket) { this.addHandler(socket); } + /** + * Bind event listeners for real-time WebSocket protocol events. + * Incoming messages are decoded from JSON and matched against pre-defined route keys. + */ private addHandler(socket: WebSocket) { socket.on("message", (data) => { let message; try { message = JSON.parse(data.toString()); } catch { + // Prevent crashes on malformed payload inputs return; } @@ -51,14 +67,19 @@ export class GameManager { }); } + /** + * Handles user matchmaking request (INIT_GAME). + * FIFO matchmaking implementation: if a pending user exists, pair them immediately and launch a game. + * Otherwise, push the requester into the pending slot. + */ private handleInitGame(socket: WebSocket, name: string) { const newUser: User = { socket, name }; - // If already in a game, ignore + // Prevent double-matching if a player is already engaged in an active game if (this.socketToGame.has(socket)) return; if (this.pendingUser) { - // Start new game + // Create and initialize a new Game state machine const game = new Game( this.pendingUser.socket, newUser.socket, @@ -66,17 +87,22 @@ export class GameManager { newUser.name ); + // Save game index pointers in memory this.games.add(game); this.socketToGame.set(this.pendingUser.socket, game); this.socketToGame.set(newUser.socket, game); - // Clear pending + // Clear the matchmaking queue this.pendingUser = null; } else { + // Put player in waiting queue this.pendingUser = newUser; } } + /** + * Direct a user's move attempt to their active game instance + */ private handleMove(socket: WebSocket, move: { from: string; to: string }) { const game = this.socketToGame.get(socket); if (game) { @@ -84,6 +110,9 @@ export class GameManager { } } + /** + * Route real-time in-game chat messages + */ private handleChat(socket: WebSocket, text: string) { const game = this.socketToGame.get(socket); if (game) { @@ -91,14 +120,18 @@ export class GameManager { } } + /** + * Clean up memory records, socket mappings, and notify active opponents + * when a player unexpectedly leaves or closes their socket session. + */ removeUser(leavingSocket: WebSocket) { - // If they were waiting to be matched + // If the leaving user was currently waiting in matchmaking queue if (this.pendingUser?.socket === leavingSocket) { this.pendingUser = null; return; } - // If they were in a game + // If they were actively playing, terminate the game and alert opponent const game = this.socketToGame.get(leavingSocket); if (game) { const opponentSocket = @@ -106,20 +139,24 @@ export class GameManager { this.safeSend(opponentSocket, { type: "opponent_left" }); - // Cleanup + // Free up references for Garbage Collector to clean up game object this.socketToGame.delete(leavingSocket); this.socketToGame.delete(opponentSocket); this.games.delete(game); } } + /** + * Send JSON-serialized packets over WebSockets with safety checks + * against closed connection errors. + */ private safeSend(ws: WebSocket, payload: any) { try { if ((ws as any).readyState === (WebSocket as any).OPEN) { ws.send(JSON.stringify(payload)); } } catch { - // ignore send errors + // Fail silently to prevent crashing from network drops mid-transit } } } diff --git a/backend1/src/app.ts b/backend1/src/app.ts index 4d64da5..81f5b65 100644 --- a/backend1/src/app.ts +++ b/backend1/src/app.ts @@ -1,24 +1,30 @@ import express from 'express'; import cors from 'cors'; import router from './routes/auth.route'; -import { errorHandler } from './middlewares/errorhandler'; // ✅ check filename case too! +import { errorHandler } from './middlewares/errorhandler'; const app = express(); -// ✅ Use JSON parser first +// ─── MIDDLEWARE SETUP ───────────────────────────────────────── +// Parse incoming requests with JSON payloads first, making req.body available. app.use(express.json()); + +// Configure Cross-Origin Resource Sharing (CORS) +// In production, configure CORS_ORIGIN to restrict access to trusted clients. app.use(cors({ origin: process.env.CORS_ORIGIN || 'http://localhost:5173', - credentials: true, + credentials: true, // Allow cookies and Auth headers across domains })); -// ✅ Then your routes +// ─── API ROUTES ─────────────────────────────────────────────── app.use('/api/auth', router); -// ✅ Then your fallback route (optional) +// Static health check endpoint to verify HTTP layer availability app.get('/', (req, res) => res.send('Hello from Express + TypeScript')); -// ✅ Finally the error handler — always last! +// ─── ERROR HANDLING ─────────────────────────────────────────── +// Centralized error handler MUST be registered last in the Express stack +// to catch all downstream sync/async exceptions. app.use(errorHandler); export default app; diff --git a/backend1/src/index.ts b/backend1/src/index.ts index 7a8ea56..5ea5716 100644 --- a/backend1/src/index.ts +++ b/backend1/src/index.ts @@ -8,15 +8,25 @@ import { GameManager } from './GameManager'; const PORT = process.env.PORT || 3000; -// Create a single HTTP server for both Express and WebSocket +/** + * Express app & WebSockets are bound to a single HTTP Server instance. + * This simplifies deployment on Cloud providers (like Render or Fly.io) + * by bypassing multi-port routing restrictions. + */ const server = createServer(app); -// Attach WebSocket to the same server (upgrades on ws:// connections) +/** + * Instantiate WebSocketServer on top of our existing HTTP server. + * The 'ws' library automatically listens for HTTP 'Upgrade' requests (WSS handshake). + */ const wss = new WebSocketServer({ server }); const gameManager = new GameManager(); wss.on('connection', (ws) => { + // Delegate socket management and routing to the global GameManager gameManager.addUser(ws); + + // Clean up references when client session disconnects ws.on('close', () => gameManager.removeUser(ws)); }); diff --git a/frontend/src/hooks/useSockets.ts b/frontend/src/hooks/useSockets.ts index 143fd17..a24cbb7 100644 --- a/frontend/src/hooks/useSockets.ts +++ b/frontend/src/hooks/useSockets.ts @@ -1,56 +1,83 @@ import { useEffect, useRef, useState } from "react"; +// Read WebSocket connection base URL from environment configuration or default to localhost:3000 const WS_URL = import.meta.env.VITE_WS_URL || "ws://localhost:3000"; -/** Max reconnection delay in ms */ +/** Max reconnection delay in ms (capped at 10 seconds to keep connection attempts responsive) */ const MAX_DELAY = 10_000; /** - * WebSocket hook with automatic reconnection. - * Returns the socket (null while connecting) and connection status. + * A custom React hook that establishes and maintains a resilient real-time WebSocket connection. + * Features automated lifecycle management: clean-up on unmount, active retry prevention, + * and automatic exponential backoff reconnection when network state degrades. + * + * @returns {WebSocket | null} The active socket connection or null if connecting/offline. */ export const useSocket = () => { const [socket, setSocket] = useState(null); + + // Track reconnection retry count to compute exponential backoff times const retriesRef = useRef(0); + + // Hold a reference to the active reconnect timer to cancel it if the component unmounts const timerRef = useRef(null); + + // Track whether the hook has unmounted to prevent updating state or launching timers asynchronously const unmountedRef = useRef(false); useEffect(() => { unmountedRef.current = false; + /** + * Recursively creates a new WebSocket connection and hooks up connectivity listeners. + */ function connect() { if (unmountedRef.current) return; const ws = new WebSocket(WS_URL); ws.onopen = () => { - if (unmountedRef.current) { ws.close(); return; } - retriesRef.current = 0; // reset backoff on success + // Guard against race condition if component unmounts during active socket handshake + if (unmountedRef.current) { + ws.close(); + return; + } + + // Reset retry index to immediately attempt fast reconnection on next connection drop + retriesRef.current = 0; setSocket(ws); }; ws.onclose = () => { + // Prevent launching reconnection sequences if component is unmounted if (unmountedRef.current) return; + + // Clear active socket reference from state to alert UI of offline status setSocket(null); - // Exponential backoff: 1s, 2s, 4s, 8s, max 10s + // Exponential backoff strategy: 1s, 2s, 4s, 8s, up to MAX_DELAY (10s) + // Helps avoid DDOSing our own server when under high load or network outage const delay = Math.min(1000 * 2 ** retriesRef.current, MAX_DELAY); retriesRef.current++; + timerRef.current = window.setTimeout(connect, delay); }; ws.onerror = () => { - // onerror is always followed by onclose, so reconnect happens there + // According to WebSocket specification, onerror is immediately followed by onclose. + // We close explicitly here to force-trigger the onclose event handler. ws.close(); }; } connect(); + // Cleanup hook on unmount return () => { unmountedRef.current = true; if (timerRef.current) clearTimeout(timerRef.current); - // Close any open socket + + // Close active socket session to free resources and notify the server immediately setSocket((prev) => { if (prev && prev.readyState === WebSocket.OPEN) prev.close(); return null; From 9131256b9ee22a773c4d4b552cc7e15b32ff2aa7 Mon Sep 17 00:00:00 2001 From: Harsh16gupta Date: Wed, 20 May 2026 04:21:31 +0530 Subject: [PATCH 3/5] feat: implement active background clock timeout checking to resolve stall exploits --- backend1/dist/Game.js | 135 ++++++++++++++++++++++++++++++----- backend1/dist/GameManager.js | 47 ++++++++++-- backend1/dist/app.js | 17 +++-- backend1/dist/index.js | 13 +++- backend1/src/Game.ts | 76 ++++++++++++++++++++ 5 files changed, 254 insertions(+), 34 deletions(-) diff --git a/backend1/dist/Game.js b/backend1/dist/Game.js index 3484f10..610ea2a 100644 --- a/backend1/dist/Game.js +++ b/backend1/dist/Game.js @@ -4,10 +4,16 @@ exports.Game = void 0; const ws_1 = require("ws"); const chess_js_1 = require("chess.js"); const messages_1 = require("./messages"); +/** + * The Game class controls an active match between two players. + * It manages the chess board state machine (via chess.js), handles move validation, + * synchronizes player timers, and handles live in-game chat messaging. + */ class Game { - constructor(player1, player2, name1, name2, initialTimeMs = 5 * 60 * 1000 // default 5 minutes + constructor(player1, player2, name1, name2, initialTimeMs = 5 * 60 * 1000 // Defaults to standard Blitz: 5 minutes per player ) { - this.ended = false; + this.ended = false; // Flag to prevent duplicate game-over triggers or moves after game ends + this.timer = null; // Active background timeout for resolving flag falls (timeouts) this.player1 = player1; this.player2 = player2; this.name1 = name1; @@ -15,7 +21,8 @@ class Game { this.board = new chess_js_1.Chess(); this.lastMoveTime = Date.now(); this.timeLeft = { white: initialTimeMs, black: initialTimeMs }; - // init messages (each player needs their own payload) + // Broadcast the INIT_GAME handshake payloads independently to both clients. + // This establishes their piece colors and sets their opponents. this.safeSend(this.player1, { type: messages_1.INIT_GAME, payload: { @@ -34,24 +41,39 @@ class Game { timeLeft: this.timeLeft.black, }, }); + // Start active background tracking for White's first turn + this.startActiveTimer(); } // --- Public API --- + /** + * Commits a chess move if validation checks succeed. + * Ensures the socket matches the player whose turn it is, validates moves for legality, + * performs clock time deduction, updates the board representation, and triggers match end + * if victory or draw conditions are met. + */ makeMove(socket, move) { + // If the game has already concluded, ignore any subsequent incoming moves if (this.ended) return; - // verify who's making the move matches current player (optional but recommended) + // Clear active timeout checker as a move is currently being processed + this.clearActiveTimer(); const holderOfWhite = this.player1; const holderOfBlack = this.player2; const turnBeforeMove = this.board.turn() === "w" ? "white" : "black"; - // verify socket belongs to the player whose turn it is + // 1. Strict Turn Enforcement + // Verify that the socket proposing the move belongs to the active turn player. const expectedSocket = turnBeforeMove === "white" ? holderOfWhite : holderOfBlack; if (socket !== expectedSocket) { this.safeSend(socket, { type: messages_1.MOVE, payload: { ok: false, reason: "not_your_turn" }, }); + // Resume the active timer for the current turn player + this.startActiveTimer(); return; } + // 2. Legality Verification + // Retrieve list of all mathematically legal chess moves in the current position. const legalMoves = this.board.moves({ verbose: true }); const isLegal = legalMoves.some((m) => m.from === move.from && m.to === move.to); if (!isLegal) { @@ -59,15 +81,19 @@ class Game { type: messages_1.MOVE, payload: { ok: false, reason: "illegal_move", move }, }); + // Resume the active timer for the current turn player + this.startActiveTimer(); return; } + // 3. Passive Chess Clock Math + // Note: The backend tracks player clocks passively to minimize active CPU execution. + // When a move is completed, the duration since the last move is calculated and deducted. const now = Date.now(); const elapsed = now - this.lastMoveTime; - // Subtract elapsed time from the player who just moved (turnBeforeMove). - // Example: if turnBeforeMove === 'white', white is about to move and will be the one using elapsed time. this.timeLeft[turnBeforeMove] -= elapsed; this.lastMoveTime = now; - // If the player ran out of time BEFORE making this move, treat as timeout (they lost). + // Verify whether the moving player's clock fell below 0 BEFORE committing this move. + // If a timeout occurred, they forfeit the match immediately. if (this.timeLeft[turnBeforeMove] <= 0) { const winner = turnBeforeMove === "white" ? "black" : "white"; const winnerName = winner === "white" ? this.name1 : this.name2; @@ -79,24 +105,26 @@ class Game { }); return; } - // Make the move on the board + // 4. State Update + // Commit the legal move to the chess.js state engine. this.board.move(move); - // Prepare and broadcast move message + // Prepare state update payload and broadcast to both players. const moveMessage = { type: messages_1.MOVE, payload: { ok: true, move, - board: this.board.fen(), + board: this.board.fen(), // Send board representation in FEN notation turn: this.board.turn() === "w" ? "white" : "black", timeLeft: this.timeLeft, players: { white: this.name1, black: this.name2 }, }, }; this.sendToBoth(moveMessage); - // After move, check chess-end conditions + // 5. Game Termination Audits + // A. Checkmate Resolution if (this.board.isCheckmate()) { - // The side to move after the move lost (the side that was put in checkmate) + // The side to move AFTER the current move is checkmated and loses. const loser = this.board.turn() === "w" ? "white" : "black"; const winner = loser === "white" ? "black" : "white"; const winnerName = winner === "white" ? this.name1 : this.name2; @@ -107,7 +135,8 @@ class Game { }); return; } - // Draw conditions: stalemate, insufficient material, threefold repetition, or generic draw + // B. Draw Resolution + // Covers: Stalemate, Insufficient Material (e.g. King vs King), Threefold Repetition, 50-move rule if (this.board.isStalemate() || this.board.isInsufficientMaterial() || this.board.isThreefoldRepetition() || @@ -119,13 +148,17 @@ class Game { }); return; } - // Note: clocks continue from lastMoveTime; next player's clock will be reduced on their next move or if you implement a periodic checker. + // Move committed successfully. Begin active clock tracking for the next turn player. + this.startActiveTimer(); } + /** + * Broadcasts sanitized and length-limited real-time chat messages to both clients. + */ sendChatMessage(senderSocket, text) { if (this.ended) return; const senderName = senderSocket === this.player1 ? this.name1 : this.name2; - // Basic sanitization: trim and limit length + // Basic sanitization: trim leading/trailing whitespace and truncate at 1000 characters const trimmed = typeof text === "string" ? text.trim().slice(0, 1000) : ""; const message = { type: messages_1.CHAT_MESSAGE, @@ -134,11 +167,17 @@ class Game { this.sendToBoth(message); } // --- Helpers --- + /** + * Ends the match session, locks the ended state to prevent double execution, + * and broadcasts the final result metrics to both players. + */ endGame(payload) { var _a; if (this.ended) return; this.ended = true; + // Halt active timer loops to prevent dangling background intervals/timeouts + this.clearActiveTimer(); const gameOverMessage = { type: messages_1.GAME_OVER, payload: { @@ -153,20 +192,78 @@ class Game { }; this.sendToBoth(gameOverMessage); } + /** + * Helper to write payloads to both players' WebSocket streams. + */ sendToBoth(payload) { this.safeSend(this.player1, payload); this.safeSend(this.player2, payload); } + // --- Active Clock Schedulers --- + /** + * Computes remaining turn time and schedules a single precise timeout for clock expiry. + * This provides exact active timeout resolution without expensive periodic high-frequency polling. + */ + startActiveTimer() { + this.clearActiveTimer(); + if (this.ended) + return; + const turn = this.board.turn() === "w" ? "white" : "black"; + const timeLeft = this.timeLeft[turn]; + // Schedule timeout execution at the precise millisecond the player's clock drops to zero. + // Plus a micro 150ms buffer to compensate for TCP transmission delay/jitters. + this.timer = setTimeout(() => { + this.handleTimeout(); + }, timeLeft + 150); + } + /** + * Resets active timeout handles safely. + */ + clearActiveTimer() { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + } + /** + * Executed when the allocated time limit runs out. Recalculates elapsed times + * and triggers the flag fall (loss by timeout) if the clock truly fell below zero. + */ + handleTimeout() { + if (this.ended) + return; + const turn = this.board.turn() === "w" ? "white" : "black"; + const now = Date.now(); + const elapsed = now - this.lastMoveTime; + this.timeLeft[turn] = Math.max(0, this.timeLeft[turn] - elapsed); + this.lastMoveTime = now; + if (this.timeLeft[turn] <= 0) { + const winner = turn === "white" ? "black" : "white"; + const winnerName = winner === "white" ? this.name1 : this.name2; + this.endGame({ + result: "timeout", + winner, + winnerName, + reason: `${turn}_flag_fall`, + }); + } + else { + // If latency / CPU drift left time on the clock, reschedule the remainder + this.startActiveTimer(); + } + } + /** + * Safe socket transmitter which swallows connection-drop faults silently + * to guarantee server thread stability. + */ safeSend(ws, payload) { try { - // WebSocket.OPEN === 1 in 'ws' if (ws.readyState === ws_1.WebSocket.OPEN) { ws.send(JSON.stringify(payload)); } } catch (err) { - // swallow send errors; optionally log - // console.warn("send failed", err); + // Swallowed: network transport issues do not deserve runtime crash overhead } } } diff --git a/backend1/dist/GameManager.js b/backend1/dist/GameManager.js index 4615a2a..f40dbb0 100644 --- a/backend1/dist/GameManager.js +++ b/backend1/dist/GameManager.js @@ -4,15 +4,26 @@ exports.GameManager = void 0; const ws_1 = require("ws"); const messages_1 = require("./messages"); const Game_1 = require("./Game"); +/** + * GameManager handles matchmaking queues, maps active WebSocket connections to active game sessions, + * and handles multiplexing incoming real-time socket actions (moves, chats, matchmaking). + */ class GameManager { constructor() { this.games = new Set(); this.socketToGame = new Map(); this.pendingUser = null; } + /** + * Register a newly opened socket connection and bind its message listeners + */ addUser(socket) { this.addHandler(socket); } + /** + * Bind event listeners for real-time WebSocket protocol events. + * Incoming messages are decoded from JSON and matched against pre-defined route keys. + */ addHandler(socket) { socket.on("message", (data) => { let message; @@ -20,6 +31,7 @@ class GameManager { message = JSON.parse(data.toString()); } catch (_a) { + // Prevent crashes on malformed payload inputs return; } switch (message.type) { @@ -38,54 +50,75 @@ class GameManager { this.removeUser(socket); }); } + /** + * Handles user matchmaking request (INIT_GAME). + * FIFO matchmaking implementation: if a pending user exists, pair them immediately and launch a game. + * Otherwise, push the requester into the pending slot. + */ handleInitGame(socket, name) { const newUser = { socket, name }; - // If already in a game, ignore + // Prevent double-matching if a player is already engaged in an active game if (this.socketToGame.has(socket)) return; if (this.pendingUser) { - // Start new game + // Create and initialize a new Game state machine const game = new Game_1.Game(this.pendingUser.socket, newUser.socket, this.pendingUser.name, newUser.name); + // Save game index pointers in memory this.games.add(game); this.socketToGame.set(this.pendingUser.socket, game); this.socketToGame.set(newUser.socket, game); - // Clear pending + // Clear the matchmaking queue this.pendingUser = null; } else { + // Put player in waiting queue this.pendingUser = newUser; } } + /** + * Direct a user's move attempt to their active game instance + */ handleMove(socket, move) { const game = this.socketToGame.get(socket); if (game) { game.makeMove(socket, move); } } + /** + * Route real-time in-game chat messages + */ handleChat(socket, text) { const game = this.socketToGame.get(socket); if (game) { game.sendChatMessage(socket, text); } } + /** + * Clean up memory records, socket mappings, and notify active opponents + * when a player unexpectedly leaves or closes their socket session. + */ removeUser(leavingSocket) { var _a; - // If they were waiting to be matched + // If the leaving user was currently waiting in matchmaking queue if (((_a = this.pendingUser) === null || _a === void 0 ? void 0 : _a.socket) === leavingSocket) { this.pendingUser = null; return; } - // If they were in a game + // If they were actively playing, terminate the game and alert opponent const game = this.socketToGame.get(leavingSocket); if (game) { const opponentSocket = game.player1 === leavingSocket ? game.player2 : game.player1; this.safeSend(opponentSocket, { type: "opponent_left" }); - // Cleanup + // Free up references for Garbage Collector to clean up game object this.socketToGame.delete(leavingSocket); this.socketToGame.delete(opponentSocket); this.games.delete(game); } } + /** + * Send JSON-serialized packets over WebSockets with safety checks + * against closed connection errors. + */ safeSend(ws, payload) { try { if (ws.readyState === ws_1.WebSocket.OPEN) { @@ -93,7 +126,7 @@ class GameManager { } } catch (_a) { - // ignore send errors + // Fail silently to prevent crashing from network drops mid-transit } } } diff --git a/backend1/dist/app.js b/backend1/dist/app.js index 9e0c2b1..aa79948 100644 --- a/backend1/dist/app.js +++ b/backend1/dist/app.js @@ -6,18 +6,23 @@ Object.defineProperty(exports, "__esModule", { value: true }); const express_1 = __importDefault(require("express")); const cors_1 = __importDefault(require("cors")); const auth_route_1 = __importDefault(require("./routes/auth.route")); -const errorhandler_1 = require("./middlewares/errorhandler"); // ✅ check filename case too! +const errorhandler_1 = require("./middlewares/errorhandler"); const app = (0, express_1.default)(); -// ✅ Use JSON parser first +// ─── MIDDLEWARE SETUP ───────────────────────────────────────── +// Parse incoming requests with JSON payloads first, making req.body available. app.use(express_1.default.json()); +// Configure Cross-Origin Resource Sharing (CORS) +// In production, configure CORS_ORIGIN to restrict access to trusted clients. app.use((0, cors_1.default)({ origin: process.env.CORS_ORIGIN || 'http://localhost:5173', - credentials: true, + credentials: true, // Allow cookies and Auth headers across domains })); -// ✅ Then your routes +// ─── API ROUTES ─────────────────────────────────────────────── app.use('/api/auth', auth_route_1.default); -// ✅ Then your fallback route (optional) +// Static health check endpoint to verify HTTP layer availability app.get('/', (req, res) => res.send('Hello from Express + TypeScript')); -// ✅ Finally the error handler — always last! +// ─── ERROR HANDLING ─────────────────────────────────────────── +// Centralized error handler MUST be registered last in the Express stack +// to catch all downstream sync/async exceptions. app.use(errorhandler_1.errorHandler); exports.default = app; diff --git a/backend1/dist/index.js b/backend1/dist/index.js index 3ec1b3f..6a4c550 100644 --- a/backend1/dist/index.js +++ b/backend1/dist/index.js @@ -10,13 +10,22 @@ const ws_1 = require("ws"); const app_1 = __importDefault(require("./app")); const GameManager_1 = require("./GameManager"); const PORT = process.env.PORT || 3000; -// Create a single HTTP server for both Express and WebSocket +/** + * Express app & WebSockets are bound to a single HTTP Server instance. + * This simplifies deployment on Cloud providers (like Render or Fly.io) + * by bypassing multi-port routing restrictions. + */ const server = (0, http_1.createServer)(app_1.default); -// Attach WebSocket to the same server (upgrades on ws:// connections) +/** + * Instantiate WebSocketServer on top of our existing HTTP server. + * The 'ws' library automatically listens for HTTP 'Upgrade' requests (WSS handshake). + */ const wss = new ws_1.WebSocketServer({ server }); const gameManager = new GameManager_1.GameManager(); wss.on('connection', (ws) => { + // Delegate socket management and routing to the global GameManager gameManager.addUser(ws); + // Clean up references when client session disconnects ws.on('close', () => gameManager.removeUser(ws)); }); server.listen(PORT, () => { diff --git a/backend1/src/Game.ts b/backend1/src/Game.ts index b8f953a..92b4ec3 100644 --- a/backend1/src/Game.ts +++ b/backend1/src/Game.ts @@ -18,6 +18,7 @@ export class Game { private lastMoveTime: number; // Unix timestamp of the last validated move, used for clock math public timeLeft: { white: number; black: number }; // In-game timers represented in milliseconds private ended: boolean = false; // Flag to prevent duplicate game-over triggers or moves after game ends + private timer: NodeJS.Timeout | null = null; // Active background timeout for resolving flag falls (timeouts) constructor( player1: WebSocket, @@ -55,6 +56,9 @@ export class Game { timeLeft: this.timeLeft.black, }, }); + + // Start active background tracking for White's first turn + this.startActiveTimer(); } // --- Public API --- @@ -69,6 +73,9 @@ export class Game { // If the game has already concluded, ignore any subsequent incoming moves if (this.ended) return; + // Clear active timeout checker as a move is currently being processed + this.clearActiveTimer(); + const holderOfWhite = this.player1; const holderOfBlack = this.player2; const turnBeforeMove: Color = this.board.turn() === "w" ? "white" : "black"; @@ -82,6 +89,8 @@ export class Game { type: MOVE, payload: { ok: false, reason: "not_your_turn" }, }); + // Resume the active timer for the current turn player + this.startActiveTimer(); return; } @@ -97,6 +106,8 @@ export class Game { type: MOVE, payload: { ok: false, reason: "illegal_move", move }, }); + // Resume the active timer for the current turn player + this.startActiveTimer(); return; } @@ -173,6 +184,9 @@ export class Game { }); return; } + + // Move committed successfully. Begin active clock tracking for the next turn player. + this.startActiveTimer(); } /** @@ -208,6 +222,9 @@ export class Game { if (this.ended) return; this.ended = true; + // Halt active timer loops to prevent dangling background intervals/timeouts + this.clearActiveTimer(); + const gameOverMessage = { type: GAME_OVER, payload: { @@ -232,6 +249,65 @@ export class Game { this.safeSend(this.player2, payload); } + // --- Active Clock Schedulers --- + + /** + * Computes remaining turn time and schedules a single precise timeout for clock expiry. + * This provides exact active timeout resolution without expensive periodic high-frequency polling. + */ + private startActiveTimer() { + this.clearActiveTimer(); + if (this.ended) return; + + const turn: Color = this.board.turn() === "w" ? "white" : "black"; + const timeLeft = this.timeLeft[turn]; + + // Schedule timeout execution at the precise millisecond the player's clock drops to zero. + // Plus a micro 150ms buffer to compensate for TCP transmission delay/jitters. + this.timer = setTimeout(() => { + this.handleTimeout(); + }, timeLeft + 150); + } + + /** + * Resets active timeout handles safely. + */ + private clearActiveTimer() { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + } + + /** + * Executed when the allocated time limit runs out. Recalculates elapsed times + * and triggers the flag fall (loss by timeout) if the clock truly fell below zero. + */ + private handleTimeout() { + if (this.ended) return; + + const turn: Color = this.board.turn() === "w" ? "white" : "black"; + const now = Date.now(); + const elapsed = now - this.lastMoveTime; + + this.timeLeft[turn] = Math.max(0, this.timeLeft[turn] - elapsed); + this.lastMoveTime = now; + + if (this.timeLeft[turn] <= 0) { + const winner: Color = turn === "white" ? "black" : "white"; + const winnerName = winner === "white" ? this.name1 : this.name2; + this.endGame({ + result: "timeout", + winner, + winnerName, + reason: `${turn}_flag_fall`, + }); + } else { + // If latency / CPU drift left time on the clock, reschedule the remainder + this.startActiveTimer(); + } + } + /** * Safe socket transmitter which swallows connection-drop faults silently * to guarantee server thread stability. From 0be064809b24168c9e2dc17dc1070cef8faf82d8 Mon Sep 17 00:00:00 2001 From: Harsh16gupta Date: Wed, 20 May 2026 04:23:24 +0530 Subject: [PATCH 4/5] feat: implement game history database persistence, FIDE Elo rating adjustments, and historical API endpoints --- backend1/dist/Game.js | 75 +++++++++++++++++++ backend1/dist/app.js | 2 + .../migration.sql | 21 ++++++ backend1/prisma/schema.prisma | 24 ++++-- backend1/src/Game.ts | 69 +++++++++++++++++ backend1/src/app.ts | 6 +- backend1/src/controllers/game.controller.ts | 62 +++++++++++++++ backend1/src/middlewares/auth.middleware.ts | 31 ++++++++ backend1/src/routes/game.route.ts | 10 +++ backend1/tsconfig.tsbuildinfo | 2 +- 10 files changed, 294 insertions(+), 8 deletions(-) create mode 100644 backend1/prisma/migrations/20260519225232_add_game_history/migration.sql create mode 100644 backend1/src/controllers/game.controller.ts create mode 100644 backend1/src/middlewares/auth.middleware.ts create mode 100644 backend1/src/routes/game.route.ts diff --git a/backend1/dist/Game.js b/backend1/dist/Game.js index 610ea2a..8726c40 100644 --- a/backend1/dist/Game.js +++ b/backend1/dist/Game.js @@ -1,9 +1,20 @@ "use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; Object.defineProperty(exports, "__esModule", { value: true }); exports.Game = void 0; const ws_1 = require("ws"); const chess_js_1 = require("chess.js"); const messages_1 = require("./messages"); +const client_1 = require("@prisma/client"); +const prisma = new client_1.PrismaClient(); /** * The Game class controls an active match between two players. * It manages the chess board state machine (via chess.js), handles move validation, @@ -178,6 +189,8 @@ class Game { this.ended = true; // Halt active timer loops to prevent dangling background intervals/timeouts this.clearActiveTimer(); + // Persist the match records asynchronously to PostgreSQL and update player Elo ratings + this.persistGameAndElo(payload.result, payload.winner); const gameOverMessage = { type: messages_1.GAME_OVER, payload: { @@ -266,5 +279,67 @@ class Game { // Swallowed: network transport issues do not deserve runtime crash overhead } } + /** + * Resolves registered users, calculates and saves updated Elo ratings, + * and persists game logs in PostgreSQL via Prisma. + */ + persistGameAndElo(result, winnerColor) { + return __awaiter(this, void 0, void 0, function* () { + var _a, _b; + try { + // Query profile records from PostgreSQL. + // In matchmaking, registered users pass their email as their initial name payload. + const whiteUser = yield prisma.user.findUnique({ where: { Email: this.name1 } }); + const blackUser = yield prisma.user.findUnique({ where: { Email: this.name2 } }); + const whiteRating = (_a = whiteUser === null || whiteUser === void 0 ? void 0 : whiteUser.Rating) !== null && _a !== void 0 ? _a : 1200; + const blackRating = (_b = blackUser === null || blackUser === void 0 ? void 0 : blackUser.Rating) !== null && _b !== void 0 ? _b : 1200; + let scoreWhite = 0.5; // Draw + if (winnerColor === "white") + scoreWhite = 1; + if (winnerColor === "black") + scoreWhite = 0; + // Compute standard FIDE Elo delta + const expectedWhite = 1 / (1 + Math.pow(10, (blackRating - whiteRating) / 400)); + const expectedBlack = 1 / (1 + Math.pow(10, (whiteRating - blackRating) / 400)); + const scoreBlack = 1 - scoreWhite; + const kFactor = 32; + const newWhiteRating = Math.round(whiteRating + kFactor * (scoreWhite - expectedWhite)); + const newBlackRating = Math.round(blackRating + kFactor * (scoreBlack - expectedBlack)); + let winnerId = null; + if (winnerColor === "white" && whiteUser) + winnerId = whiteUser.id; + if (winnerColor === "black" && blackUser) + winnerId = blackUser.id; + // Extract space-separated SAN moves array + const pgn = this.board.history().join(" "); + // 1. Persist the game history log + yield prisma.game.create({ + data: { + whitePlayerId: whiteUser ? whiteUser.id : null, + blackPlayerId: blackUser ? blackUser.id : null, + winnerId, + result, + pgn, + }, + }); + // 2. Persist updated Elo ratings for registered profiles + if (whiteUser) { + yield prisma.user.update({ + where: { id: whiteUser.id }, + data: { Rating: newWhiteRating }, + }); + } + if (blackUser) { + yield prisma.user.update({ + where: { id: blackUser.id }, + data: { Rating: newBlackRating }, + }); + } + } + catch (err) { + console.error("Failed to persist game history or process Elo updates:", err); + } + }); + } } exports.Game = Game; diff --git a/backend1/dist/app.js b/backend1/dist/app.js index aa79948..60690f1 100644 --- a/backend1/dist/app.js +++ b/backend1/dist/app.js @@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); const express_1 = __importDefault(require("express")); const cors_1 = __importDefault(require("cors")); const auth_route_1 = __importDefault(require("./routes/auth.route")); +const game_route_1 = __importDefault(require("./routes/game.route")); const errorhandler_1 = require("./middlewares/errorhandler"); const app = (0, express_1.default)(); // ─── MIDDLEWARE SETUP ───────────────────────────────────────── @@ -19,6 +20,7 @@ app.use((0, cors_1.default)({ })); // ─── API ROUTES ─────────────────────────────────────────────── app.use('/api/auth', auth_route_1.default); +app.use('/api/games', game_route_1.default); // Static health check endpoint to verify HTTP layer availability app.get('/', (req, res) => res.send('Hello from Express + TypeScript')); // ─── ERROR HANDLING ─────────────────────────────────────────── diff --git a/backend1/prisma/migrations/20260519225232_add_game_history/migration.sql b/backend1/prisma/migrations/20260519225232_add_game_history/migration.sql new file mode 100644 index 0000000..d9f84bb --- /dev/null +++ b/backend1/prisma/migrations/20260519225232_add_game_history/migration.sql @@ -0,0 +1,21 @@ +-- CreateTable +CREATE TABLE "Game" ( + "id" TEXT NOT NULL, + "whitePlayerId" INTEGER, + "blackPlayerId" INTEGER, + "winnerId" INTEGER, + "result" TEXT NOT NULL, + "pgn" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Game_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Game" ADD CONSTRAINT "Game_whitePlayerId_fkey" FOREIGN KEY ("whitePlayerId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Game" ADD CONSTRAINT "Game_blackPlayerId_fkey" FOREIGN KEY ("blackPlayerId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Game" ADD CONSTRAINT "Game_winnerId_fkey" FOREIGN KEY ("winnerId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/backend1/prisma/schema.prisma b/backend1/prisma/schema.prisma index 108a531..af302be 100644 --- a/backend1/prisma/schema.prisma +++ b/backend1/prisma/schema.prisma @@ -2,18 +2,32 @@ generator client { provider = "prisma-client-js" } - - datasource db { provider = "postgresql" url = env("DATABASE_URL") } -model User{ +model User { id Int @id @default(autoincrement()) - Email String @unique + Email String @unique Password String - Rating Int? @default(1200) + Rating Int? @default(1200) + // Relations to Game histories + whiteGames Game[] @relation("WhiteGames") + blackGames Game[] @relation("BlackGames") + wonGames Game[] @relation("WonGames") +} +model Game { + id String @id @default(uuid()) + whitePlayerId Int? // Nullable for Guest players + blackPlayerId Int? // Nullable for Guest players + whitePlayer User? @relation("WhiteGames", fields: [whitePlayerId], references: [id]) + blackPlayer User? @relation("BlackGames", fields: [blackPlayerId], references: [id]) + winnerId Int? // Nullable if the match ends in a draw + winner User? @relation("WonGames", fields: [winnerId], references: [id]) + result String // "checkmate" | "draw" | "timeout" + pgn String // Space-separated list of moves (e.g. "e4 e5 Nf3 Nc6") + createdAt DateTime @default(now()) } \ No newline at end of file diff --git a/backend1/src/Game.ts b/backend1/src/Game.ts index 92b4ec3..1151c28 100644 --- a/backend1/src/Game.ts +++ b/backend1/src/Game.ts @@ -1,7 +1,9 @@ import { WebSocket } from "ws"; import { Chess } from "chess.js"; import { GAME_OVER, INIT_GAME, MOVE, CHAT_MESSAGE } from "./messages"; +import { PrismaClient } from "@prisma/client"; +const prisma = new PrismaClient(); type Color = "white" | "black"; /** @@ -225,6 +227,9 @@ export class Game { // Halt active timer loops to prevent dangling background intervals/timeouts this.clearActiveTimer(); + // Persist the match records asynchronously to PostgreSQL and update player Elo ratings + this.persistGameAndElo(payload.result, payload.winner); + const gameOverMessage = { type: GAME_OVER, payload: { @@ -321,4 +326,68 @@ export class Game { // Swallowed: network transport issues do not deserve runtime crash overhead } } + + /** + * Resolves registered users, calculates and saves updated Elo ratings, + * and persists game logs in PostgreSQL via Prisma. + */ + private async persistGameAndElo(result: "checkmate" | "draw" | "timeout", winnerColor: Color | null) { + try { + // Query profile records from PostgreSQL. + // In matchmaking, registered users pass their email as their initial name payload. + const whiteUser = await prisma.user.findUnique({ where: { Email: this.name1 } }); + const blackUser = await prisma.user.findUnique({ where: { Email: this.name2 } }); + + const whiteRating = whiteUser?.Rating ?? 1200; + const blackRating = blackUser?.Rating ?? 1200; + + let scoreWhite = 0.5; // Draw + if (winnerColor === "white") scoreWhite = 1; + if (winnerColor === "black") scoreWhite = 0; + + // Compute standard FIDE Elo delta + const expectedWhite = 1 / (1 + Math.pow(10, (blackRating - whiteRating) / 400)); + const expectedBlack = 1 / (1 + Math.pow(10, (whiteRating - blackRating) / 400)); + const scoreBlack = 1 - scoreWhite; + + const kFactor = 32; + const newWhiteRating = Math.round(whiteRating + kFactor * (scoreWhite - expectedWhite)); + const newBlackRating = Math.round(blackRating + kFactor * (scoreBlack - expectedBlack)); + + let winnerId: number | null = null; + if (winnerColor === "white" && whiteUser) winnerId = whiteUser.id; + if (winnerColor === "black" && blackUser) winnerId = blackUser.id; + + // Extract space-separated SAN moves array + const pgn = this.board.history().join(" "); + + // 1. Persist the game history log + await prisma.game.create({ + data: { + whitePlayerId: whiteUser ? whiteUser.id : null, + blackPlayerId: blackUser ? blackUser.id : null, + winnerId, + result, + pgn, + }, + }); + + // 2. Persist updated Elo ratings for registered profiles + if (whiteUser) { + await prisma.user.update({ + where: { id: whiteUser.id }, + data: { Rating: newWhiteRating }, + }); + } + + if (blackUser) { + await prisma.user.update({ + where: { id: blackUser.id }, + data: { Rating: newBlackRating }, + }); + } + } catch (err) { + console.error("Failed to persist game history or process Elo updates:", err); + } + } } diff --git a/backend1/src/app.ts b/backend1/src/app.ts index 81f5b65..ec753a4 100644 --- a/backend1/src/app.ts +++ b/backend1/src/app.ts @@ -1,6 +1,7 @@ import express from 'express'; import cors from 'cors'; -import router from './routes/auth.route'; +import authRouter from './routes/auth.route'; +import gameRouter from './routes/game.route'; import { errorHandler } from './middlewares/errorhandler'; const app = express(); @@ -17,7 +18,8 @@ app.use(cors({ })); // ─── API ROUTES ─────────────────────────────────────────────── -app.use('/api/auth', router); +app.use('/api/auth', authRouter); +app.use('/api/games', gameRouter); // Static health check endpoint to verify HTTP layer availability app.get('/', (req, res) => res.send('Hello from Express + TypeScript')); diff --git a/backend1/src/controllers/game.controller.ts b/backend1/src/controllers/game.controller.ts new file mode 100644 index 0000000..331ac38 --- /dev/null +++ b/backend1/src/controllers/game.controller.ts @@ -0,0 +1,62 @@ +import { Response } from "express"; +import { PrismaClient } from "@prisma/client"; +import { AuthenticatedRequest } from "../middlewares/auth.middleware"; + +const prisma = new PrismaClient(); + +/** + * Controller to retrieve complete historical matches for the authenticated profile. + * Fetches games played as White or Black, resolving opponent identities and Elo ratings. + */ +export const getGameHistory = async (req: AuthenticatedRequest, res: Response) => { + const userId = req.userId; + + if (!userId) { + return res.status(400).json({ error: "User ID is required." }); + } + + try { + const games = await prisma.game.findMany({ + where: { + OR: [ + { whitePlayerId: userId }, + { blackPlayerId: userId } + ] + }, + include: { + whitePlayer: { + select: { Email: true, Rating: true } + }, + blackPlayer: { + select: { Email: true, Rating: true } + }, + winner: { + select: { Email: true } + } + }, + orderBy: { + createdAt: "desc" + } + }); + + // Map database results to a sanitized REST payload + const sanitizedGames = games.map((game) => { + return { + id: game.id, + whitePlayer: game.whitePlayer?.Email || "Guest", + blackPlayer: game.blackPlayer?.Email || "Guest", + whiteRating: game.whitePlayer?.Rating ?? 1200, + blackRating: game.blackPlayer?.Rating ?? 1200, + winner: game.winner?.Email || (game.winnerId ? "Unknown" : "Draw"), + result: game.result, + pgn: game.pgn, + createdAt: game.createdAt, + }; + }); + + return res.status(200).json({ games: sanitizedGames }); + } catch (err) { + console.error("Error retrieving game history:", err); + return res.status(500).json({ error: "Internal server error." }); + } +}; diff --git a/backend1/src/middlewares/auth.middleware.ts b/backend1/src/middlewares/auth.middleware.ts new file mode 100644 index 0000000..a389e2b --- /dev/null +++ b/backend1/src/middlewares/auth.middleware.ts @@ -0,0 +1,31 @@ +import { Request, Response, NextFunction } from "express"; +import jwt from "jsonwebtoken"; + +export interface AuthenticatedRequest extends Request { + userId?: number; +} + +/** + * Standard JWT verification middleware to secure Express REST endpoints. + * Extracts the Bearer token from the 'Authorization' header, validates it, + * and attaches 'userId' to the request object. + */ +export const requireAuth = (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return res.status(401).json({ error: "Access denied. No token provided." }); + } + + const token = authHeader.split(" ")[1]; + + try { + const secret = process.env.JWT_SECRET || "mysecretpassword"; + const decoded = jwt.verify(token, secret) as { userId: number }; + + req.userId = decoded.userId; + next(); + } catch (err) { + return res.status(401).json({ error: "Invalid or expired token." }); + } +}; diff --git a/backend1/src/routes/game.route.ts b/backend1/src/routes/game.route.ts new file mode 100644 index 0000000..504488d --- /dev/null +++ b/backend1/src/routes/game.route.ts @@ -0,0 +1,10 @@ +import { Router } from "express"; +import { getGameHistory } from "../controllers/game.controller"; +import { requireAuth } from "../middlewares/auth.middleware"; + +const router = Router(); + +// Retrieve match history for authenticated players +router.get("/history", requireAuth, getGameHistory); + +export default router; diff --git a/backend1/tsconfig.tsbuildinfo b/backend1/tsconfig.tsbuildinfo index 174c9ba..07ab89a 100644 --- a/backend1/tsconfig.tsbuildinfo +++ b/backend1/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/Game.ts","./src/GameManager.ts","./src/app.ts","./src/index.ts","./src/messages.ts","./src/controllers/auth.controller.ts","./src/middlewares/errorhandler.ts","./src/routes/auth.route.ts","./src/schemas/user.schema.ts","./src/utils/hash.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/Game.ts","./src/GameManager.ts","./src/app.ts","./src/index.ts","./src/messages.ts","./src/controllers/auth.controller.ts","./src/controllers/game.controller.ts","./src/middlewares/auth.middleware.ts","./src/middlewares/errorhandler.ts","./src/routes/auth.route.ts","./src/routes/game.route.ts","./src/schemas/user.schema.ts","./src/utils/hash.ts"],"version":"5.9.3"} \ No newline at end of file From fb5027a51d2d9d6872bd76fbd64501a87983e223 Mon Sep 17 00:00:00 2001 From: Harsh16gupta Date: Wed, 20 May 2026 04:24:43 +0530 Subject: [PATCH 5/5] feat: design interactive match log history and Elo progress component in Home dashboard --- frontend/src/screens/Game.tsx | 3 - frontend/src/screens/Home.tsx | 241 +++++++++++++++++++++++++++------- 2 files changed, 192 insertions(+), 52 deletions(-) diff --git a/frontend/src/screens/Game.tsx b/frontend/src/screens/Game.tsx index 76287e2..5afb43f 100644 --- a/frontend/src/screens/Game.tsx +++ b/frontend/src/screens/Game.tsx @@ -36,7 +36,6 @@ export default function Game() { // Player & game state const [myName, setMyName] = useState(null); - const [guestModalOpen, setGuestModalOpen] = useState(false); const [tempName, setTempName] = useState(""); const [myColor, setMyColor] = useState<"white" | "black" | null>(null); const [players, setPlayers] = useState<{ white: string; black: string }>({ @@ -248,7 +247,6 @@ export default function Game() { const startMatch = (nameOverride?: string) => { const name = user?.email || nameOverride || myName; if (!name) { - setGuestModalOpen(true); return; } @@ -358,7 +356,6 @@ export default function Game() { const name = tempName.trim() || genGuestName(); localStorage.setItem("guestName", name); setMyName(name); - setGuestModalOpen(false); // Immediately start the match with provided name startMatch(name); }; diff --git a/frontend/src/screens/Home.tsx b/frontend/src/screens/Home.tsx index b56b105..f2f84ab 100644 --- a/frontend/src/screens/Home.tsx +++ b/frontend/src/screens/Home.tsx @@ -1,126 +1,269 @@ +import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { LoginSidebar } from "../components/LoginSidebar"; -import { Button } from "../components/Button"; import { useAuth } from "../context/AuthContext"; +import axios from "axios"; + +interface GameRecord { + id: string; + whitePlayer: string; + blackPlayer: string; + whiteRating: number; + blackRating: number; + winner: string; + result: string; + pgn: string; + createdAt: string; +} + +const API_URL = import.meta.env.VITE_API_URL || "http://localhost:3000"; export default function Home() { const navigate = useNavigate(); const { user } = useAuth(); + const [games, setGames] = useState([]); + const [loading, setLoading] = useState(true); + + // Hydrate user rating from localStorage if it exists + const localRating = localStorage.getItem("rating") || "1200"; + + useEffect(() => { + async function fetchHistory() { + const token = localStorage.getItem("token"); + if (!token) { + setLoading(false); + return; + } + + try { + const res = await axios.get(`${API_URL}/api/games/history`, { + headers: { Authorization: `Bearer ${token}` } + }); + setGames(res.data.games || []); + } catch (err) { + console.error("Failed to load match history:", err); + } finally { + setLoading(false); + } + } + + fetchHistory(); + }, []); return ( -
- {/* Sidebar */} +
+ {/* Sidebar Navigation */} - {/* Main Content Area */} -
- {/* Header Section */} -
+ {/* Main Dashboard Panel */} +
+ + {/* User Card Profile Header */} +
{user?.picture ? ( Profile ) : ( -
+
{(user?.name || user?.email || "G")[0].toUpperCase()}
)}
-
{user?.name || "Guest"}
-
{user?.email}
+

{user?.name || "Chess Competitor"}

+

{user?.email}

+
+
+ +
+ Blitz Rating +
+ {localRating} + ELO
- {/* Quick Play Section */} + {/* Quick Play Selection */}
-

Quick Play

+

+ Quick Play Lobby +

- {/* Feature Cards */} + {/* Game History List Panel */} +
+

+ 📜 Recent Matches +

+ + {loading ? ( +
+
+ Loading history... +
+ ) : games.length === 0 ? ( +
+ ♟️ +

No recorded battles yet. Launch matchmaking to write history!

+
+ ) : ( +
+ + + + + + + + + + + + {games.map((game) => { + const isWhite = user?.email && game.whitePlayer.toLowerCase() === user.email.toLowerCase(); + const isWinner = user?.email && game.winner.toLowerCase() === user.email.toLowerCase(); + const isDraw = game.winner === "Draw"; + + let outcomeBadge = ( + + DRAW + + ); + if (!isDraw) { + outcomeBadge = isWinner ? ( + + VICTORY + + ) : ( + + DEFEAT + + ); + } + + return ( + + + + + + + + ); + })} + +
WhiteBlackOutcomeDetailsDate
+ + {game.whitePlayer.split("@")[0]} + + Rating: {game.whiteRating} + + + {game.blackPlayer.split("@")[0]} + + Rating: {game.blackRating} + {outcomeBadge} + {game.result} + + {game.pgn || "No recorded moves"} + + + {new Date(game.createdAt).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "2-digit" + })} +
+
+ )} +
+ + {/* Feature Cards Grid */}
{/* Puzzles */}
navigate("/puzzle")} - className="group bg-stone-800 rounded-xl overflow-hidden border border-stone-700/30 cursor-pointer transition-all duration-200 hover:border-stone-600/50 hover:shadow-lg" + onClick={() => navigate("/game")} + className="group bg-stone-800/40 rounded-2xl overflow-hidden border border-stone-800/50 cursor-pointer transition-all duration-200 hover:border-stone-700/60 hover:shadow-lg" > -
- 🧩 +
+ 🧩
-

Puzzles

-

Sharpen your tactics with daily puzzles

+

Tactical Puzzles

+

Sharpen openings and chess problem tactics

{/* Lessons */}
navigate("/lesson")} - className="group bg-stone-800 rounded-xl overflow-hidden border border-stone-700/30 cursor-pointer transition-all duration-200 hover:border-stone-600/50 hover:shadow-lg" + onClick={() => navigate("/game")} + className="group bg-stone-800/40 rounded-2xl overflow-hidden border border-stone-800/50 cursor-pointer transition-all duration-200 hover:border-stone-700/60 hover:shadow-lg" > -
- 📘 +
+ 📘
-

Lessons

-

Learn openings, strategy, and endgames

+

Chess Academy

+

Learn critical endgames and strategic setups

{/* Game Review */}
navigate("/review")} - className="group bg-stone-800 rounded-xl overflow-hidden border border-stone-700/30 cursor-pointer transition-all duration-200 hover:border-stone-600/50 hover:shadow-lg" + onClick={() => navigate("/game")} + className="group bg-stone-800/40 rounded-2xl overflow-hidden border border-stone-800/50 cursor-pointer transition-all duration-200 hover:border-stone-700/60 hover:shadow-lg" > -
- 🔍 +
+ 🔍
-

Game Review

-

Analyze your games and find improvements

+

Game Review

+

Retrieve historical FENs and review key blunders

+
);