Files
website-collection/server.js
T

1279 lines
27 KiB
JavaScript
Raw Normal View History

2026-08-22 17:41:22 +00:00
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";
2026-08-22 17:49:23 +00:00
/* =========================================================
PATHS
========================================================= */
2026-08-22 17:41:22 +00:00
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);
2026-08-22 17:49:23 +00:00
/* =========================================================
SERVER
========================================================= */
2026-08-22 17:41:22 +00:00
2026-08-22 17:49:23 +00:00
const app = express();
app.disable("x-powered-by");
app.use(express.json({ limit: "32kb" }));
app.use(express.static(PUBLIC_DIR, {
extensions: ["html"],
maxAge: "1h",
}));
app.use("/shared", express.static(SHARED_DIR, {
maxAge: "1h",
}));
/* =========================================================
CONFIG
========================================================= */
const MAX_SPECTATORS = 20;
2026-08-22 17:41:22 +00:00
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,
},
};
2026-08-22 17:49:23 +00:00
/* =========================================================
STORAGE
========================================================= */
const parties = new Map();
2026-08-22 17:41:22 +00:00
/* =========================================================
HELPERS
========================================================= */
function safeName(name) {
const value = typeof name === "string"
? name.trim()
: "";
if (!value) {
return "Guest";
}
return value
.replace(/[<>]/g, "")
.slice(0, 24);
}
2026-08-22 17:49:23 +00:00
function normalizeCode(code) {
return String(code || "")
2026-08-22 17:41:22 +00:00
.trim()
2026-08-22 17:49:23 +00:00
.toUpperCase();
2026-08-22 17:41:22 +00:00
}
function getTimeControl(key) {
return TIME_CONTROLS[key] || TIME_CONTROLS["10+0"];
}
2026-08-22 17:49:23 +00:00
function createClientId() {
return crypto.randomUUID();
}
function createPartyCode() {
2026-08-22 17:41:22 +00:00
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let code;
do {
2026-08-22 17:49:23 +00:00
code = "";
for (let i = 0; i < 6; i++) {
code += alphabet[
crypto.randomInt(0, alphabet.length)
];
}
2026-08-22 17:41:22 +00:00
} while (parties.has(code));
return code;
}
2026-08-22 17:49:23 +00:00
function getParty(code) {
const party = parties.get(normalizeCode(code));
2026-08-22 17:41:22 +00:00
if (!party) {
throw new Error("Party not found.");
}
return party;
}
2026-08-22 17:49:23 +00:00
function getParticipant(party, clientId) {
if (!clientId) {
return null;
}
return party.participants.get(clientId) || null;
}
function getPlayer(party, role) {
2026-08-22 17:41:22 +00:00
for (const participant of party.participants.values()) {
if (participant.role === role) {
return participant;
}
}
return null;
}
2026-08-22 17:49:23 +00:00
function getOpponentRole(role) {
return role === "white"
? "black"
: "white";
}
function roleToColor(role) {
return role === "white"
? "w"
: "b";
}
function colorToRole(color) {
return color === "w"
? "white"
: "black";
}
2026-08-22 17:41:22 +00:00
function countSpectators(party) {
let count = 0;
for (const participant of party.participants.values()) {
if (participant.role === "spectator") {
count++;
}
}
return count;
}
/* =========================================================
CLOCKS
========================================================= */
function createClocks(timeControl) {
return {
white: timeControl.initial,
black: timeControl.initial,
2026-08-22 17:49:23 +00:00
2026-08-22 17:41:22 +00:00
running: false,
2026-08-22 17:49:23 +00:00
2026-08-22 17:41:22 +00:00
lastTick: Date.now(),
2026-08-22 17:49:23 +00:00
turn: "w",
2026-08-22 17:41:22 +00:00
};
}
function updateClock(party) {
const clocks = party.clocks;
if (!clocks) {
return;
}
2026-08-22 17:49:23 +00:00
if (party.timeControl.initial <= 0) {
return;
}
if (!clocks.running) {
return;
}
if (party.gameOverReason) {
2026-08-22 17:41:22 +00:00
return;
}
const now = Date.now();
2026-08-22 17:49:23 +00:00
const elapsed =
(now - clocks.lastTick) / 1000;
2026-08-22 17:41:22 +00:00
if (elapsed <= 0) {
return;
}
2026-08-22 17:49:23 +00:00
const role = colorToRole(
party.engine.state.turn
);
2026-08-22 17:41:22 +00:00
2026-08-22 17:49:23 +00:00
clocks[role] = Math.max(
2026-08-22 17:41:22 +00:00
0,
2026-08-22 17:49:23 +00:00
clocks[role] - elapsed
2026-08-22 17:41:22 +00:00
);
clocks.lastTick = now;
2026-08-22 17:49:23 +00:00
clocks.turn = party.engine.state.turn;
2026-08-22 17:41:22 +00:00
2026-08-22 17:49:23 +00:00
if (clocks[role] <= 0) {
const winnerColor = roleToColor(
getOpponentRole(role)
);
2026-08-22 17:41:22 +00:00
setGameOver(
party,
"timeout",
2026-08-22 17:49:23 +00:00
winnerColor
2026-08-22 17:41:22 +00:00
);
}
}
2026-08-22 17:49:23 +00:00
function startClockIfReady(party) {
if (party.timeControl.initial <= 0) {
return;
}
if (party.gameOverReason) {
return;
}
const white = getPlayer(
party,
"white"
);
const black = getPlayer(
party,
"black"
);
if (!white || !black) {
return;
}
if (!clocksCanRun(party)) {
return;
}
party.clocks.running = true;
party.clocks.lastTick = Date.now();
party.clocks.turn = party.engine.state.turn;
}
function clocksCanRun(party) {
return Boolean(
party.clocks &&
!party.gameOverReason &&
getPlayer(party, "white") &&
getPlayer(party, "black")
);
}
function applyMoveClock(party, movingRole) {
2026-08-22 17:41:22 +00:00
if (party.timeControl.initial <= 0) {
return;
}
const clocks = party.clocks;
2026-08-22 17:49:23 +00:00
updateClock(party);
2026-08-22 17:41:22 +00:00
2026-08-22 17:49:23 +00:00
if (party.gameOverReason) {
2026-08-22 17:41:22 +00:00
return;
}
2026-08-22 17:49:23 +00:00
clocks[movingRole] +=
party.timeControl.increment;
2026-08-22 17:41:22 +00:00
2026-08-22 17:49:23 +00:00
clocks.turn = party.engine.state.turn;
clocks.lastTick = Date.now();
clocks.running = true;
2026-08-22 17:41:22 +00:00
}
function getClockSnapshot(party) {
updateClock(party);
return {
2026-08-22 17:49:23 +00:00
white: Math.max(
0,
Number(party.clocks.white) || 0
),
black: Math.max(
0,
Number(party.clocks.black) || 0
),
running: Boolean(
party.clocks.running
),
2026-08-22 17:41:22 +00:00
turn: party.engine.state.turn,
};
}
/* =========================================================
GAME STATE
========================================================= */
2026-08-22 17:49:23 +00:00
function getEngineStatus(party) {
return (
party.engine.state.status ||
ChessEngine.evaluateStatus(
party.engine.state
)
);
}
function setGameOver(
party,
reason,
winner = null
) {
if (party.gameOverReason) {
return;
}
2026-08-22 17:41:22 +00:00
party.gameOverReason = reason;
party.winner = winner;
2026-08-22 17:49:23 +00:00
party.clocks.running = false;
broadcastParty(party);
2026-08-22 17:41:22 +00:00
}
2026-08-22 17:49:23 +00:00
function updateGameResult(party) {
2026-08-22 17:41:22 +00:00
if (party.gameOverReason) {
2026-08-22 17:49:23 +00:00
return;
2026-08-22 17:41:22 +00:00
}
2026-08-22 17:49:23 +00:00
const status = getEngineStatus(party);
2026-08-22 17:41:22 +00:00
2026-08-22 17:49:23 +00:00
if (!status) {
return;
2026-08-22 17:41:22 +00:00
}
2026-08-22 17:49:23 +00:00
if (status.phase === "checkmate") {
setGameOver(
party,
"checkmate",
party.engine.state.turn === "w"
? "b"
: "w"
);
return;
}
if (status.phase === "draw") {
setGameOver(
party,
"draw",
null
);
}
2026-08-22 17:41:22 +00:00
}
/* =========================================================
SERIALIZATION
========================================================= */
2026-08-22 17:49:23 +00:00
function serializeParticipant(participant) {
return {
id: participant.id,
name: participant.name,
role: participant.role,
connected: participant.connected,
};
}
function serializeParty(party, clientId = null) {
2026-08-22 17:41:22 +00:00
updateClock(party);
2026-08-22 17:49:23 +00:00
const players = {
white: null,
black: null,
};
2026-08-22 17:41:22 +00:00
2026-08-22 17:49:23 +00:00
const spectators = [];
2026-08-22 17:41:22 +00:00
2026-08-22 17:49:23 +00:00
for (const participant of party.participants.values()) {
if (participant.role === "white") {
players.white =
serializeParticipant(participant);
}
2026-08-22 17:41:22 +00:00
2026-08-22 17:49:23 +00:00
if (participant.role === "black") {
players.black =
serializeParticipant(participant);
}
2026-08-22 17:41:22 +00:00
2026-08-22 17:49:23 +00:00
if (participant.role === "spectator") {
spectators.push(
serializeParticipant(participant)
);
}
}
const you = getParticipant(
party,
clientId
);
2026-08-22 17:41:22 +00:00
return {
code: party.code,
2026-08-22 17:49:23 +00:00
game: party.engine.getSnapshot(),
2026-08-22 17:41:22 +00:00
2026-08-22 17:49:23 +00:00
players,
2026-08-22 17:41:22 +00:00
spectators,
2026-08-22 17:49:23 +00:00
spectatorCount: spectators.length,
2026-08-22 17:41:22 +00:00
2026-08-22 17:49:23 +00:00
maxSpectators: MAX_SPECTATORS,
2026-08-22 17:41:22 +00:00
timeControl: {
key: party.timeControl.key,
label: party.timeControl.label,
initial: party.timeControl.initial,
increment: party.timeControl.increment,
},
clocks: getClockSnapshot(party),
gameOverReason:
party.gameOverReason || null,
winner:
party.winner || null,
2026-08-22 17:49:23 +00:00
you: you
? {
id: you.id,
name: you.name,
role: you.role,
}
: null,
2026-08-22 17:41:22 +00:00
};
}
/* =========================================================
SSE
========================================================= */
2026-08-22 17:49:23 +00:00
function sendSSE(res, event, data) {
2026-08-22 17:41:22 +00:00
try {
res.write(
2026-08-22 17:49:23 +00:00
`event: ${event}\n` +
`data: ${JSON.stringify(data)}\n\n`
2026-08-22 17:41:22 +00:00
);
} catch {
2026-08-22 17:49:23 +00:00
// Connection already closed.
2026-08-22 17:41:22 +00:00
}
}
function broadcastParty(party) {
2026-08-22 17:49:23 +00:00
const clients = party.sseClients;
2026-08-22 17:41:22 +00:00
2026-08-22 17:49:23 +00:00
if (!clients.size) {
return;
}
for (const client of clients) {
sendSSE(
client.res,
2026-08-22 17:41:22 +00:00
"party",
serializeParty(
party,
2026-08-22 17:49:23 +00:00
client.id
2026-08-22 17:41:22 +00:00
)
);
}
}
2026-08-22 17:49:23 +00:00
function addSSEClient(
2026-08-22 17:41:22 +00:00
party,
2026-08-22 17:49:23 +00:00
clientId,
res
2026-08-22 17:41:22 +00:00
) {
2026-08-22 17:49:23 +00:00
const client = {
id: clientId,
res,
};
2026-08-22 17:41:22 +00:00
2026-08-22 17:49:23 +00:00
party.sseClients.add(client);
return client;
}
function removeSSEClient(
party,
client
) {
party.sseClients.delete(client);
}
/* =========================================================
PARTICIPANT CONNECTION
========================================================= */
function disconnectParticipant(
party,
clientId
) {
const participant =
getParticipant(
party,
clientId
2026-08-22 17:41:22 +00:00
);
2026-08-22 17:49:23 +00:00
if (!participant) {
return;
}
participant.connected = false;
broadcastParty(party);
}
function cleanupEmptyParty(party) {
const hasConnectedPlayer =
[...party.participants.values()]
.some(
p =>
p.role !== "spectator" &&
p.connected
);
const hasConnectedSpectator =
[...party.participants.values()]
.some(
p =>
p.role === "spectator" &&
p.connected
);
if (
!hasConnectedPlayer &&
!hasConnectedSpectator &&
party.sseClients.size === 0
) {
parties.delete(party.code);
2026-08-22 17:41:22 +00:00
}
}
/* =========================================================
2026-08-22 17:49:23 +00:00
CREATE PARTY
2026-08-22 17:41:22 +00:00
========================================================= */
app.post(
"/api/party/create",
(req, res) => {
try {
2026-08-22 17:49:23 +00:00
const name = safeName(
req.body?.name
);
2026-08-22 17:41:22 +00:00
const timeControl =
getTimeControl(
req.body?.timeControl
);
2026-08-22 17:49:23 +00:00
const code =
createPartyCode();
const clientId =
createClientId();
const engine =
new ChessEngine();
2026-08-22 17:41:22 +00:00
const party = {
code,
2026-08-22 17:49:23 +00:00
engine,
2026-08-22 17:41:22 +00:00
timeControl,
clocks:
createClocks(
timeControl
),
2026-08-22 17:49:23 +00:00
gameOverReason: null,
2026-08-22 17:41:22 +00:00
2026-08-22 17:49:23 +00:00
winner: null,
2026-08-22 17:41:22 +00:00
2026-08-22 17:49:23 +00:00
participants:
new Map(),
2026-08-22 17:41:22 +00:00
2026-08-22 17:49:23 +00:00
sseClients:
new Set(),
2026-08-22 17:41:22 +00:00
};
2026-08-22 17:49:23 +00:00
party.participants.set(
clientId,
{
id: clientId,
name,
role: "white",
connected: true,
}
);
2026-08-22 17:41:22 +00:00
parties.set(
code,
party
);
res.json({
ok: true,
party:
serializeParty(
party,
2026-08-22 17:49:23 +00:00
clientId
2026-08-22 17:41:22 +00:00
),
});
2026-08-22 17:49:23 +00:00
} catch (error) {
2026-08-22 17:41:22 +00:00
res.status(400).json({
ok: false,
2026-08-22 17:49:23 +00:00
error:
error.message ||
"Unable to create party.",
2026-08-22 17:41:22 +00:00
});
}
}
);
/* =========================================================
2026-08-22 17:49:23 +00:00
JOIN PARTY
2026-08-22 17:41:22 +00:00
========================================================= */
app.post(
"/api/party/join",
(req, res) => {
try {
2026-08-22 17:49:23 +00:00
const code =
normalizeCode(
2026-08-22 17:41:22 +00:00
req.body?.partyCode
);
2026-08-22 17:49:23 +00:00
const name =
safeName(
req.body?.name
2026-08-22 17:41:22 +00:00
);
2026-08-22 17:49:23 +00:00
const party =
getParty(code);
2026-08-22 17:41:22 +00:00
2026-08-22 17:49:23 +00:00
let clientId =
String(
req.body?.clientId ||
""
).trim();
let participant =
getParticipant(
party,
clientId
);
/*
* Reconnect existing client.
*/
if (participant) {
participant.name = name;
participant.connected = true;
} else {
clientId =
createClientId();
let role = "spectator";
if (
!getPlayer(
party,
"white"
)
) {
role = "white";
} else if (
!getPlayer(
party,
"black"
)
) {
role = "black";
} else if (
countSpectators(party) >=
MAX_SPECTATORS
) {
return res.status(403).json({
ok: false,
error:
"Spectator limit reached.",
});
}
participant = {
id: clientId,
name,
role,
connected: true,
};
party.participants.set(
clientId,
participant
);
}
startClockIfReady(party);
broadcastParty(party);
2026-08-22 17:41:22 +00:00
res.json({
ok: true,
party:
serializeParty(
party,
2026-08-22 17:49:23 +00:00
clientId
2026-08-22 17:41:22 +00:00
),
});
2026-08-22 17:49:23 +00:00
} catch (error) {
2026-08-22 17:41:22 +00:00
res.status(400).json({
ok: false,
2026-08-22 17:49:23 +00:00
error:
error.message ||
"Unable to join party.",
2026-08-22 17:41:22 +00:00
});
}
}
);
/* =========================================================
MOVE
========================================================= */
app.post(
"/api/party/move",
(req, res) => {
try {
2026-08-22 17:49:23 +00:00
const {
partyCode,
clientId,
from,
to,
promotion,
} = req.body || {};
2026-08-22 17:41:22 +00:00
const party =
2026-08-22 17:49:23 +00:00
getParty(
partyCode
2026-08-22 17:41:22 +00:00
);
const participant =
2026-08-22 17:49:23 +00:00
getParticipant(
2026-08-22 17:41:22 +00:00
party,
2026-08-22 17:49:23 +00:00
clientId
2026-08-22 17:41:22 +00:00
);
2026-08-22 17:49:23 +00:00
if (!participant) {
return res.status(403).json({
ok: false,
error:
"Player session not found.",
});
}
if (
participant.role !== "white" &&
participant.role !== "black"
) {
return res.status(403).json({
ok: false,
error:
"Spectators cannot make moves.",
});
}
if (party.gameOverReason) {
return res.status(400).json({
ok: false,
error:
"Game is already over.",
party:
serializeParty(
party,
clientId
),
});
}
const playerColor =
roleToColor(
participant.role
);
if (
party.engine.state.turn !==
playerColor
) {
return res.status(400).json({
ok: false,
error:
"It is not your turn.",
});
}
updateClock(party);
if (party.gameOverReason) {
return res.status(400).json({
ok: false,
error:
"Time has expired.",
party:
serializeParty(
party,
clientId
),
});
}
2026-08-22 17:41:22 +00:00
const result =
party.engine.makeMove({
2026-08-22 17:49:23 +00:00
from,
to,
2026-08-22 17:41:22 +00:00
promotion:
2026-08-22 17:49:23 +00:00
promotion || null,
2026-08-22 17:41:22 +00:00
});
2026-08-22 17:49:23 +00:00
if (!result?.ok) {
2026-08-22 17:41:22 +00:00
return res.status(400).json({
ok: false,
error:
2026-08-22 17:49:23 +00:00
result?.error ||
2026-08-22 17:41:22 +00:00
"Illegal move.",
});
}
2026-08-22 17:49:23 +00:00
applyMoveClock(
2026-08-22 17:41:22 +00:00
party,
participant.role
);
2026-08-22 17:49:23 +00:00
updateGameResult(party);
broadcastParty(party);
2026-08-22 17:41:22 +00:00
res.json({
ok: true,
party:
serializeParty(
party,
2026-08-22 17:49:23 +00:00
clientId
2026-08-22 17:41:22 +00:00
),
});
2026-08-22 17:49:23 +00:00
} catch (error) {
2026-08-22 17:41:22 +00:00
res.status(400).json({
ok: false,
2026-08-22 17:49:23 +00:00
error:
error.message ||
"Move failed.",
2026-08-22 17:41:22 +00:00
});
}
}
);
/* =========================================================
2026-08-22 17:49:23 +00:00
PARTY EVENTS / SSE
2026-08-22 17:41:22 +00:00
========================================================= */
2026-08-22 17:49:23 +00:00
app.get(
"/api/party/events",
2026-08-22 17:41:22 +00:00
(req, res) => {
try {
2026-08-22 17:49:23 +00:00
const partyCode =
normalizeCode(
req.query?.partyCode
2026-08-22 17:41:22 +00:00
);
2026-08-22 17:49:23 +00:00
const clientId =
String(
req.query?.clientId ||
""
).trim();
2026-08-22 17:41:22 +00:00
const party =
2026-08-22 17:49:23 +00:00
getParty(
partyCode
2026-08-22 17:41:22 +00:00
);
const participant =
2026-08-22 17:49:23 +00:00
getParticipant(
2026-08-22 17:41:22 +00:00
party,
2026-08-22 17:49:23 +00:00
clientId
2026-08-22 17:41:22 +00:00
);
2026-08-22 17:49:23 +00:00
if (!participant) {
return res.status(403).end();
2026-08-22 17:41:22 +00:00
}
2026-08-22 17:49:23 +00:00
res.setHeader(
"Content-Type",
"text/event-stream"
);
2026-08-22 17:41:22 +00:00
2026-08-22 17:49:23 +00:00
res.setHeader(
"Cache-Control",
"no-cache, no-transform"
);
2026-08-22 17:41:22 +00:00
2026-08-22 17:49:23 +00:00
res.setHeader(
"Connection",
"keep-alive"
);
2026-08-22 17:41:22 +00:00
2026-08-22 17:49:23 +00:00
res.setHeader(
"X-Accel-Buffering",
"no"
2026-08-22 17:41:22 +00:00
);
if (
2026-08-22 17:49:23 +00:00
typeof res.flushHeaders ===
"function"
2026-08-22 17:41:22 +00:00
) {
2026-08-22 17:49:23 +00:00
res.flushHeaders();
2026-08-22 17:41:22 +00:00
}
2026-08-22 17:49:23 +00:00
const client =
addSSEClient(
party,
clientId,
res
);
participant.connected = true;
sendSSE(
res,
"party",
serializeParty(
party,
clientId
)
2026-08-22 17:41:22 +00:00
);
2026-08-22 17:49:23 +00:00
broadcastParty(party);
const heartbeat =
setInterval(() => {
try {
res.write(": ping\n\n");
} catch {
clearInterval(
heartbeat
);
}
}, 15000);
req.on(
"close",
() => {
clearInterval(
heartbeat
);
removeSSEClient(
party,
client
);
disconnectParticipant(
party,
clientId
);
cleanupEmptyParty(
party
);
}
);
} catch {
res.status(404).end();
2026-08-22 17:41:22 +00:00
}
}
);
/* =========================================================
2026-08-22 17:49:23 +00:00
LEAVE PARTY
2026-08-22 17:41:22 +00:00
========================================================= */
app.post(
"/api/party/leave",
(req, res) => {
try {
const party =
2026-08-22 17:49:23 +00:00
getParty(
2026-08-22 17:41:22 +00:00
req.body?.partyCode
);
2026-08-22 17:49:23 +00:00
const clientId =
String(
req.body?.clientId ||
""
).trim();
2026-08-22 17:41:22 +00:00
const participant =
2026-08-22 17:49:23 +00:00
getParticipant(
party,
clientId
2026-08-22 17:41:22 +00:00
);
2026-08-22 17:49:23 +00:00
if (!participant) {
return res.json({
ok: true,
});
2026-08-22 17:41:22 +00:00
}
2026-08-22 17:49:23 +00:00
/*
* Полностью удаляем spectator.
*/
2026-08-22 17:41:22 +00:00
if (
2026-08-22 17:49:23 +00:00
participant.role ===
"spectator"
2026-08-22 17:41:22 +00:00
) {
2026-08-22 17:49:23 +00:00
party.participants.delete(
clientId
2026-08-22 17:41:22 +00:00
);
} else {
2026-08-22 17:49:23 +00:00
/*
* Игрок остаётся в комнате,
* но считается отключённым.
* Это позволяет переподключиться.
*/
participant.connected = false;
2026-08-22 17:41:22 +00:00
}
2026-08-22 17:49:23 +00:00
broadcastParty(party);
cleanupEmptyParty(
party
);
2026-08-22 17:41:22 +00:00
res.json({
ok: true,
});
2026-08-22 17:49:23 +00:00
} catch (error) {
2026-08-22 17:41:22 +00:00
res.status(400).json({
ok: false,
2026-08-22 17:49:23 +00:00
error:
error.message ||
"Unable to leave party.",
2026-08-22 17:41:22 +00:00
});
}
}
);
/* =========================================================
2026-08-22 17:49:23 +00:00
CLOCK TICK
2026-08-22 17:41:22 +00:00
========================================================= */
2026-08-22 17:49:23 +00:00
/*
* Серверные часы должны продолжать идти,
* даже если клиент ничего не отправляет.
*
* Поэтому один лёгкий глобальный таймер
* проверяет активные Party.
*/
2026-08-22 17:41:22 +00:00
2026-08-22 17:49:23 +00:00
const clockTimer = setInterval(
() => {
for (const party of parties.values()) {
if (
party.timeControl.initial <= 0
) {
2026-08-22 17:41:22 +00:00
continue;
}
2026-08-22 17:49:23 +00:00
if (
!party.clocks.running
) {
continue;
}
if (
party.gameOverReason
) {
continue;
}
const beforeWhite =
party.clocks.white;
const beforeBlack =
party.clocks.black;
updateClock(party);
/*
* Отправляем обновление только
* если часы действительно изменились.
*/
if (
beforeWhite !==
party.clocks.white ||
beforeBlack !==
party.clocks.black
) {
broadcastParty(
party
);
}
2026-08-22 17:41:22 +00:00
}
2026-08-22 17:49:23 +00:00
},
1000
);
/* =========================================================
CLEANUP
========================================================= */
function shutdown() {
clearInterval(
clockTimer
);
for (const party of parties.values()) {
for (const client of party.sseClients) {
try {
client.res.end();
} catch {
// ignore
}
}
party.sseClients.clear();
2026-08-22 17:41:22 +00:00
}
2026-08-22 17:49:23 +00:00
parties.clear();
process.exit(0);
}
process.on(
"SIGINT",
shutdown
);
process.on(
"SIGTERM",
shutdown
);
/* =========================================================
FALLBACK
========================================================= */
app.get(
"*",
(req, res) => {
res.sendFile(
path.join(
PUBLIC_DIR,
"index.html"
)
);
}
);
2026-08-22 17:41:22 +00:00
/* =========================================================
START
========================================================= */
app.listen(
PORT,
"0.0.0.0",
() => {
console.log(
2026-08-22 17:49:23 +00:00
`Chess server running on port ${PORT}`
2026-08-22 17:41:22 +00:00
);
}
);
2026-08-22 17:49:23 +00:00