import crypto from "node:crypto"; import path from "node:path"; import { fileURLToPath } from "node:url"; import express from "express"; import { ChessEngine } from "./shared/chess-engine.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const PUBLIC_DIR = path.join(__dirname, "public"); const SHARED_DIR = path.join(__dirname, "shared"); const PORT = Number(process.env.PORT || 3015); const MAX_SPECTATORS = 20; const MAX_CHAT_MESSAGES = 100; const MAX_CHAT_LENGTH = 300; const parties = new Map(); const TIME_CONTROLS = { none: { key: "none", label: "Без часов", initial: 0, increment: 0, }, "1+0": { key: "1+0", label: "1 + 0 • Bullet", initial: 60, increment: 0, }, "3+0": { key: "3+0", label: "3 + 0 • Blitz", initial: 180, increment: 0, }, "3+2": { key: "3+2", label: "3 + 2 • Blitz", initial: 180, increment: 2, }, "5+0": { key: "5+0", label: "5 + 0 • Blitz", initial: 300, increment: 0, }, "5+3": { key: "5+3", label: "5 + 3 • Blitz", initial: 300, increment: 3, }, "10+0": { key: "10+0", label: "10 + 0 • Rapid", initial: 600, increment: 0, }, "15+10": { key: "15+10", label: "15 + 10 • Rapid", initial: 900, increment: 10, }, "30+0": { key: "30+0", label: "30 + 0 • Classical", initial: 1800, increment: 0, }, "30+20": { key: "30+20", label: "30 + 20 • Classical", initial: 1800, increment: 20, }, }; /* ========================================================= HELPERS ========================================================= */ function safeName(name) { const value = typeof name === "string" ? name.trim() : ""; if (!value) { return "Guest"; } return value .replace(/[<>]/g, "") .slice(0, 24); } function safeChatMessage(message) { if (typeof message !== "string") { return ""; } return message .trim() .replace(/\s+/g, " ") .replace(/[<>]/g, "") .slice(0, MAX_CHAT_LENGTH); } function getTimeControl(key) { return TIME_CONTROLS[key] || TIME_CONTROLS["10+0"]; } function generatePartyCode() { const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; let code; do { code = Array.from( { length: 6 }, () => alphabet[Math.floor(Math.random() * alphabet.length)] ).join(""); } while (parties.has(code)); return code; } function ensureParty(code) { const normalized = String(code || "") .trim() .toUpperCase(); const party = parties.get(normalized); if (!party) { throw new Error("Party not found."); } return party; } function getPlayerByRole(party, role) { for (const participant of party.participants.values()) { if (participant.role === role) { return participant; } } return null; } function countSpectators(party) { let count = 0; for (const participant of party.participants.values()) { if (participant.role === "spectator") { count++; } } return count; } function getOpponentRole(role) { return role === "white" ? "black" : "white"; } function roleToColor(role) { return role === "white" ? "w" : "b"; } function colorToRole(color) { return color === "w" ? "white" : "black"; } /* ========================================================= CLOCKS ========================================================= */ function createClocks(timeControl) { return { white: timeControl.initial, black: timeControl.initial, running: false, lastTick: Date.now(), }; } function updateClock(party) { const clocks = party.clocks; if (!clocks) { return; } if ( party.timeControl.initial <= 0 || party.gameOverReason || !clocks.running ) { return; } const now = Date.now(); const elapsed = (now - clocks.lastTick) / 1000; if (elapsed <= 0) { return; } const turnRole = colorToRole(party.engine.state.turn); clocks[turnRole] = Math.max( 0, clocks[turnRole] - elapsed ); clocks.lastTick = now; if (clocks[turnRole] <= 0) { const winnerRole = getOpponentRole(turnRole); const winner = roleToColor(winnerRole); setGameOver( party, "timeout", winner ); } } function switchClockAfterMove(party, movingRole) { if (party.timeControl.initial <= 0) { return; } const clocks = party.clocks; const now = Date.now(); if (clocks.running) { const elapsed = (now - clocks.lastTick) / 1000; clocks[movingRole] = Math.max( 0, clocks[movingRole] - elapsed ); } clocks[movingRole] += party.timeControl.increment; clocks.lastTick = now; if (!party.gameOverReason) { clocks.running = true; } } function startClockIfReady(party) { if ( party.timeControl.initial <= 0 || party.gameOverReason ) { return; } const white = getPlayerByRole(party, "white"); const black = getPlayerByRole(party, "black"); if (white && black) { party.clocks.running = true; party.clocks.lastTick = Date.now(); } } function getClockSnapshot(party) { updateClock(party); return { white: Math.max(0, party.clocks.white), black: Math.max(0, party.clocks.black), running: party.clocks.running, turn: party.engine.state.turn, }; } /* ========================================================= GAME STATE ========================================================= */ function setGameOver(party, reason, winner = null) { party.gameOverReason = reason; party.winner = winner; party.drawOffer = null; if (party.clocks) { updateClock(party); party.clocks.running = false; } } function resetGame(party) { party.engine = new ChessEngine(); party.drawOffer = null; party.gameOverReason = null; party.winner = null; party.rematch.white = false; party.rematch.black = false; party.clocks = createClocks( party.timeControl ); startClockIfReady(party); } /* ========================================================= PARTICIPANTS ========================================================= */ function getOrCreateParticipant( party, name, clientId = null ) { if ( clientId && party.participants.has(clientId) ) { const existing = party.participants.get(clientId); if (name) { existing.name = safeName(name); } return existing; } const participant = { id: clientId || crypto.randomUUID(), name: safeName(name), role: "spectator", connected: false, stream: null, }; const hasWhite = Boolean( getPlayerByRole(party, "white") ); const hasBlack = Boolean( getPlayerByRole(party, "black") ); if (!hasWhite) { participant.role = "white"; } else if (!hasBlack) { participant.role = "black"; } else { if ( countSpectators(party) >= MAX_SPECTATORS ) { throw new Error( `Party is full. Maximum ${MAX_SPECTATORS} spectators.` ); } participant.role = "spectator"; } party.participants.set( participant.id, participant ); startClockIfReady(party); return participant; } function ensureParticipant( party, clientId ) { const participant = party.participants.get(clientId); if (!participant) { throw new Error( "You are not part of this party." ); } return participant; } function ensurePlayer( party, clientId ) { const participant = ensureParticipant( party, clientId ); if ( participant.role !== "white" && participant.role !== "black" ) { throw new Error( "Spectators cannot perform this action." ); } return participant; } function ensurePlayerTurn( party, clientId ) { const participant = ensurePlayer( party, clientId ); updateClock(party); if (party.gameOverReason) { throw new Error( "The game is already over." ); } const expectedRole = party.engine.state.turn === "w" ? "white" : "black"; if ( participant.role !== expectedRole ) { throw new Error( "It is not your turn." ); } return participant; } /* ========================================================= SERIALIZATION ========================================================= */ function serializeParty( party, viewerId = null ) { updateClock(party); const participants = Array.from( party.participants.values() ); const white = participants.find( p => p.role === "white" ) || null; const black = participants.find( p => p.role === "black" ) || null; const spectators = participants .filter( p => p.role === "spectator" ) .map(p => ({ id: p.id, name: p.name, connected: p.connected, })); const you = viewerId ? party.participants.get(viewerId) || null : null; return { code: party.code, players: { white: white ? { id: white.id, name: white.name, connected: white.connected, } : null, black: black ? { id: black.id, name: black.name, connected: black.connected, } : null, }, spectators, limits: { players: 2, spectators: MAX_SPECTATORS, }, you: you ? { id: you.id, name: you.name, role: you.role, connected: you.connected, } : null, game: party.engine.getSnapshot(), timeControl: { key: party.timeControl.key, label: party.timeControl.label, initial: party.timeControl.initial, increment: party.timeControl.increment, }, clocks: getClockSnapshot(party), drawOffer: party.drawOffer ? { by: party.drawOffer.by, name: party.drawOffer.name, role: party.drawOffer.role, } : null, rematch: { white: Boolean( party.rematch.white ), black: Boolean( party.rematch.black ), }, gameOverReason: party.gameOverReason || null, winner: party.winner || null, chat: party.chat.slice( -MAX_CHAT_MESSAGES ), }; } /* ========================================================= SSE ========================================================= */ function writeSseEvent( res, eventName, payload ) { try { if (res.writableEnded) { return; } res.write( `event: ${eventName}\n` ); res.write( `data: ${JSON.stringify(payload)}\n\n` ); } catch { // Client disconnected. } } function broadcastParty(party) { for ( const participant of party.participants.values() ) { if (!participant.stream) { continue; } writeSseEvent( participant.stream, "party", serializeParty( party, participant.id ) ); } } function broadcastChat( party, message ) { for ( const participant of party.participants.values() ) { if (!participant.stream) { continue; } writeSseEvent( participant.stream, "chat", message ); } } /* ========================================================= EXPRESS ========================================================= */ const app = express(); app.disable("x-powered-by"); app.use( express.json({ limit: "1mb", }) ); app.use( express.static( PUBLIC_DIR ) ); app.use( "/shared", express.static( SHARED_DIR ) ); /* ========================================================= PARTY EVENTS ========================================================= */ app.get( "/api/party/events", (req, res) => { try { const party = ensureParty( req.query.partyCode ); const clientId = String( req.query.clientId || "" ); const participant = party.participants.get( clientId ); if (!participant) { return res .status(404) .json({ ok: false, error: "Participant not found.", }); } res.writeHead( 200, { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", "X-Accel-Buffering": "no", } ); participant.connected = true; participant.stream = res; writeSseEvent( res, "party", serializeParty( party, participant.id ) ); req.on( "close", () => { participant.connected = false; participant.stream = null; broadcastParty( party ); } ); broadcastParty(party); } catch (err) { if (!res.headersSent) { res.status(400).json({ ok: false, error: err.message, }); } } } ); /* ========================================================= CREATE ========================================================= */ app.post( "/api/party/create", (req, res) => { try { const code = generatePartyCode(); const timeControl = getTimeControl( req.body?.timeControl ); const party = { code, engine: new ChessEngine(), participants: new Map(), createdAt: Date.now(), timeControl, clocks: createClocks( timeControl ), drawOffer: null, rematch: { white: false, black: false, }, gameOverReason: null, winner: null, chat: [], }; const participant = getOrCreateParticipant( party, req.body?.name, req.body?.clientId || null ); parties.set( code, party ); res.json({ ok: true, clientId: participant.id, party: serializeParty( party, participant.id ), }); } catch (err) { res.status(400).json({ ok: false, error: err.message, }); } } ); /* ========================================================= JOIN ========================================================= */ app.post( "/api/party/join", (req, res) => { try { const party = ensureParty( req.body?.partyCode ); const participant = getOrCreateParticipant( party, req.body?.name, req.body?.clientId || null ); startClockIfReady( party ); broadcastParty( party ); res.json({ ok: true, clientId: participant.id, party: serializeParty( party, participant.id ), }); } catch (err) { res.status(400).json({ ok: false, error: err.message, }); } } ); /* ========================================================= MOVE ========================================================= */ app.post( "/api/party/move", (req, res) => { try { const party = ensureParty( req.body?.partyCode ); const participant = ensurePlayerTurn( party, req.body?.clientId ); const movingRole = participant.role; const result = party.engine.makeMove({ from: req.body?.from, to: req.body?.to, promotion: req.body?.promotion || null, }); if (!result.ok) { return res.status(400).json({ ok: false, error: result.error || "Illegal move.", party: serializeParty( party, req.body?.clientId ), }); } party.drawOffer = null; switchClockAfterMove( party, movingRole ); const status = party.engine.state.status; if ( status?.phase === "checkmate" ) { setGameOver( party, "checkmate", status.winner ); } else if ( status?.phase === "draw" ) { setGameOver( party, "draw", null ); } broadcastParty( party ); res.json({ ok: true, party: serializeParty( party, req.body?.clientId ), }); } catch (err) { res.status(400).json({ ok: false, error: err.message, }); } } ); /* ========================================================= RESIGN ========================================================= */ app.post( "/api/party/resign", (req, res) => { try { const party = ensureParty( req.body?.partyCode ); const participant = ensurePlayer( party, req.body?.clientId ); if (party.gameOverReason) { return res.status(400).json({ ok: false, error: "The game is already over.", party: serializeParty( party, req.body?.clientId ), }); } const winner = roleToColor( getOpponentRole( participant.role ) ); setGameOver( party, "resignation", winner ); broadcastParty( party ); res.json({ ok: true, party: serializeParty( party, req.body?.clientId ), }); } catch (err) { res.status(400).json({ ok: false, error: err.message, }); } } ); /* ========================================================= DRAW OFFER ========================================================= */ app.post( "/api/party/draw-offer", (req, res) => { try { const party = ensureParty( req.body?.partyCode ); const participant = ensurePlayer( party, req.body?.clientId ); if (party.gameOverReason) { return res.status(400).json({ ok: false, error: "The game is already over.", }); } if (party.drawOffer) { return res.status(400).json({ ok: false, error: "A draw offer is already pending.", }); } party.drawOffer = { by: participant.id, name: participant.name, role: participant.role, }; broadcastParty( party ); res.json({ ok: true, party: serializeParty( party, participant.id ), }); } catch (err) { res.status(400).json({ ok: false, error: err.message, }); } } ); /* ========================================================= DRAW RESPONSE ========================================================= */ app.post( "/api/party/draw-response", (req, res) => { try { const party = ensureParty( req.body?.partyCode ); const participant = ensurePlayer( party, req.body?.clientId ); if (!party.drawOffer) { return res.status(400).json({ ok: false, error: "There is no draw offer.", }); } if ( party.drawOffer.by === participant.id ) { return res.status(400).json({ ok: false, error: "You cannot respond to your own draw offer.", }); } if ( req.body?.accept === true ) { setGameOver( party, "agreement", null ); } else { party.drawOffer = null; } broadcastParty( party ); res.json({ ok: true, party: serializeParty( party, participant.id ), }); } catch (err) { res.status(400).json({ ok: false, error: err.message, }); } } ); /* ========================================================= REMATCH ========================================================= */ app.post( "/api/party/rematch", (req, res) => { try { const party = ensureParty( req.body?.partyCode ); const participant = ensurePlayer( party, req.body?.clientId ); if (!party.gameOverReason) { return res.status(400).json({ ok: false, error: "The game is still running.", }); } party.rematch[ participant.role ] = true; if ( party.rematch.white && party.rematch.black ) { resetGame(party); } broadcastParty( party ); res.json({ ok: true, party: serializeParty( party, participant.id ), }); } catch (err) { res.status(400).json({ ok: false, error: err.message, }); } } ); /* ========================================================= NEW GAME ========================================================= */ app.post( "/api/party/new-game", (req, res) => { try { const party = ensureParty( req.body?.partyCode ); ensurePlayer( party, req.body?.clientId ); resetGame(party); broadcastParty( party ); res.json({ ok: true, party: serializeParty( party, req.body?.clientId ), }); } catch (err) { res.status(400).json({ ok: false, error: err.message, }); } } ); /* ========================================================= CHAT ========================================================= */ app.post( "/api/party/chat", (req, res) => { try { const party = ensureParty( req.body?.partyCode ); const participant = ensureParticipant( party, req.body?.clientId ); const text = safeChatMessage( req.body?.message ); if (!text) { return res.status(400).json({ ok: false, error: "Message cannot be empty.", }); } const message = { id: crypto.randomUUID(), participantId: participant.id, name: participant.name, role: participant.role, text, time: Date.now(), }; party.chat.push( message ); if ( party.chat.length > MAX_CHAT_MESSAGES ) { party.chat = party.chat.slice( -MAX_CHAT_MESSAGES ); } broadcastChat( party, message ); res.json({ ok: true, message, }); } catch (err) { res.status(400).json({ ok: false, error: err.message, }); } } ); /* ========================================================= LEAVE ========================================================= */ app.post( "/api/party/leave", (req, res) => { try { const party = ensureParty( req.body?.partyCode ); const participant = party.participants.get( req.body?.clientId ); if (participant?.stream) { try { participant.stream.end(); } catch { // ignore } } party.participants.delete( req.body?.clientId ); if ( party.drawOffer?.by === req.body?.clientId ) { party.drawOffer = null; } if ( participant && ( participant.role === "white" || participant.role === "black" ) ) { party.clocks.running = false; } if ( party.participants.size === 0 ) { parties.delete( party.code ); } else { broadcastParty( party ); } res.json({ ok: true, }); } catch (err) { res.status(400).json({ ok: false, error: err.message, }); } } ); /* ========================================================= UNKNOWN API ROUTE ========================================================= */ app.use( "/api", (req, res) => { res.status(404).json({ ok: false, error: "API route not found.", path: req.path, }); } ); /* ========================================================= FRONTEND FALLBACK ========================================================= */ app.use( (req, res, next) => { if ( req.method === "GET" && req.accepts("html") ) { return res.sendFile( path.join( PUBLIC_DIR, "index.html" ) ); } next(); } ); /* ========================================================= CLOCK LOOP ========================================================= */ setInterval(() => { for ( const party of parties.values() ) { updateClock(party); broadcastParty(party); } }, 1000); /* ========================================================= SSE KEEP ALIVE ========================================================= */ setInterval(() => { for ( const party of parties.values() ) { for ( const participant of party.participants.values() ) { if (!participant.stream) { continue; } writeSseEvent( participant.stream, "ping", { now: Date.now(), } ); } } }, 15000); /* ========================================================= START ========================================================= */ app.listen( PORT, "0.0.0.0", () => { console.log( `Chess Party server running on port ${PORT}` ); console.log( `Open: http://localhost:${PORT}` ); } );