Files
website-collection/server.js
T

1314 lines
22 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";
2026-08-23 07:20:17 +00:00
2026-08-22 17:41:22 +00:00
import express from "express";
2026-08-23 06:43:20 +00:00
2026-08-22 17:41:22 +00:00
import { ChessEngine } from "./shared/chess-engine.js";
2026-08-23 06:43:20 +00:00
2026-08-23 07:20:17 +00:00
const __filename =
fileURLToPath(import.meta.url);
2026-08-22 17:41:22 +00:00
2026-08-23 07:20:17 +00:00
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 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"
}
)
);
2026-08-23 06:43:20 +00:00
2026-08-22 17:49:23 +00:00
const MAX_SPECTATORS = 20;
2026-08-23 07:20:17 +00:00
2026-08-22 17:41:22 +00:00
const TIME_CONTROLS = {
2026-08-23 07:20:17 +00:00
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:41:22 +00:00
};
2026-08-23 07:20:17 +00:00
const parties =
new Map();
2026-08-22 17:49:23 +00:00
2026-08-23 06:43:20 +00:00
function safeName(name) {
2026-08-23 07:20:17 +00:00
const value =
typeof name === "string"
? name.trim()
: "";
if (!value) {
return "Guest";
}
return value
.replace(/[<>]/g, "")
.slice(0, 24);
2026-08-23 06:43:20 +00:00
}
2026-08-22 17:41:22 +00:00
2026-08-23 07:20:17 +00:00
2026-08-23 06:43:20 +00:00
function normalizeCode(code) {
2026-08-23 07:20:17 +00:00
return String(code || "")
.trim()
.toUpperCase();
2026-08-23 06:43:20 +00:00
}
2026-08-22 17:41:22 +00:00
2026-08-23 07:20:17 +00:00
2026-08-23 06:43:20 +00:00
function getTimeControl(key) {
2026-08-23 07:20:17 +00:00
return (
TIME_CONTROLS[key] ||
TIME_CONTROLS["10+0"]
);
2026-08-23 06:43:20 +00:00
}
2026-08-22 17:41:22 +00:00
2026-08-23 07:20:17 +00:00
2026-08-23 06:43:20 +00:00
function createClientId() {
2026-08-23 07:20:17 +00:00
2026-08-23 06:43:20 +00:00
return crypto.randomUUID();
}
2026-08-23 07:20:17 +00:00
2026-08-23 06:43:20 +00:00
function createPartyCode() {
2026-08-23 07:20:17 +00:00
const alphabet =
"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
2026-08-23 06:43:20 +00:00
let code;
2026-08-23 07:20:17 +00:00
2026-08-23 06:43:20 +00:00
do {
2026-08-23 07:20:17 +00:00
2026-08-23 06:43:20 +00:00
code = "";
2026-08-23 07:20:17 +00:00
for (
let i = 0;
i < 6;
i++
) {
code +=
alphabet[
crypto.randomInt(
0,
alphabet.length
)
];
}
} while (
parties.has(code)
);
2026-08-23 06:43:20 +00:00
return code;
2026-08-22 17:41:22 +00:00
}
2026-08-23 07:20:17 +00:00
2026-08-22 17:49:23 +00:00
function getParty(code) {
2026-08-23 07:20:17 +00:00
const party =
parties.get(
normalizeCode(code)
);
if (!party) {
throw new Error(
"Комната не найдена."
);
}
2026-08-23 06:43:20 +00:00
return party;
2026-08-22 17:41:22 +00:00
}
2026-08-23 07:20:17 +00:00
function getParticipant(
party,
clientId
) {
return (
clientId &&
party.participants.get(
clientId
)
) || null;
2026-08-22 17:49:23 +00:00
}
2026-08-23 07:20:17 +00:00
function getPlayer(
party,
role
) {
for (
const participant
of party.participants.values()
) {
if (
participant.role === role
) {
return participant;
}
2026-08-23 06:43:20 +00:00
}
2026-08-23 07:20:17 +00:00
2026-08-23 06:43:20 +00:00
return null;
2026-08-22 17:41:22 +00:00
}
2026-08-23 07:20:17 +00:00
function roleColor(role) {
return role === "white"
? "w"
: "b";
}
function oppositeColor(color) {
return color === "w"
? "b"
: "w";
}
2026-08-23 06:43:20 +00:00
function countSpectators(party) {
2026-08-23 07:20:17 +00:00
2026-08-23 06:43:20 +00:00
let count = 0;
2026-08-23 07:20:17 +00:00
for (
const participant
of party.participants.values()
) {
if (
participant.role === "spectator"
) {
count++;
}
2026-08-23 06:43:20 +00:00
}
2026-08-23 07:20:17 +00:00
2026-08-23 06:43:20 +00:00
return count;
2026-08-22 17:49:23 +00:00
}
2026-08-23 07:20:17 +00:00
function createClocks(
timeControl
) {
return {
white:
timeControl.initial,
black:
timeControl.initial,
running: false,
turn: "w",
lastTick:
Date.now()
};
2026-08-22 17:41:22 +00:00
}
2026-08-23 07:20:17 +00:00
2026-08-22 17:41:22 +00:00
function updateClock(party) {
2026-08-23 07:20:17 +00:00
if (
!party.timeControl.initial
) {
return;
}
2026-08-22 17:41:22 +00:00
2026-08-23 07:20:17 +00:00
if (
!party.clocks.running
) {
return;
}
2026-08-22 17:49:23 +00:00
2026-08-23 07:20:17 +00:00
if (
party.gameOverReason
) {
return;
}
const now =
Date.now();
const elapsed =
(
now -
party.clocks.lastTick
) / 1000;
if (
elapsed <= 0
) {
return;
}
const color =
party.engine.state.turn;
const key =
color === "w"
? "white"
: "black";
party.clocks[key] =
Math.max(
0,
party.clocks[key] -
elapsed
);
party.clocks.turn =
color;
party.clocks.lastTick =
now;
if (
party.clocks[key] <= 0
) {
party.gameOverReason =
"timeout";
party.winner =
oppositeColor(
color
);
party.clocks.running =
false;
2026-08-23 06:43:20 +00:00
}
2026-08-22 17:41:22 +00:00
}
2026-08-23 07:20:17 +00:00
function startClock(
party
) {
if (
!party.timeControl.initial
) {
return;
}
if (
!getPlayer(
party,
"white"
) ||
!getPlayer(
party,
"black"
)
) {
return;
}
party.clocks.running =
true;
party.clocks.turn =
party.engine.state.turn;
party.clocks.lastTick =
Date.now();
2026-08-23 06:43:20 +00:00
}
2026-08-22 17:49:23 +00:00
2026-08-23 07:20:17 +00:00
function applyMoveClock(
party,
movingColor
) {
if (
!party.timeControl.initial
) {
return;
}
updateClock(
party
);
if (
party.gameOverReason
) {
return;
}
const key =
movingColor === "w"
? "white"
: "black";
party.clocks[key] +=
party.timeControl.increment;
party.clocks.turn =
party.engine.state.turn;
party.clocks.lastTick =
Date.now();
party.clocks.running =
true;
2026-08-22 17:49:23 +00:00
}
2026-08-23 07:20:17 +00:00
function getEngineStatus(
party
) {
return party.engine.getStatus();
2026-08-22 17:41:22 +00:00
}
2026-08-23 07:20:17 +00:00
function updateGameResult(
party
) {
if (
party.gameOverReason
) {
return;
}
const status =
getEngineStatus(
party
);
if (
status.phase === "checkmate"
) {
party.gameOverReason =
"checkmate";
party.winner =
status.winner;
party.clocks.running =
false;
} else if (
status.phase === "draw"
) {
party.gameOverReason =
"draw";
party.winner =
null;
party.clocks.running =
false;
}
}
function serializeParticipant(
participant
) {
2026-08-23 06:43:20 +00:00
return {
2026-08-23 07:20:17 +00:00
id: participant.id,
name: participant.name,
role: participant.role,
connected:
participant.connected
2026-08-23 06:43:20 +00:00
};
2026-08-22 17:41:22 +00:00
}
2026-08-22 17:49:23 +00:00
2026-08-23 07:20:17 +00:00
function serializeParty(
party,
clientId
) {
2026-08-22 17:49:23 +00:00
2026-08-23 07:20:17 +00:00
updateClock(
party
);
2026-08-22 17:49:23 +00:00
2026-08-23 07:20:17 +00:00
const players = {
white: null,
black: null
};
2026-08-22 17:49:23 +00:00
2026-08-23 06:43:20 +00:00
const spectators = [];
2026-08-22 17:49:23 +00:00
2026-08-23 07:20:17 +00:00
for (
const participant
of party.participants.values()
) {
if (
participant.role === "white"
) {
players.white =
serializeParticipant(
participant
);
} else if (
participant.role === "black"
) {
players.black =
serializeParticipant(
participant
);
} else {
spectators.push(
serializeParticipant(
participant
)
);
}
2026-08-23 06:43:20 +00:00
}
2026-08-22 18:56:03 +00:00
2026-08-23 07:20:17 +00:00
const you =
getParticipant(
party,
clientId
);
2026-08-23 06:43:20 +00:00
return {
2026-08-23 07:20:17 +00:00
code:
party.code,
game:
party.engine.getSnapshot(),
2026-08-23 06:43:20 +00:00
players,
2026-08-23 07:20:17 +00:00
2026-08-23 06:43:20 +00:00
spectators,
2026-08-23 07:20:17 +00:00
spectatorCount:
spectators.length,
maxSpectators:
MAX_SPECTATORS,
timeControl:
party.timeControl,
clocks: {
white:
party.clocks.white,
black:
party.clocks.black,
running:
party.clocks.running,
turn:
party.clocks.turn
},
gameOverReason:
party.gameOverReason ||
null,
winner:
party.winner ||
null,
you:
you
? {
id: you.id,
name: you.name,
role: you.role
}
: null
2026-08-23 06:43:20 +00:00
};
2026-08-22 18:56:03 +00:00
}
2026-08-23 06:43:20 +00:00
2026-08-23 07:20:17 +00:00
function sendSSE(
response,
event,
data
) {
2026-08-23 06:43:20 +00:00
try {
2026-08-23 07:20:17 +00:00
response.write(
`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
);
2026-08-23 06:43:20 +00:00
} catch {
2026-08-23 07:20:17 +00:00
// connection closed
2026-08-23 06:43:20 +00:00
}
}
2026-08-23 07:20:17 +00:00
function broadcastParty(
party
) {
2026-08-22 18:56:03 +00:00
2026-08-23 07:20:17 +00:00
for (
const client
of party.sseClients
) {
2026-08-22 17:49:23 +00:00
2026-08-23 07:20:17 +00:00
sendSSE(
client.res,
"party",
serializeParty(
party,
client.id
)
);
}
}
app.post(
"/api/party/create",
(req, res) => {
try {
const name =
safeName(
req.body?.name
);
const timeControl =
getTimeControl(
req.body?.timeControl
);
const code =
createPartyCode();
const clientId =
createClientId();
const party = {
code,
engine:
new ChessEngine(),
timeControl,
clocks:
createClocks(
timeControl
),
participants:
new Map(),
sseClients:
new Set(),
gameOverReason:
null,
winner:
null
};
party.participants.set(
clientId,
{
id: clientId,
name,
role: "white",
connected: true
}
);
parties.set(
code,
party
);
res.json({
ok: true,
party:
serializeParty(
party,
clientId
)
});
} catch (error) {
res.status(400).json({
ok: false,
error:
error.message
});
}
}
);
app.post(
"/api/party/join",
(req, res) => {
try {
const code =
normalizeCode(
req.body?.partyCode
);
const name =
safeName(
req.body?.name
);
const party =
getParty(code);
let clientId =
String(
req.body?.clientId || ""
).trim();
let participant =
getParticipant(
party,
clientId
);
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:
"Достигнут лимит зрителей."
});
}
participant = {
id: clientId,
name,
role,
connected: true
};
party.participants.set(
clientId,
participant
);
}
startClock(
party
);
broadcastParty(
party
);
res.json({
ok: true,
party:
serializeParty(
party,
clientId
)
});
} catch (error) {
res.status(400).json({
ok: false,
error:
error.message
});
}
}
);
app.post(
"/api/party/move",
(req, res) => {
try {
const {
partyCode,
clientId,
from,
to,
promotion
} = req.body || {};
const party =
getParty(
partyCode
);
const participant =
getParticipant(
party,
clientId
);
if (!participant) {
return res.status(403).json({
ok: false,
error:
"Игрок не найден."
});
}
if (
participant.role !== "white" &&
participant.role !== "black"
) {
return res.status(403).json({
ok: false,
error:
"Зритель не может делать ходы."
});
}
if (
party.gameOverReason
) {
return res.status(400).json({
ok: false,
error:
"Игра уже закончена.",
party:
serializeParty(
party,
clientId
)
});
}
const color =
roleColor(
participant.role
);
if (
party.engine.state.turn !== color
) {
return res.status(400).json({
ok: false,
error:
"Сейчас не ваш ход."
});
}
updateClock(
party
);
if (
party.gameOverReason
) {
return res.status(400).json({
ok: false,
error:
"Время закончилось.",
party:
serializeParty(
party,
clientId
)
});
}
const result =
party.engine.makeMove({
from,
to,
promotion:
promotion || null
});
if (!result.ok) {
return res.status(400).json({
ok: false,
error:
result.error
});
}
applyMoveClock(
party,
color
);
updateGameResult(
party
);
broadcastParty(
party
);
res.json({
ok: true,
party:
serializeParty(
party,
clientId
)
});
} catch (error) {
res.status(400).json({
ok: false,
error:
error.message
});
}
}
);
app.get(
"/api/party/events",
(req, res) => {
try {
const code =
normalizeCode(
req.query?.partyCode
);
const clientId =
String(
req.query?.clientId || ""
).trim();
const party =
getParty(code);
const participant =
getParticipant(
party,
clientId
);
if (!participant) {
return res.status(403).end();
}
res.setHeader(
"Content-Type",
"text/event-stream"
);
res.setHeader(
"Cache-Control",
"no-cache, no-transform"
);
res.setHeader(
"Connection",
"keep-alive"
);
res.setHeader(
"X-Accel-Buffering",
"no"
);
if (
typeof res.flushHeaders ===
"function"
) {
res.flushHeaders();
}
const client = {
id: clientId,
res
};
party.sseClients.add(
client
);
participant.connected =
true;
sendSSE(
res,
"party",
serializeParty(
party,
clientId
)
);
const heartbeat =
setInterval(
() => {
try {
res.write(
": ping\n\n"
);
} catch {
clearInterval(
heartbeat
);
}
},
15000
);
req.on(
"close",
() => {
clearInterval(
heartbeat
);
party.sseClients.delete(
client
);
participant.connected =
false;
broadcastParty(
party
);
}
);
} catch {
res.status(404).end();
}
}
);
app.post(
"/api/party/leave",
(req, res) => {
try {
const party =
getParty(
req.body?.partyCode
);
const clientId =
String(
req.body?.clientId || ""
).trim();
const participant =
getParticipant(
party,
clientId
);
if (!participant) {
return res.json({
ok: true
});
}
if (
participant.role ===
"spectator"
) {
party.participants.delete(
clientId
);
} else {
participant.connected =
false;
}
broadcastParty(
party
);
res.json({
ok: true
});
} catch (error) {
res.status(400).json({
ok: false,
error:
error.message
});
}
}
);
setInterval(
() => {
for (
const party
of parties.values()
) {
if (
!party.timeControl.initial ||
!party.clocks.running ||
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
);
}
}
},
1000
);
app.get(
"*",
(req, res) => {
res.sendFile(
path.join(
PUBLIC_DIR,
"index.html"
)
);
}
);
app.listen(
PORT,
"0.0.0.0",
() => {
console.log(
`Chess server running on port ${PORT}`
);
}
);