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}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
);
|