1279 lines
27 KiB
JavaScript
1279 lines
27 KiB
JavaScript
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";
|
|
|
|
/* =========================================================
|
|
PATHS
|
|
========================================================= */
|
|
|
|
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);
|
|
|
|
/* =========================================================
|
|
SERVER
|
|
========================================================= */
|
|
|
|
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;
|
|
|
|
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,
|
|
},
|
|
};
|
|
|
|
/* =========================================================
|
|
STORAGE
|
|
========================================================= */
|
|
|
|
const parties = new Map();
|
|
|
|
/* =========================================================
|
|
HELPERS
|
|
========================================================= */
|
|
|
|
function safeName(name) {
|
|
const value = typeof name === "string"
|
|
? name.trim()
|
|
: "";
|
|
|
|
if (!value) {
|
|
return "Guest";
|
|
}
|
|
|
|
return value
|
|
.replace(/[<>]/g, "")
|
|
.slice(0, 24);
|
|
}
|
|
|
|
function normalizeCode(code) {
|
|
return String(code || "")
|
|
.trim()
|
|
.toUpperCase();
|
|
}
|
|
|
|
function getTimeControl(key) {
|
|
return TIME_CONTROLS[key] || TIME_CONTROLS["10+0"];
|
|
}
|
|
|
|
function createClientId() {
|
|
return crypto.randomUUID();
|
|
}
|
|
|
|
function createPartyCode() {
|
|
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
|
|
let code;
|
|
|
|
do {
|
|
code = "";
|
|
|
|
for (let i = 0; i < 6; i++) {
|
|
code += alphabet[
|
|
crypto.randomInt(0, alphabet.length)
|
|
];
|
|
}
|
|
} while (parties.has(code));
|
|
|
|
return code;
|
|
}
|
|
|
|
function getParty(code) {
|
|
const party = parties.get(normalizeCode(code));
|
|
|
|
if (!party) {
|
|
throw new Error("Party not found.");
|
|
}
|
|
|
|
return party;
|
|
}
|
|
|
|
function getParticipant(party, clientId) {
|
|
if (!clientId) {
|
|
return null;
|
|
}
|
|
|
|
return party.participants.get(clientId) || null;
|
|
}
|
|
|
|
function getPlayer(party, role) {
|
|
for (const participant of party.participants.values()) {
|
|
if (participant.role === role) {
|
|
return participant;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function getOpponentRole(role) {
|
|
return role === "white"
|
|
? "black"
|
|
: "white";
|
|
}
|
|
|
|
function roleToColor(role) {
|
|
return role === "white"
|
|
? "w"
|
|
: "b";
|
|
}
|
|
|
|
function colorToRole(color) {
|
|
return color === "w"
|
|
? "white"
|
|
: "black";
|
|
}
|
|
|
|
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,
|
|
|
|
running: false,
|
|
|
|
lastTick: Date.now(),
|
|
|
|
turn: "w",
|
|
};
|
|
}
|
|
|
|
function updateClock(party) {
|
|
const clocks = party.clocks;
|
|
|
|
if (!clocks) {
|
|
return;
|
|
}
|
|
|
|
if (party.timeControl.initial <= 0) {
|
|
return;
|
|
}
|
|
|
|
if (!clocks.running) {
|
|
return;
|
|
}
|
|
|
|
if (party.gameOverReason) {
|
|
return;
|
|
}
|
|
|
|
const now = Date.now();
|
|
|
|
const elapsed =
|
|
(now - clocks.lastTick) / 1000;
|
|
|
|
if (elapsed <= 0) {
|
|
return;
|
|
}
|
|
|
|
const role = colorToRole(
|
|
party.engine.state.turn
|
|
);
|
|
|
|
clocks[role] = Math.max(
|
|
0,
|
|
clocks[role] - elapsed
|
|
);
|
|
|
|
clocks.lastTick = now;
|
|
clocks.turn = party.engine.state.turn;
|
|
|
|
if (clocks[role] <= 0) {
|
|
const winnerColor = roleToColor(
|
|
getOpponentRole(role)
|
|
);
|
|
|
|
setGameOver(
|
|
party,
|
|
"timeout",
|
|
winnerColor
|
|
);
|
|
}
|
|
}
|
|
|
|
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) {
|
|
if (party.timeControl.initial <= 0) {
|
|
return;
|
|
}
|
|
|
|
const clocks = party.clocks;
|
|
|
|
updateClock(party);
|
|
|
|
if (party.gameOverReason) {
|
|
return;
|
|
}
|
|
|
|
clocks[movingRole] +=
|
|
party.timeControl.increment;
|
|
|
|
clocks.turn = party.engine.state.turn;
|
|
clocks.lastTick = Date.now();
|
|
clocks.running = true;
|
|
}
|
|
|
|
function getClockSnapshot(party) {
|
|
updateClock(party);
|
|
|
|
return {
|
|
white: Math.max(
|
|
0,
|
|
Number(party.clocks.white) || 0
|
|
),
|
|
|
|
black: Math.max(
|
|
0,
|
|
Number(party.clocks.black) || 0
|
|
),
|
|
|
|
running: Boolean(
|
|
party.clocks.running
|
|
),
|
|
|
|
turn: party.engine.state.turn,
|
|
};
|
|
}
|
|
|
|
/* =========================================================
|
|
GAME STATE
|
|
========================================================= */
|
|
|
|
function getEngineStatus(party) {
|
|
return (
|
|
party.engine.state.status ||
|
|
ChessEngine.evaluateStatus(
|
|
party.engine.state
|
|
)
|
|
);
|
|
}
|
|
|
|
function setGameOver(
|
|
party,
|
|
reason,
|
|
winner = null
|
|
) {
|
|
if (party.gameOverReason) {
|
|
return;
|
|
}
|
|
|
|
party.gameOverReason = reason;
|
|
party.winner = winner;
|
|
|
|
party.clocks.running = false;
|
|
|
|
broadcastParty(party);
|
|
}
|
|
|
|
function updateGameResult(party) {
|
|
if (party.gameOverReason) {
|
|
return;
|
|
}
|
|
|
|
const status = getEngineStatus(party);
|
|
|
|
if (!status) {
|
|
return;
|
|
}
|
|
|
|
if (status.phase === "checkmate") {
|
|
setGameOver(
|
|
party,
|
|
"checkmate",
|
|
party.engine.state.turn === "w"
|
|
? "b"
|
|
: "w"
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
if (status.phase === "draw") {
|
|
setGameOver(
|
|
party,
|
|
"draw",
|
|
null
|
|
);
|
|
}
|
|
}
|
|
|
|
/* =========================================================
|
|
SERIALIZATION
|
|
========================================================= */
|
|
|
|
function serializeParticipant(participant) {
|
|
return {
|
|
id: participant.id,
|
|
name: participant.name,
|
|
role: participant.role,
|
|
connected: participant.connected,
|
|
};
|
|
}
|
|
|
|
function serializeParty(party, clientId = null) {
|
|
updateClock(party);
|
|
|
|
const players = {
|
|
white: null,
|
|
black: null,
|
|
};
|
|
|
|
const spectators = [];
|
|
|
|
for (const participant of party.participants.values()) {
|
|
if (participant.role === "white") {
|
|
players.white =
|
|
serializeParticipant(participant);
|
|
}
|
|
|
|
if (participant.role === "black") {
|
|
players.black =
|
|
serializeParticipant(participant);
|
|
}
|
|
|
|
if (participant.role === "spectator") {
|
|
spectators.push(
|
|
serializeParticipant(participant)
|
|
);
|
|
}
|
|
}
|
|
|
|
const you = getParticipant(
|
|
party,
|
|
clientId
|
|
);
|
|
|
|
return {
|
|
code: party.code,
|
|
|
|
game: party.engine.getSnapshot(),
|
|
|
|
players,
|
|
|
|
spectators,
|
|
|
|
spectatorCount: spectators.length,
|
|
|
|
maxSpectators: MAX_SPECTATORS,
|
|
|
|
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,
|
|
|
|
you: you
|
|
? {
|
|
id: you.id,
|
|
name: you.name,
|
|
role: you.role,
|
|
}
|
|
: null,
|
|
};
|
|
}
|
|
|
|
/* =========================================================
|
|
SSE
|
|
========================================================= */
|
|
|
|
function sendSSE(res, event, data) {
|
|
try {
|
|
res.write(
|
|
`event: ${event}\n` +
|
|
`data: ${JSON.stringify(data)}\n\n`
|
|
);
|
|
} catch {
|
|
// Connection already closed.
|
|
}
|
|
}
|
|
|
|
function broadcastParty(party) {
|
|
const clients = party.sseClients;
|
|
|
|
if (!clients.size) {
|
|
return;
|
|
}
|
|
|
|
for (const client of clients) {
|
|
sendSSE(
|
|
client.res,
|
|
"party",
|
|
serializeParty(
|
|
party,
|
|
client.id
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
function addSSEClient(
|
|
party,
|
|
clientId,
|
|
res
|
|
) {
|
|
const client = {
|
|
id: clientId,
|
|
res,
|
|
};
|
|
|
|
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
|
|
);
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
/* =========================================================
|
|
CREATE PARTY
|
|
========================================================= */
|
|
|
|
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 engine =
|
|
new ChessEngine();
|
|
|
|
const party = {
|
|
code,
|
|
|
|
engine,
|
|
|
|
timeControl,
|
|
|
|
clocks:
|
|
createClocks(
|
|
timeControl
|
|
),
|
|
|
|
gameOverReason: null,
|
|
|
|
winner: null,
|
|
|
|
participants:
|
|
new Map(),
|
|
|
|
sseClients:
|
|
new Set(),
|
|
};
|
|
|
|
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 ||
|
|
"Unable to create party.",
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
/* =========================================================
|
|
JOIN PARTY
|
|
========================================================= */
|
|
|
|
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
|
|
);
|
|
|
|
/*
|
|
* 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);
|
|
|
|
res.json({
|
|
ok: true,
|
|
|
|
party:
|
|
serializeParty(
|
|
party,
|
|
clientId
|
|
),
|
|
});
|
|
} catch (error) {
|
|
res.status(400).json({
|
|
ok: false,
|
|
error:
|
|
error.message ||
|
|
"Unable to join party.",
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
/* =========================================================
|
|
MOVE
|
|
========================================================= */
|
|
|
|
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:
|
|
"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
|
|
),
|
|
});
|
|
}
|
|
|
|
const result =
|
|
party.engine.makeMove({
|
|
from,
|
|
to,
|
|
promotion:
|
|
promotion || null,
|
|
});
|
|
|
|
if (!result?.ok) {
|
|
return res.status(400).json({
|
|
ok: false,
|
|
error:
|
|
result?.error ||
|
|
"Illegal move.",
|
|
});
|
|
}
|
|
|
|
applyMoveClock(
|
|
party,
|
|
participant.role
|
|
);
|
|
|
|
updateGameResult(party);
|
|
|
|
broadcastParty(party);
|
|
|
|
res.json({
|
|
ok: true,
|
|
|
|
party:
|
|
serializeParty(
|
|
party,
|
|
clientId
|
|
),
|
|
});
|
|
} catch (error) {
|
|
res.status(400).json({
|
|
ok: false,
|
|
error:
|
|
error.message ||
|
|
"Move failed.",
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
/* =========================================================
|
|
PARTY EVENTS / SSE
|
|
========================================================= */
|
|
|
|
app.get(
|
|
"/api/party/events",
|
|
(req, res) => {
|
|
try {
|
|
const partyCode =
|
|
normalizeCode(
|
|
req.query?.partyCode
|
|
);
|
|
|
|
const clientId =
|
|
String(
|
|
req.query?.clientId ||
|
|
""
|
|
).trim();
|
|
|
|
const party =
|
|
getParty(
|
|
partyCode
|
|
);
|
|
|
|
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 =
|
|
addSSEClient(
|
|
party,
|
|
clientId,
|
|
res
|
|
);
|
|
|
|
participant.connected = true;
|
|
|
|
sendSSE(
|
|
res,
|
|
"party",
|
|
serializeParty(
|
|
party,
|
|
clientId
|
|
)
|
|
);
|
|
|
|
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();
|
|
}
|
|
}
|
|
);
|
|
|
|
/* =========================================================
|
|
LEAVE PARTY
|
|
========================================================= */
|
|
|
|
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,
|
|
});
|
|
}
|
|
|
|
/*
|
|
* Полностью удаляем spectator.
|
|
*/
|
|
if (
|
|
participant.role ===
|
|
"spectator"
|
|
) {
|
|
party.participants.delete(
|
|
clientId
|
|
);
|
|
} else {
|
|
/*
|
|
* Игрок остаётся в комнате,
|
|
* но считается отключённым.
|
|
* Это позволяет переподключиться.
|
|
*/
|
|
participant.connected = false;
|
|
}
|
|
|
|
broadcastParty(party);
|
|
|
|
cleanupEmptyParty(
|
|
party
|
|
);
|
|
|
|
res.json({
|
|
ok: true,
|
|
});
|
|
} catch (error) {
|
|
res.status(400).json({
|
|
ok: false,
|
|
error:
|
|
error.message ||
|
|
"Unable to leave party.",
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
/* =========================================================
|
|
CLOCK TICK
|
|
========================================================= */
|
|
|
|
/*
|
|
* Серверные часы должны продолжать идти,
|
|
* даже если клиент ничего не отправляет.
|
|
*
|
|
* Поэтому один лёгкий глобальный таймер
|
|
* проверяет активные Party.
|
|
*/
|
|
|
|
const clockTimer = setInterval(
|
|
() => {
|
|
for (const party of parties.values()) {
|
|
if (
|
|
party.timeControl.initial <= 0
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
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
|
|
);
|
|
}
|
|
}
|
|
},
|
|
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();
|
|
}
|
|
|
|
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"
|
|
)
|
|
);
|
|
}
|
|
);
|
|
|
|
/* =========================================================
|
|
START
|
|
========================================================= */
|
|
|
|
app.listen(
|
|
PORT,
|
|
"0.0.0.0",
|
|
() => {
|
|
console.log(
|
|
`Chess server running on port ${PORT}`
|
|
);
|
|
}
|
|
);
|
|
|