478 lines
14 KiB
JavaScript
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}`);
|
|
});
|
|
|