Files
website-collection/server.js
T
2026-08-22 18:56:03 +00:00

478 lines
14 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";
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 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" }));
const MAX_SPECTATORS = 20;
const MAX_CHAT = 100;
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 }
};
const parties = new Map();
const safeName = (value) => {
const name = String(value ?? "").trim().replace(/[<>]/g, "").slice(0, 24);
return name || "Guest";
};
const normalizeCode = (value) => String(value ?? "").trim().toUpperCase();
const clientId = () => crypto.randomUUID();
function partyCode() {
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let code;
do {
code = Array.from({ length: 6 }, () =>
alphabet[crypto.randomInt(0, alphabet.length)]
).join("");
} while (parties.has(code));
return code;
}
function getParty(code) {
const party = parties.get(normalizeCode(code));
if (!party) throw new Error("Комната не найдена.");
return party;
}
function participant(party, id) {
return party.participants.get(String(id || "")) || null;
}
function player(party, role) {
for (const p of party.participants.values()) {
if (p.role === role) return p;
}
return null;
}
function roleColor(role) {
return role === "white" ? "w" : "b";
}
function colorRole(color) {
return color === "w" ? "white" : "black";
}
function makeClocks(tc) {
return {
white: tc.initial,
black: tc.initial,
running: false,
lastTick: Date.now()
};
}
function updateClock(party) {
if (!party.clocks.running || !party.timeControl.initial || party.gameOverReason) return;
const now = Date.now();
const elapsed = Math.max(0, (now - party.clocks.lastTick) / 1000);
const side = colorRole(party.engine.state.turn);
party.clocks[side] = Math.max(0, party.clocks[side] - elapsed);
party.clocks.lastTick = now;
if (party.clocks[side] <= 0) {
party.clocks.running = false;
party.gameOverReason = "timeout";
party.winner = side === "w" ? "b" : "w";
}
}
function startClockIfReady(party) {
if (!party.timeControl.initial || party.gameOverReason) return;
if (!player(party, "white") || !player(party, "black")) return;
party.clocks.running = true;
party.clocks.lastTick = Date.now();
}
function applyMoveClock(party, movingRole) {
if (!party.timeControl.initial || party.gameOverReason) return;
updateClock(party);
if (party.gameOverReason) return;
party.clocks[movingRole] += party.timeControl.increment;
party.clocks.lastTick = Date.now();
party.clocks.running = true;
}
function resetGame(party) {
party.engine = new ChessEngine();
party.clocks = makeClocks(party.timeControl);
party.gameOverReason = null;
party.winner = null;
party.drawOffer = null;
party.gameNumber += 1;
startClockIfReady(party);
}
function serializeParticipant(p) {
return {
id: p.id,
name: p.name,
role: p.role,
connected: Boolean(p.connected)
};
}
function serializeParty(party, me = null) {
updateClock(party);
const players = { white: null, black: null };
const spectators = [];
for (const p of party.participants.values()) {
if (p.role === "white") players.white = serializeParticipant(p);
else if (p.role === "black") players.black = serializeParticipant(p);
else spectators.push(serializeParticipant(p));
}
const you = participant(party, me);
return {
code: party.code,
gameNumber: party.gameNumber,
game: party.engine.getSnapshot(),
players,
spectators,
spectatorCount: spectators.length,
maxSpectators: MAX_SPECTATORS,
timeControl: party.timeControl,
clocks: {
white: Math.max(0, party.clocks.white),
black: Math.max(0, party.clocks.black),
running: Boolean(party.clocks.running),
turn: party.engine.state.turn
},
gameOverReason: party.gameOverReason || null,
winner: party.winner || null,
drawOffer: party.drawOffer
? { from: party.drawOffer.from, name: party.drawOffer.name }
: null,
chat: party.chat.slice(-MAX_CHAT),
you: you ? { id: you.id, name: you.name, role: you.role } : null
};
}
function send(res, event, data) {
try {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
} catch {}
}
function broadcast(party) {
for (const c of party.sse) {
send(c.res, "party", serializeParty(party, c.id));
}
}
function createParty(name, timeControl) {
const tc = TIME_CONTROLS[timeControl] || TIME_CONTROLS["10+0"];
const code = partyCode();
const id = clientId();
const party = {
code,
engine: new ChessEngine(),
timeControl: tc,
clocks: makeClocks(tc),
gameOverReason: null,
winner: null,
drawOffer: null,
gameNumber: 1,
participants: new Map(),
sse: new Set(),
chat: []
};
party.participants.set(id, {
id,
name: safeName(name),
role: "white",
connected: true
});
parties.set(code, party);
return { party, id };
}
function cleanup(party) {
const active = [...party.participants.values()].some(p => p.connected);
if (!active && party.sse.size === 0) parties.delete(party.code);
}
app.post("/api/party/create", (req, res) => {
try {
const { party, id } = createParty(req.body?.name, req.body?.timeControl);
res.json({ ok: true, party: serializeParty(party, id) });
} 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 party = getParty(code);
const name = safeName(req.body?.name);
let id = String(req.body?.clientId || "").trim();
let p = participant(party, id);
if (p) {
p.name = name;
p.connected = true;
} else {
id = clientId();
let role = "spectator";
if (!player(party, "white")) role = "white";
else if (!player(party, "black")) role = "black";
else if (party.participants.size >= MAX_SPECTATORS + 2) {
return res.status(403).json({ ok: false, error: "Лимит зрителей достигнут." });
}
p = { id, name, role, connected: true };
party.participants.set(id, p);
}
startClockIfReady(party);
broadcast(party);
res.json({ ok: true, party: serializeParty(party, id) });
} catch (error) {
res.status(400).json({ ok: false, error: error.message });
}
});
app.post("/api/party/move", (req, res) => {
try {
const party = getParty(req.body?.partyCode);
const p = participant(party, req.body?.clientId);
if (!p || !["white", "black"].includes(p.role)) {
return res.status(403).json({ ok: false, error: "Вы не игрок этой партии." });
}
updateClock(party);
if (party.gameOverReason) {
return res.status(400).json({
ok: false,
error: "Игра уже завершена.",
party: serializeParty(party, p.id)
});
}
const color = roleColor(p.role);
if (party.engine.state.turn !== color) {
return res.status(400).json({ ok: false, error: "Сейчас ход соперника." });
}
const result = party.engine.makeMove({
from: req.body?.from,
to: req.body?.to,
promotion: req.body?.promotion || "q"
});
if (!result.ok) {
return res.status(400).json({ ok: false, error: result.error });
}
applyMoveClock(party, p.role);
const status = party.engine.getStatus();
if (status.phase === "checkmate") {
party.gameOverReason = "checkmate";
party.winner = party.engine.state.turn === "w" ? "b" : "w";
party.clocks.running = false;
} else if (status.phase === "draw") {
party.gameOverReason = "draw";
party.winner = null;
party.clocks.running = false;
}
party.drawOffer = null;
broadcast(party);
res.json({ ok: true, party: serializeParty(party, p.id) });
} catch (error) {
res.status(400).json({ ok: false, error: error.message });
}
});
app.post("/api/party/rematch", (req, res) => {
try {
const party = getParty(req.body?.partyCode);
const p = participant(party, req.body?.clientId);
if (!p) return res.status(403).json({ ok: false, error: "Сессия не найдена." });
resetGame(party);
broadcast(party);
res.json({ ok: true, party: serializeParty(party, p.id) });
} catch (error) {
res.status(400).json({ ok: false, error: error.message });
}
});
app.post("/api/party/resign", (req, res) => {
try {
const party = getParty(req.body?.partyCode);
const p = participant(party, req.body?.clientId);
if (!p || !["white", "black"].includes(p.role)) {
return res.status(403).json({ ok: false, error: "Вы не игрок." });
}
if (party.gameOverReason) return res.json({ ok: true, party: serializeParty(party, p.id) });
party.gameOverReason = "resign";
party.winner = roleColor(p.role) === "w" ? "b" : "w";
party.clocks.running = false;
party.drawOffer = null;
broadcast(party);
res.json({ ok: true, party: serializeParty(party, p.id) });
} catch (error) {
res.status(400).json({ ok: false, error: error.message });
}
});
app.post("/api/party/draw", (req, res) => {
try {
const party = getParty(req.body?.partyCode);
const p = participant(party, req.body?.clientId);
if (!p || !["white", "black"].includes(p.role)) {
return res.status(403).json({ ok: false, error: "Вы не игрок." });
}
if (party.gameOverReason) return res.json({ ok: true, party: serializeParty(party, p.id) });
if (party.drawOffer && party.drawOffer.from !== p.id) {
party.gameOverReason = "draw";
party.winner = null;
party.drawOffer = null;
party.clocks.running = false;
} else {
party.drawOffer = { from: p.id, name: p.name };
}
broadcast(party);
res.json({ ok: true, party: serializeParty(party, p.id) });
} catch (error) {
res.status(400).json({ ok: false, error: error.message });
}
});
app.post("/api/party/chat", (req, res) => {
try {
const party = getParty(req.body?.partyCode);
const p = participant(party, req.body?.clientId);
if (!p) return res.status(403).json({ ok: false, error: "Сессия не найдена." });
const text = String(req.body?.text ?? "").trim().replace(/[<>]/g, "").slice(0, 300);
if (!text) return res.status(400).json({ ok: false, error: "Пустое сообщение." });
party.chat.push({
id: crypto.randomUUID(),
name: p.name,
text,
at: Date.now()
});
if (party.chat.length > MAX_CHAT) party.chat.splice(0, party.chat.length - MAX_CHAT);
broadcast(party);
res.json({ ok: true });
} catch (error) {
res.status(400).json({ ok: false, error: error.message });
}
});
app.get("/api/party/events", (req, res) => {
try {
const party = getParty(req.query?.partyCode);
const id = String(req.query?.clientId || "").trim();
if (!participant(party, id)) 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");
res.flushHeaders?.();
const client = { id, res };
party.sse.add(client);
participant(party, id).connected = true;
send(res, "party", serializeParty(party, id));
broadcast(party);
const heartbeat = setInterval(() => {
try { res.write(": ping\n\n"); } catch { clearInterval(heartbeat); }
}, 15000);
req.on("close", () => {
clearInterval(heartbeat);
party.sse.delete(client);
const p = participant(party, id);
if (p) p.connected = false;
broadcast(party);
cleanup(party);
});
} catch {
res.status(404).end();
}
});
app.post("/api/party/leave", (req, res) => {
try {
const party = getParty(req.body?.partyCode);
const p = participant(party, req.body?.clientId);
if (p) {
if (p.role === "spectator") party.participants.delete(p.id);
else p.connected = false;
}
broadcast(party);
cleanup(party);
res.json({ ok: true });
} catch (error) {
res.status(400).json({ ok: false, error: error.message });
}
});
setInterval(() => {
for (const party of parties.values()) {
const before = party.gameOverReason;
updateClock(party);
if (!before && party.gameOverReason) broadcast(party);
}
}, 250);
app.get("*", (req, res) => {
res.sendFile(path.join(PUBLIC_DIR, "index.html"));
});
app.listen(PORT, "0.0.0.0", () => {
console.log(`Chess Party running on port ${PORT}`);
});