36 Commits
Author SHA1 Message Date
admin01 52edd18b3e Update chess/public/index.html 2026-08-22 19:00:53 +00:00
admin01 299f47b1b3 Update chess/public/styles.css 2026-08-22 19:00:17 +00:00
admin01 d0e0e3274a Update chess/public/js/app.js 2026-08-22 18:59:37 +00:00
admin01 d38ae7cbe6 Update chess/shared/chess-engine.js 2026-08-22 18:57:39 +00:00
admin01 7c94426848 Update package.json 2026-08-22 18:56:58 +00:00
admin01 1fbdd1317c Update server.js 2026-08-22 18:56:03 +00:00
admin01 1f41362338 Upload files to "/" 2026-08-22 18:54:41 +00:00
admin01 2dfea24f42 Update chess/public/styles.css 2026-08-22 18:21:04 +00:00
admin01 039b64f9e5 Update chess/public/js/app.js 2026-08-22 18:20:15 +00:00
admin01 6ed96533b0 Update chess/public/js/app.js 2026-08-22 18:14:17 +00:00
admin01 8dcc964d04 Update chess/public/js/app.js 2026-08-22 18:08:35 +00:00
admin01 226ad23f7b Update chess/public/js/app.js 2026-08-22 17:51:45 +00:00
admin01 5f75752b26 Update server.js 2026-08-22 17:49:23 +00:00
admin01 5e57feb66f Update chess/public/index.html 2026-08-22 17:47:08 +00:00
admin01 4e6e2fc9e2 Update chess/public/styles.css 2026-08-22 17:46:20 +00:00
admin01 e6e57a3cc0 Add server.js 2026-08-22 17:41:22 +00:00
admin01 0720bcc229 Add package.json 2026-08-22 17:40:44 +00:00
admin01 093b4ac46f Add package-lock.json 2026-08-22 17:40:02 +00:00
admin01 3b02071fe7 Add chess/shared/chess-engine.js 2026-08-22 17:39:10 +00:00
admin01 728da46b10 Add chess/public/js/app.js 2026-08-22 17:37:25 +00:00
admin01 328fe6b417 Add chess/public/js/ai.js 2026-08-22 17:36:51 +00:00
admin01 0aba79015d Delete chess/public/js 2026-08-22 17:36:17 +00:00
admin01 3d7bfd1797 Add chess/public/js 2026-08-22 17:36:04 +00:00
admin01 debf3bbc68 Add chess/public/styles.css 2026-08-22 17:35:26 +00:00
admin01 1d87e4539b Add chess/public/index.html 2026-08-22 17:34:34 +00:00
admin01 11a1b0cb41 Delete chess/public 2026-08-22 17:32:52 +00:00
admin01 83500d2d86 Add chess/public 2026-08-22 17:30:18 +00:00
admin01 da311edec3 Delete batch.zip 2026-08-22 17:03:06 +00:00
admin01 34606b50cc Upload files to "/" 2026-08-22 17:02:45 +00:00
admin01 0a8475d245 Delete directory 'public' 2026-08-22 17:00:37 +00:00
admin01 52ce4ab42a Add public/404.html 2026-08-22 16:55:44 +00:00
admin01 ff02d54bab Delete radiowebview.html 2026-08-22 16:53:45 +00:00
admin01 555d0b2005 Update radiowebview.html 2026-08-22 16:45:58 +00:00
admin01 bef651fa5d Merge pull request 'Radio managment' (#1) from radio into main
Reviewed-on: #1
2026-08-22 16:37:48 +00:00
admin01 1db3fee220 Delete 404example 2026-08-22 16:34:37 +00:00
admin01 7df9a0436a Add radio.webview 2026-08-22 16:33:49 +00:00
10 changed files with 2375 additions and 304 deletions
-304
View File
@@ -1,304 +0,0 @@
<!DOCTYPE html>
<html lang="ru" data-theme="neutral">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<!-- FAVICON -->
<link rel="icon" type="image/png" href="https://staticonline.duckdns.org/api/public/dl/ВАШ_FAVICON?inline=true">
<link rel="shortcut icon" href="https://staticonline.duckdns.org/api/public/dl/ВАШ_FAVICON?inline=true">
<title>404</title>
<style>
:root {
--bg: #12121a;
--card-bg: rgba(26, 26, 36, 0.75);
--text: #e0e0e0;
--sub: #a0a0b0;
--accent-blue: #7aa2f7;
--accent-pink: #c084fc;
--border: rgba(255, 255, 255, 0.12);
--input-bg: rgba(18, 18, 26, 0.85);
--mono: 'Courier New', 'Consolas', 'Liberation Mono', monospace;
--font: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--radius: 6px;
--transition: 0.2s ease;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html { font-size: 16px; background: var(--bg); -webkit-font-smoothing: antialiased; }
body {
font-family: var(--font);
min-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
color: var(--text);
position: relative;
background: url('https://staticonline.duckdns.org/api/public/dl/K_naZzYv?inline=true') center/cover no-repeat fixed;
}
body::before {
content: '';
position: fixed;
inset: 0;
background: rgba(10, 10, 20, 0.55);
z-index: 0;
pointer-events: none;
}
.card {
position: relative;
z-index: 1;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
padding: 2.8rem 2.2rem;
text-align: center;
width: 100%;
max-width: 500px;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
animation: cardIn 0.6s cubic-bezier(0.22, 0.61, 0.36, 1);
}
@keyframes cardIn {
from { opacity: 0; transform: translateY(20px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.code-block { display: flex; align-items: center; justify-content: center; gap: 0.3rem; margin-bottom: 0.5rem; }
.digit {
font-family: var(--mono);
font-size: 6rem;
font-weight: 700;
line-height: 1;
color: var(--text);
text-shadow: 0 0 12px rgba(122, 162, 247, 0.4);
animation: gentleFloat 4s ease-in-out infinite;
}
.digit:nth-child(2) { animation-delay: 0.2s; color: var(--accent-blue); text-shadow: 0 0 16px rgba(122, 162, 247, 0.6); }
.digit:nth-child(3) { animation-delay: 0.4s; color: var(--accent-pink); text-shadow: 0 0 16px rgba(192, 132, 252, 0.6); }
@keyframes gentleFloat {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
.divider-dot { width: 10px; height: 10px; background: var(--accent-blue); border-radius: 50%; opacity: 0.5; }
.divider-dot:nth-child(4) { background: var(--accent-pink); }
.title { font-family: var(--mono); font-size: 1.2rem; margin-bottom: 0.5rem; letter-spacing: 0.05em; text-transform: uppercase; }
.desc { font-size: 0.85rem; color: var(--sub); max-width: 380px; margin: 0 auto 1.8rem; line-height: 1.6; }
.search-section { text-align: left; }
.search-label {
font-family: var(--mono);
font-size: 0.65rem;
color: var(--accent-blue);
display: block;
margin: 0 0 8px 4px;
text-transform: uppercase;
letter-spacing: 0.08em;
opacity: 0.9;
}
.search-box {
display: flex;
background: var(--input-bg);
border-radius: var(--radius);
padding: 3px 3px 3px 14px;
border: 1px solid var(--border);
transition: var(--transition);
}
.search-box:focus-within { border-color: var(--accent-blue); box-shadow: 0 0 0 2px rgba(122, 162, 247, 0.2); }
.search-box input {
flex: 1;
background: transparent;
border: none;
padding: 0.55rem 0;
font-size: 0.8rem;
outline: none;
color: var(--text);
font-family: var(--mono);
min-width: 0;
}
.search-box input::placeholder { color: var(--sub); opacity: 0.6; }
.search-box button {
display: flex;
align-items: center;
gap: 5px;
background: rgba(122, 162, 247, 0.1);
color: var(--accent-blue);
border: 1px solid var(--accent-blue);
border-radius: var(--radius);
padding: 0.5rem 1.2rem;
font: 500 0.75rem var(--mono);
cursor: pointer;
transition: var(--transition);
letter-spacing: 0.05em;
text-transform: uppercase;
}
.search-box button:hover { background: rgba(122, 162, 247, 0.2); box-shadow: 0 0 12px rgba(122, 162, 247, 0.3); color: #fff; }
.toast {
position: fixed;
bottom: 28px;
left: 50%;
transform: translate(-50%, 10px);
background: var(--card-bg);
color: var(--accent-blue);
padding: 10px 24px;
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
font-size: 0.72rem;
z-index: 9999;
pointer-events: none;
max-width: 90vw;
text-align: center;
transition: opacity 0.3s ease, transform 0.3s ease;
opacity: 0;
font-family: var(--mono);
letter-spacing: 0.04em;
text-transform: uppercase;
backdrop-filter: blur(4px);
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }
.shake { animation: shake 0.4s ease; }
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-5px); }
40% { transform: translateX(5px); }
60% { transform: translateX(-4px); }
80% { transform: translateX(3px); }
}
@media (max-width: 520px) {
.card { padding: 2rem 1rem; }
.digit { font-size: 4rem; }
.search-box button { padding: 0.45rem 0.8rem; font-size: 0.7rem; }
}
@media (max-width: 380px) {
.digit { font-size: 3rem; }
.title { font-size: 1rem; }
.desc { font-size: 0.75rem; }
}
</style>
</head>
<body>
<div class="card">
<div class="code-block">
<span class="digit">4</span>
<span class="divider-dot"></span>
<span class="digit">0</span>
<span class="divider-dot"></span>
<span class="digit">4</span>
</div>
<h1 class="title" id="titleText">Страница не найдена</h1>
<p class="desc" id="descText">Похоже, этой страницы больше нет или она перемещена. Проверьте адрес или воспользуйтесь поиском.</p>
<div class="search-section">
<span class="search-label" id="searchLabel">✦ Поиск через SearXNG</span>
<!-- Заменено на форму для нативной поддержки клавиши Enter -->
<form class="search-box" id="searchForm">
<input type="text" id="searchInput" placeholder="Введите запрос..." autocomplete="off">
<button type="submit" id="searchBtn">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<span id="searchBtnLabel">Найти</span>
</button>
</form>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
(function () {
const SEARXNG_BASE = 'https://searx.be/search?q=';
const browserLang = (navigator.language || 'en').split('-')[0];
const lang = ['ru', 'en', 'lt', 'es'].includes(browserLang) ? browserLang : 'ru';
const translations = {
ru: { title: 'Страница не найдена', desc: 'Похоже, этой страницы больше нет или она перемещена. Проверьте адрес или воспользуйтесь поиском.', searchLabel: '✦ Поиск через SearXNG', searchPlaceholder: 'Введите запрос...', searchBtn: 'Найти', empty: 'Введите поисковый запрос' },
en: { title: 'Page not found', desc: 'This page seems to be missing or moved. Double-check the URL or use the search.', searchLabel: '✦ Search via SearXNG', searchPlaceholder: 'Enter your query...', searchBtn: 'Search', empty: 'Enter a search query' },
lt: { title: 'Puslapis nerastas', desc: 'Atrodo, šio puslapio nebėra arba jis perkeltas. Patikrinkite adresą arba naudokitės paieška.', searchLabel: '✦ Paieška per SearXNG', searchPlaceholder: 'Įveskite užklausą...', searchBtn: 'Ieškoti', empty: 'Įveskite paieškos užklausą' },
es: { title: 'Página no encontrada', desc: 'Esta página parece haber desaparecido o haberse movido. Verifica la URL o usa la búsqueda.', searchLabel: '✦ Buscar con SearXNG', searchPlaceholder: 'Ingresa tu búsqueda...', searchBtn: 'Buscar', empty: 'Ingresa una búsqueda' }
};
const t = translations[lang] || translations.ru;
// Кэшируем DOM-элементы
const els = {
title: document.getElementById('titleText'),
desc: document.getElementById('descText'),
label: document.getElementById('searchLabel'),
input: document.getElementById('searchInput'),
btnLabel: document.getElementById('searchBtnLabel'),
form: document.getElementById('searchForm'),
toast: document.getElementById('toast')
};
// Применяем переводы
els.title.textContent = t.title;
els.desc.textContent = t.desc;
els.label.textContent = t.searchLabel;
els.input.placeholder = t.searchPlaceholder;
els.btnLabel.textContent = t.searchBtn;
let toastTimer;
function showToast(message, duration = 2400) {
els.toast.textContent = message;
els.toast.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => els.toast.classList.remove('show'), duration);
}
// Обработчик отправки формы (обрабатывает и клик по кнопке, и Enter)
els.form.addEventListener('submit', (e) => {
e.preventDefault();
const query = els.input.value.trim();
if (!query) {
els.input.focus();
// Перезапуск анимации
els.input.classList.remove('shake');
void els.input.offsetWidth; // Магия для перерисовки (reflow)
els.input.classList.add('shake');
showToast(t.empty);
return;
}
window.open(SEARXNG_BASE + encodeURIComponent(query), '_blank');
});
})();
</script>
</body>
</html>
+140
View File
@@ -0,0 +1,140 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="theme-color" content="#171512">
<title>Chess Party</title>
<meta name="description" content="Chess Party — шахматы против ИИ и с друзьями.">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Ctext y='52' font-size='52'%3E♞%3C/text%3E%3C/svg%3E">
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<main class="app">
<header class="topbar">
<div>
<div class="eyebrow">CHESS PARTY</div>
<h1>Шахматы</h1>
</div>
<div class="top-actions">
<button id="newGameBtn" class="btn primary">Новая игра</button>
<button id="flipBtn" class="btn">Повернуть</button>
</div>
</header>
<section class="home" id="homeView">
<div class="hero">
<div>
<span class="pill">Без лишних зависимостей</span>
<h2>Играй спокойно.<br>Ходы всегда на виду.</h2>
<p>Корректные подсказки фигур, последний ход, материал, история и стабильная ориентация доски.</p>
</div>
<div class="home-grid">
<button class="mode-card" data-mode="ai">
<strong>🤖 Против ИИ</strong>
<span>Локальная партия без сервера.</span>
</button>
<button class="mode-card" data-mode="party">
<strong>👥 Party</strong>
<span>Создай комнату или подключись к другу.</span>
</button>
</div>
</div>
<div class="panel party-panel hidden" id="partySetup">
<h3>Party</h3>
<div class="form-row">
<label>Имя<input id="nameInput" maxlength="24" placeholder="Guest"></label>
<label>Контроль времени
<select id="timeControl">
<option value="none">Без часов</option>
<option value="1+0">1 + 0</option>
<option value="3+0">3 + 0</option>
<option value="3+2">3 + 2</option>
<option value="5+0">5 + 0</option>
<option value="5+3">5 + 3</option>
<option value="10+0" selected>10 + 0</option>
<option value="15+10">15 + 10</option>
<option value="30+0">30 + 0</option>
<option value="30+20">30 + 20</option>
</select>
</label>
</div>
<div class="form-row">
<button id="createPartyBtn" class="btn primary">Создать комнату</button>
<input id="partyCodeInput" maxlength="6" placeholder="Код комнаты">
<button id="joinPartyBtn" class="btn">Войти</button>
</div>
<p id="setupError" class="error"></p>
</div>
</section>
<section class="game hidden" id="gameView">
<div class="game-layout">
<aside class="side-panel left-panel">
<div class="player-row">
<div class="avatar"></div>
<div><strong id="blackName">Чёрные</strong><span id="blackMeta"></span></div>
<div id="blackClock" class="clock"></div>
</div>
<div class="board-wrap">
<div id="board" class="board" aria-label="Шахматная доска"></div>
</div>
<div class="player-row">
<div class="avatar"></div>
<div><strong id="whiteName">Белые</strong><span id="whiteMeta"></span></div>
<div id="whiteClock" class="clock"></div>
</div>
</aside>
<aside class="right-column">
<div class="panel status-panel">
<div class="panel-title"><span>Партия</span><span id="turnBadge" class="pill">Ход белых</span></div>
<div id="gameStatus" class="status">Выберите фигуру.</div>
<div id="material" class="material"></div>
</div>
<div class="panel controls">
<button id="resignBtn" class="btn danger">Сдаться</button>
<button id="drawBtn" class="btn">Предложить ничью</button>
<button id="rematchBtn" class="btn primary">Реванш</button>
<button id="backHomeBtn" class="btn">Выйти</button>
</div>
<div class="panel moves-panel">
<div class="panel-title"><span>Ходы</span><span id="moveCount">0</span></div>
<div id="movesList" class="moves-list"></div>
</div>
<div class="panel chat-panel hidden" id="chatPanel">
<div class="panel-title">Чат</div>
<div id="chatMessages" class="chat-messages"></div>
<form id="chatForm" class="chat-form">
<input id="chatInput" maxlength="300" autocomplete="off" placeholder="Сообщение…">
<button class="btn primary" type="submit"></button>
</form>
</div>
</aside>
</div>
</section>
</main>
<div id="promotionModal" class="modal hidden">
<div class="modal-card">
<h3>Превращение пешки</h3>
<div class="promotion-grid">
<button data-promotion="q"><span>Ферзь</span></button>
<button data-promotion="r"><span>Ладья</span></button>
<button data-promotion="b"><span>Слон</span></button>
<button data-promotion="n"><span>Конь</span></button>
</div>
</div>
</div>
<div id="toast" class="toast"></div>
<script type="module" src="/app.js"></script>
</body>
</html>
+543
View File
@@ -0,0 +1,543 @@
const PIECE_VALUE = {
P: 100,
N: 320,
B: 330,
R: 500,
Q: 900,
K: 20_000,
};
const LEVELS = {
easy: {
depth: 1,
randomness: 0.35,
},
intermediate: {
depth: 2,
randomness: 0.08,
},
hard: {
depth: 3,
randomness: 0.01,
},
expert: {
depth: 4,
randomness: 0,
},
};
const CENTER = new Set([
"d4",
"e4",
"d5",
"e5",
]);
const EXTENDED_CENTER = new Set([
"c3",
"d3",
"e3",
"f3",
"c4",
"d4",
"e4",
"f4",
"c5",
"d5",
"e5",
"f5",
"c6",
"d6",
"e6",
"f6",
]);
function cloneEngine(engine) {
return new engine.constructor(
engine.getSnapshot(),
);
}
function opposite(color) {
return color === "w"
? "b"
: "w";
}
function squareName(x, y) {
return String.fromCharCode(
97 + x,
) + (8 - y);
}
function getAllMoves(engine, color) {
const moves = [];
for (let y = 0; y < 8; y++) {
for (let x = 0; x < 8; x++) {
const square =
squareName(x, y);
const piece =
engine.state.board[y][x];
if (
!piece ||
piece.color !== color
) {
continue;
}
const pieceMoves =
engine.getMovesFrom(
square,
);
for (const move of pieceMoves) {
moves.push(move);
}
}
}
return moves;
}
function evaluateMaterial(engine, color) {
let score = 0;
for (const row of engine.state.board) {
for (const piece of row) {
if (!piece) {
continue;
}
const value =
PIECE_VALUE[piece.type] ||
0;
score +=
piece.color === color
? value
: -value;
}
}
return score;
}
function evaluatePosition(engine, color) {
let score = 0;
for (let y = 0; y < 8; y++) {
for (let x = 0; x < 8; x++) {
const piece =
engine.state.board[y][x];
if (!piece) {
continue;
}
const square =
squareName(x, y);
const sign =
piece.color === color
? 1
: -1;
if (
CENTER.has(square)
) {
score +=
sign *
(piece.type === "P"
? 30
: 18);
}
if (
EXTENDED_CENTER.has(
square,
)
) {
score += sign * 6;
}
if (
piece.type === "N"
) {
const edgePenalty =
Math.abs(3.5 - x) +
Math.abs(3.5 - y);
score +=
sign *
Math.max(
0,
18 - edgePenalty * 4,
);
}
if (
piece.type === "P"
) {
const advancement =
piece.color === "w"
? 6 - y
: y - 1;
score +=
sign *
advancement *
8;
}
if (
piece.type === "B"
) {
score += sign * 4;
}
if (
piece.type === "R" &&
(y === 0 ||
y === 7)
) {
score += sign * 5;
}
}
}
return score;
}
function evaluateMobility(
engine,
color,
) {
const own =
getAllMoves(
engine,
color,
).length;
const enemy =
getAllMoves(
engine,
opposite(color),
).length;
return (own - enemy) * 3;
}
function evaluateKings(engine, color) {
let score = 0;
const status =
engine.state.status;
if (!status) {
return 0;
}
if (status.check) {
const turn =
engine.state.turn;
if (turn === color) {
score -= 35;
} else {
score += 35;
}
}
return score;
}
function evaluate(engine, color) {
const status =
engine.state.status;
if (
status?.phase ===
"checkmate"
) {
if (
status.winner === color
) {
return 1_000_000;
}
return -1_000_000;
}
if (
status?.phase === "draw"
) {
return 0;
}
return (
evaluateMaterial(
engine,
color,
) +
evaluatePosition(
engine,
color,
) +
evaluateMobility(
engine,
color,
) +
evaluateKings(
engine,
color,
)
);
}
function orderMoves(
engine,
moves,
) {
return [...moves].sort(
(a, b) => {
const captureA =
a.capture ? 1 : 0;
const captureB =
b.capture ? 1 : 0;
const promotionA =
a.promotion ? 1 : 0;
const promotionB =
b.promotion ? 1 : 0;
return (
captureB -
captureA ||
promotionB -
promotionA
);
},
);
}
function minimax(
engine,
depth,
alpha,
beta,
maximizingColor,
) {
const status =
engine.state.status;
if (
depth <= 0 ||
status?.phase ===
"checkmate" ||
status?.phase === "draw"
) {
return {
score: evaluate(
engine,
maximizingColor,
),
move: null,
};
}
const turn =
engine.state.turn;
const moves =
orderMoves(
engine,
getAllMoves(
engine,
turn,
),
);
if (!moves.length) {
return {
score: evaluate(
engine,
maximizingColor,
),
move: null,
};
}
const maximizing =
turn === maximizingColor;
let bestScore = maximizing
? -Infinity
: Infinity;
let bestMove = null;
for (const move of moves) {
const child =
cloneEngine(engine);
const result =
child.makeMove({
from: move.from,
to: move.to,
promotion:
move.promotion ||
null,
});
if (!result.ok) {
continue;
}
const resultNode =
minimax(
child,
depth - 1,
alpha,
beta,
maximizingColor,
);
const score =
resultNode.score;
if (maximizing) {
if (
score >
bestScore
) {
bestScore = score;
bestMove = move;
}
alpha = Math.max(
alpha,
bestScore,
);
} else {
if (
score <
bestScore
) {
bestScore = score;
bestMove = move;
}
beta = Math.min(
beta,
bestScore,
);
}
if (beta <= alpha) {
break;
}
}
return {
score: bestScore,
move: bestMove,
};
}
export const AI_LEVELS = LEVELS;
export function chooseComputerMove(
engine,
level = "intermediate",
) {
const config =
LEVELS[level] ||
LEVELS.intermediate;
const color =
engine.state.turn;
const moves =
getAllMoves(
engine,
color,
);
if (!moves.length) {
return null;
}
const ranked = [];
for (const move of moves) {
const child =
cloneEngine(engine);
const result =
child.makeMove({
from: move.from,
to: move.to,
promotion:
move.promotion ||
null,
});
if (!result.ok) {
continue;
}
const score =
minimax(
child,
Math.max(
0,
config.depth - 1,
),
-Infinity,
Infinity,
color,
).score;
ranked.push({
move,
score,
});
}
ranked.sort(
(a, b) =>
b.score - a.score,
);
if (!ranked.length) {
return moves[0];
}
if (
Math.random() <
config.randomness
) {
const count =
Math.min(
3,
ranked.length,
);
return ranked[
Math.floor(
Math.random() *
count,
)
].move;
}
return ranked[0].move;
}
+538
View File
@@ -0,0 +1,538 @@
import { ChessEngine, VALUES } from "/shared/chess-engine.js";
const PIECES = {
w: { k:"♔", q:"♕", r:"♖", b:"♗", n:"♘", p:"♙" },
b: { k:"♚", q:"♛", r:"♜", b:"♝", n:"♞", p:"♟" }
};
const $ = (id) => document.getElementById(id);
const state = {
mode: null,
engine: new ChessEngine(),
orientation: "w",
selected: null,
legal: [],
party: null,
clientId: localStorage.getItem("chess-client-id") || crypto.randomUUID(),
eventSource: null,
pendingPromotion: null,
aiBusy: false,
localGameOver: false
};
localStorage.setItem("chess-client-id", state.clientId);
function show(view) {
$("homeView").classList.toggle("hidden", view !== "home");
$("gameView").classList.toggle("hidden", view !== "game");
}
function toast(text) {
const el = $("toast");
el.textContent = text;
el.classList.add("show");
clearTimeout(toast.timer);
toast.timer = setTimeout(() => el.classList.remove("show"), 2200);
}
function resetSelection() {
state.selected = null;
state.legal = [];
}
function boardOrder() {
const files = "abcdefgh".split("");
const ranks = [8,7,6,5,4,3,2,1];
if (state.orientation === "b") {
files.reverse();
ranks.reverse();
}
return { files, ranks };
}
function snapshot() {
return state.mode === "party" ? state.party?.game : state.engine.getSnapshot();
}
function renderBoard() {
const snap = snapshot();
if (!snap) return;
const board = $("board");
board.innerHTML = "";
const { files, ranks } = boardOrder();
const legalMap = new Map(state.legal.map(m => [m.to, m]));
const last = snap.lastMove;
for (const rank of ranks) {
for (const file of files) {
const name = `${file}${rank}`;
const f = "abcdefgh".indexOf(file);
const r = rank - 1;
const el = document.createElement("button");
el.type = "button";
el.className = `square ${((f+r)%2===0) ? "light" : "dark"}`;
el.dataset.square = name;
if (state.selected === name) el.classList.add("selected");
if (state.selected && state.legal.some(m => m.to === name)) {
el.classList.add(snap.board[name] ? "capture" : "legal");
}
if (last?.from === name) el.classList.add("last-from");
if (last?.to === name) el.classList.add("last-to");
if (state.selected && state.legal.some(m => m.to === name)) el.classList.add("trajectory");
const piece = snap.board[name];
if (piece) {
const span = document.createElement("span");
span.className = "piece";
span.textContent = PIECES[piece.color][piece.type];
el.appendChild(span);
}
const fileLabel = document.createElement("span");
fileLabel.className = "coord file";
fileLabel.textContent = file;
el.appendChild(fileLabel);
const rankLabel = document.createElement("span");
rankLabel.className = "coord rank";
rankLabel.textContent = rank;
el.appendChild(rankLabel);
el.addEventListener("click", () => handleSquare(name));
board.appendChild(el);
}
}
}
function canUserMove() {
if (state.mode === "ai") return state.engine.state.turn === "w" && !state.aiBusy && !state.localGameOver;
if (state.mode === "party") {
return state.party?.you?.role === (state.party.game.turn === "w" ? "white" : "black")
&& !state.party.gameOverReason;
}
return false;
}
function handleSquare(name) {
if (!canUserMove()) return;
const snap = snapshot();
const piece = snap.board[name];
if (state.selected) {
const candidate = state.legal.filter(m => m.to === name);
if (candidate.length) {
if (candidate.some(m => m.promotion)) {
state.pendingPromotion = { from: state.selected, to: name };
$("promotionModal").classList.remove("hidden");
} else {
playMove(state.selected, name, candidate[0].promotion || null);
}
return;
}
}
if (piece && piece.color === snap.turn) {
state.selected = name;
if (state.mode === "ai") state.legal = state.engine.movesFrom(name);
else state.legal = state.partyEngineMoves(name);
} else {
resetSelection();
}
renderBoard();
updateStatus();
}
function partyEngineMoves(from) {
// The server is authoritative. The snapshot contains enough board state to
// calculate normal movement hints locally; the server validates the final move.
const temp = new ChessEngine();
temp.state = JSON.parse(JSON.stringify({
board: state.party.game.board,
turn: state.party.game.turn,
castling: state.party.game.castling,
ep: state.party.game.ep,
halfmove: state.party.game.halfmove,
fullmove: state.party.game.fullmove,
lastMove: state.party.game.lastMove
}));
return temp.movesFrom(from);
}
async function playMove(from, to, promotion = null) {
resetSelection();
$("promotionModal").classList.add("hidden");
if (state.mode === "ai") {
const result = state.engine.makeMove({ from, to, promotion: promotion || "q" });
if (!result.ok) {
toast(result.error);
renderBoard();
return;
}
renderAll();
if (!state.engine.getStatus().phase.match(/checkmate|draw/)) {
state.aiBusy = true;
renderAll();
setTimeout(aiMove, 220);
}
return;
}
try {
const response = await fetch("/api/party/move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
partyCode: state.party.code,
clientId: state.clientId,
from, to, promotion: promotion || "q"
})
});
const data = await response.json();
if (!data.ok) toast(data.error || "Ход не выполнен.");
if (data.party) applyParty(data.party);
} catch {
toast("Нет соединения с сервером.");
}
}
function aiMove() {
const moves = state.engine.legalMoves("b");
if (!moves.length) {
state.aiBusy = false;
renderAll();
return;
}
// Small deterministic evaluation: captures, promotion, checks, center.
let best = moves[0];
let bestScore = -Infinity;
for (const move of moves) {
const piece = state.engine.state.board[move.from];
const captured = state.engine.state.board[move.to];
let score = Math.random() * 0.15;
if (captured) score += VALUES[captured.type] * 2.5;
if (move.promotion) score += 9;
if (["d4","e4","d5","e5"].includes(move.to)) score += .5;
if (piece?.type === "p" && Math.abs(Number(move.to[1]) - Number(move.from[1])) === 2) score += .1;
if (score > bestScore) { bestScore = score; best = move; }
}
state.engine.makeMove({
from: best.from,
to: best.to,
promotion: best.promotion || "q"
});
state.aiBusy = false;
renderAll();
}
function renderMoves(snap) {
const list = $("movesList");
list.innerHTML = "";
const moves = snap.moves || [];
for (let i = 0; i < moves.length; i += 2) {
const row = document.createElement("div");
row.className = "move";
row.innerHTML = `
<span class="move-number">${Math.floor(i/2)+1}.</span>
<span>${escapeHtml(moves[i]?.san || "")}</span>
<span>${escapeHtml(moves[i+1]?.san || "")}</span>
`;
list.appendChild(row);
}
$("moveCount").textContent = moves.length;
list.scrollTop = list.scrollHeight;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({
"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#039;"
}[c]));
}
function renderMaterial(snap) {
const mat = snap.material || { captured:{w:[],b:[]},score:{w:0,b:0} };
const icon = (color, type) => PIECES[color][type];
$("material").innerHTML = `
<span>${mat.captured.w.map(p => icon("w",p)).join("") || "—"}</span>
<b>Δ ${mat.score.w - mat.score.b}</b>
<span>${mat.captured.b.map(p => icon("b",p)).join("") || "—"}</span>
`;
}
function updateStatus() {
const snap = snapshot();
if (!snap) return;
const status = snap.status || { phase:"playing", turn:snap.turn };
let text = state.selected ? "Выберите клетку назначения." : "Выберите фигуру.";
if (status.phase === "check") text = "Шах.";
if (status.phase === "checkmate") text = "Мат.";
if (status.phase === "draw") text = `Ничья: ${status.reason || "правило"}.`;
if (state.mode === "ai" && state.aiBusy) text = "ИИ думает…";
if (state.mode === "ai" && state.localGameOver) text = "Партия завершена сдачей.";
if (state.mode === "party" && state.party?.gameOverReason === "resign") text = "Партия завершена сдачей.";
if (state.mode === "party" && state.party?.gameOverReason === "timeout") text = "Время закончилось.";
if (state.mode === "party" && state.party?.drawOffer) text = `${state.party.drawOffer.name} предложил(а) ничью.`;
$("gameStatus").textContent = text;
$("turnBadge").textContent = snap.turn === "w" ? "Ход белых" : "Ход чёрных";
}
function renderPlayers() {
if (state.mode === "ai") {
$("whiteName").textContent = "Вы";
$("blackName").textContent = "ИИ";
$("whiteMeta").textContent = "Белые";
$("blackMeta").textContent = "Чёрные";
$("whiteClock").textContent = "—";
$("blackClock").textContent = "—";
return;
}
const p = state.party;
$("whiteName").textContent = p.players.white?.name || "Ожидание…";
$("blackName").textContent = p.players.black?.name || "Ожидание…";
$("whiteMeta").textContent = p.players.white?.connected ? "Белые • онлайн" : "Белые • офлайн";
$("blackMeta").textContent = p.players.black?.connected ? "Чёрные • онлайн" : "Чёрные • офлайн";
$("whiteClock").textContent = formatClock(p.clocks.white, p.timeControl.initial);
$("blackClock").textContent = formatClock(p.clocks.black, p.timeControl.initial);
}
function formatClock(seconds, enabled) {
if (!enabled) return "—";
const total = Math.max(0, Math.ceil(seconds));
const m = Math.floor(total / 60);
const s = total % 60;
return `${String(m).padStart(2,"0")}:${String(s).padStart(2,"0")}`;
}
function renderAll() {
renderBoard();
renderMoves(snapshot());
renderMaterial(snapshot());
renderPlayers();
updateStatus();
}
function startAI() {
closeParty();
state.mode = "ai";
state.engine = new ChessEngine();
state.orientation = "w";
state.localGameOver = false;
resetSelection();
$("chatPanel").classList.add("hidden");
show("game");
renderAll();
}
async function createParty() {
const name = $("nameInput").value.trim() || "Guest";
const timeControl = $("timeControl").value;
await enterParty("/api/party/create", { name, timeControl });
}
async function joinParty() {
const name = $("nameInput").value.trim() || "Guest";
const partyCode = $("partyCodeInput").value.trim().toUpperCase();
if (!partyCode) {
$("setupError").textContent = "Введите код комнаты.";
return;
}
await enterParty("/api/party/join", { name, partyCode, clientId: state.clientId });
}
async function enterParty(url, body) {
$("setupError").textContent = "";
try {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
const data = await response.json();
if (!data.ok) {
$("setupError").textContent = data.error || "Ошибка.";
return;
}
state.mode = "party";
state.party = data.party;
state.orientation = state.party.you?.role === "black" ? "b" : "w";
resetSelection();
$("chatPanel").classList.remove("hidden");
show("game");
connectEvents();
renderAll();
} catch {
$("setupError").textContent = "Сервер недоступен.";
}
}
function connectEvents() {
if (state.eventSource) state.eventSource.close();
state.eventSource = new EventSource(
`/api/party/events?partyCode=${encodeURIComponent(state.party.code)}&clientId=${encodeURIComponent(state.clientId)}`
);
state.eventSource.addEventListener("party", e => {
try { applyParty(JSON.parse(e.data)); } catch {}
});
state.eventSource.onerror = () => {};
}
function applyParty(party) {
state.party = party;
// Important: orientation is never changed by a received move.
// It only changes when the user presses "Повернуть".
renderAll();
renderChat();
}
function renderChat() {
if (state.mode !== "party") return;
const box = $("chatMessages");
box.innerHTML = "";
for (const msg of state.party.chat || []) {
const row = document.createElement("div");
row.className = "chat-message";
row.innerHTML = `<b>${escapeHtml(msg.name)}</b> <span>${escapeHtml(msg.text)}</span>`;
box.appendChild(row);
}
box.scrollTop = box.scrollHeight;
}
async function partyAction(endpoint) {
if (!state.party) return;
try {
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ partyCode: state.party.code, clientId: state.clientId })
});
const data = await response.json();
if (!data.ok) toast(data.error || "Операция не выполнена.");
if (data.party) applyParty(data.party);
} catch {
toast("Нет соединения с сервером.");
}
}
function newGame() {
if (state.mode === "ai") {
state.engine = new ChessEngine();
state.aiBusy = false;
state.localGameOver = false;
state.orientation = "w";
resetSelection();
renderAll();
return;
}
if (state.mode === "party") {
partyAction("/api/party/rematch");
return;
}
$("partySetup").classList.add("hidden");
show("home");
}
function closeParty() {
state.eventSource?.close();
state.eventSource = null;
state.party = null;
}
function backHome() {
if (state.mode === "party") {
partyAction("/api/party/leave");
closeParty();
}
state.mode = null;
state.engine = new ChessEngine();
resetSelection();
$("partySetup").classList.add("hidden");
show("home");
}
$("newGameBtn").addEventListener("click", newGame);
$("flipBtn").addEventListener("click", () => {
state.orientation = state.orientation === "w" ? "b" : "w";
renderBoard();
});
$("backHomeBtn").addEventListener("click", backHome);
$("resignBtn").addEventListener("click", () => {
if (state.mode === "party") partyAction("/api/party/resign");
else {
const snap = state.engine.getSnapshot();
if (!snap.status.phase.match(/checkmate|draw/)) {
state.localGameOver = true;
state.aiBusy = false;
resetSelection();
renderAll();
toast("Вы сдались.");
}
}
});
$("drawBtn").addEventListener("click", () => {
if (state.mode === "party") partyAction("/api/party/draw");
else toast("Предложение ничьей доступно в Party.");
});
$("rematchBtn").addEventListener("click", newGame);
document.querySelectorAll(".mode-card").forEach(btn => {
btn.addEventListener("click", () => {
if (btn.dataset.mode === "ai") startAI();
else $("partySetup").classList.remove("hidden");
});
});
$("createPartyBtn").addEventListener("click", createParty);
$("joinPartyBtn").addEventListener("click", joinParty);
$("partyCodeInput").addEventListener("input", e => {
e.target.value = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g,"");
});
$("chatForm").addEventListener("submit", async e => {
e.preventDefault();
if (!state.party) return;
const input = $("chatInput");
const text = input.value.trim();
if (!text) return;
input.value = "";
try {
await fetch("/api/party/chat", {
method:"POST",
headers:{"Content-Type":"application/json"},
body:JSON.stringify({
partyCode:state.party.code,
clientId:state.clientId,
text
})
});
} catch {
toast("Не удалось отправить сообщение.");
}
});
document.querySelectorAll("[data-promotion]").forEach(btn => {
btn.addEventListener("click", () => {
const p = state.pendingPromotion;
if (!p) return;
state.pendingPromotion = null;
playMove(p.from, p.to, btn.dataset.promotion);
});
});
show("home");
+113
View File
@@ -0,0 +1,113 @@
:root{
--bg:#171512;
--panel:#211e1a;
--panel2:#28231e;
--text:#f2eee7;
--muted:#a9a197;
--line:#3b342d;
--accent:#c79a5b;
--accent2:#e0bc84;
--light:#e7d8c0;
--dark:#8d6847;
--danger:#b9685e;
--shadow:0 18px 60px rgba(0,0,0,.28);
}
*{box-sizing:border-box}
html,body{margin:0;min-height:100%;background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,Segoe UI,sans-serif}
button,input,select{font:inherit}
button{cursor:pointer}
body{padding:20px}
.app{max-width:1180px;margin:auto}
.topbar{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:22px}
.eyebrow{font-size:11px;letter-spacing:.2em;color:var(--accent2)}
h1,h2,h3,p{margin:0}
h1{font-size:25px;margin-top:4px}
h2{font-size:clamp(36px,6vw,72px);line-height:.96;letter-spacing:-.045em;margin:20px 0}
h3{font-size:20px}
.top-actions,.controls,.form-row{display:flex;gap:10px;flex-wrap:wrap}
.btn{border:1px solid var(--line);background:var(--panel);color:var(--text);padding:10px 14px;border-radius:10px;min-height:42px}
.btn:hover{border-color:#635548;background:var(--panel2)}
.btn.primary{background:var(--accent);border-color:var(--accent);color:#1a1510}
.btn.danger{background:transparent;border-color:#70423e;color:#e3aaa3}
.panel,.hero{border:1px solid var(--line);background:var(--panel);box-shadow:var(--shadow);border-radius:16px}
.hero{padding:clamp(24px,5vw,54px);display:grid;grid-template-columns:1.1fr .9fr;gap:35px;align-items:end}
.hero p{max-width:650px;color:var(--muted);font-size:16px;line-height:1.6}
.pill{display:inline-flex;align-items:center;padding:5px 9px;border:1px solid var(--line);border-radius:999px;color:var(--accent2);font-size:12px}
.home-grid{display:grid;gap:10px}
.mode-card{display:flex;flex-direction:column;text-align:left;gap:6px;padding:18px;border:1px solid var(--line);background:var(--panel2);color:var(--text);border-radius:12px}
.mode-card:hover{border-color:var(--accent)}
.mode-card span{color:var(--muted);font-size:13px}
.party-panel{margin-top:14px;padding:20px}
.form-row{align-items:end;margin-top:14px}
label{display:grid;gap:6px;color:var(--muted);font-size:12px;min-width:180px}
input,select{height:42px;padding:0 12px;border-radius:9px;border:1px solid var(--line);background:#171512;color:var(--text);outline:none}
input:focus,select:focus{border-color:var(--accent)}
.error{color:#df9d95;margin-top:10px;min-height:18px}
.game-layout{display:grid;grid-template-columns:minmax(0,760px) 330px;gap:18px;align-items:start}
.left-panel{padding:14px}
.right-column{display:grid;gap:12px}
.player-row{display:grid;grid-template-columns:42px 1fr auto;gap:10px;align-items:center;padding:7px 4px 12px}
.player-row:last-child{padding:12px 4px 7px}
.avatar{width:36px;height:36px;border:1px solid var(--line);display:grid;place-items:center;border-radius:9px;background:var(--panel2)}
.player-row strong,.player-row span{display:block}
.player-row span{font-size:12px;color:var(--muted);margin-top:2px}
.clock{font-variant-numeric:tabular-nums;font-size:24px;font-weight:700}
.board-wrap{width:100%;aspect-ratio:1;max-width:760px;margin:auto}
.board{width:100%;height:100%;display:grid;grid-template-columns:repeat(8,1fr);grid-template-rows:repeat(8,1fr);overflow:hidden;border-radius:8px;user-select:none;touch-action:manipulation}
.square{position:relative;display:grid;place-items:center;font-size:clamp(27px,7vw,70px);line-height:1}
.square.light{background:var(--light);color:#251e18}
.square.dark{background:var(--dark);color:#17120f}
.square.last-from::after,.square.last-to::after{content:"";position:absolute;inset:0;background:rgba(230,184,92,.26);pointer-events:none}
.square.selected{box-shadow:inset 0 0 0 4px rgba(255,239,202,.75)}
.square.legal::before{content:"";position:absolute;width:18%;height:18%;border-radius:50%;background:rgba(40,30,20,.35);z-index:1}
.square.capture::before{content:"";position:absolute;inset:8%;border:5px solid rgba(70,40,20,.42);border-radius:50%;z-index:1}
.square.trajectory{box-shadow:inset 0 0 0 3px rgba(255,245,210,.28)}
.piece{position:relative;z-index:2;font-family:"DejaVu Sans","Segoe UI Symbol",serif;text-shadow:0 1px 0 rgba(0,0,0,.15)}
.coord{position:absolute;font-size:9px;font-weight:700;opacity:.7;z-index:3;pointer-events:none}
.coord.file{right:4px;bottom:2px}
.coord.rank{left:4px;top:2px}
.panel{padding:14px}
.panel-title{display:flex;justify-content:space-between;align-items:center;gap:10px;font-weight:700}
.status{color:var(--muted);font-size:13px;line-height:1.5;margin-top:10px}
.material{display:flex;justify-content:space-between;margin-top:12px;font-size:14px;color:var(--muted);min-height:23px}
.material span{letter-spacing:2px}
.controls{display:grid;grid-template-columns:1fr 1fr}
.controls .btn{width:100%}
.moves-panel{min-height:180px}
.moves-list{display:grid;grid-template-columns:1fr 1fr;gap:2px 10px;margin-top:10px;max-height:260px;overflow:auto}
.move{display:grid;grid-template-columns:32px 1fr 1fr;padding:6px 7px;border-radius:7px;color:var(--muted);font-size:13px}
.move:nth-child(odd){background:#1a1714}
.move-number{color:#70675e}
.chat-messages{height:180px;overflow:auto;margin-top:10px;display:grid;align-content:start;gap:8px}
.chat-message{font-size:13px}
.chat-message b{color:var(--accent2)}
.chat-message span{color:var(--muted)}
.chat-form{display:flex;gap:7px;margin-top:10px}
.chat-form input{min-width:0;flex:1}
.hidden{display:none!important}
.modal{position:fixed;inset:0;background:rgba(0,0,0,.62);display:grid;place-items:center;padding:20px;z-index:20}
.modal-card{background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:22px;width:min(430px,100%)}
.promotion-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-top:16px}
.promotion-grid button{border:1px solid var(--line);background:var(--panel2);color:var(--text);border-radius:10px;padding:12px;font-size:34px}
.promotion-grid span{display:block;font-size:11px;color:var(--muted)}
.toast{position:fixed;left:50%;bottom:24px;transform:translate(-50%,20px);opacity:0;pointer-events:none;background:#2a251f;border:1px solid var(--line);padding:10px 14px;border-radius:9px;transition:.2s;z-index:30}
.toast.show{opacity:1;transform:translate(-50%,0)}
@media(max-width:920px){
body{padding:10px}
.hero{grid-template-columns:1fr}
.game-layout{grid-template-columns:1fr}
.right-column{grid-template-columns:1fr 1fr}
.moves-panel,.chat-panel{grid-column:span 2}
}
@media(max-width:560px){
.topbar{align-items:flex-start}
.top-actions{justify-content:flex-end}
.top-actions .btn{padding:8px 10px}
.game-layout{gap:10px}
.left-panel{padding:7px}
.right-column{grid-template-columns:1fr}
.moves-panel,.chat-panel{grid-column:auto}
.controls{grid-template-columns:1fr 1fr}
.square{font-size:clamp(24px,10vw,46px)}
}
+535
View File
@@ -0,0 +1,535 @@
const FILES = "abcdefgh";
const PIECES = ["p", "n", "b", "r", "q", "k"];
const VALUES = { p: 1, n: 3, b: 3, r: 5, q: 9, k: 0 };
function square(file, rank) {
return `${FILES[file]}${rank + 1}`;
}
function parseSquare(s) {
if (!/^[a-h][1-8]$/.test(s || "")) return null;
return { file: FILES.indexOf(s[0]), rank: Number(s[1]) - 1 };
}
function other(color) {
return color === "w" ? "b" : "w";
}
function cloneBoard(board) {
return Object.fromEntries(Object.entries(board).map(([s, p]) => [s, { ...p }]));
}
function cloneState(state) {
return {
board: cloneBoard(state.board),
turn: state.turn,
castling: {
w: { ...state.castling.w },
b: { ...state.castling.b }
},
ep: state.ep,
halfmove: state.halfmove,
fullmove: state.fullmove,
lastMove: state.lastMove ? { ...state.lastMove } : null
};
}
function initialBoard() {
const board = {};
const back = "rnbqkbnr";
for (let f = 0; f < 8; f++) {
board[square(f, 0)] = { color: "w", type: back[f] };
board[square(f, 1)] = { color: "w", type: "p" };
board[square(f, 6)] = { color: "b", type: "p" };
board[square(f, 7)] = { color: "b", type: back[f] };
}
return board;
}
export class ChessEngine {
constructor() {
this.reset();
}
reset() {
this.state = {
board: initialBoard(),
turn: "w",
castling: {
w: { k: true, q: true },
b: { k: true, q: true }
},
ep: null,
halfmove: 0,
fullmove: 1,
lastMove: null
};
this.history = [];
this.positions = new Map([[this.positionKey(this.state), 1]]);
}
positionKey(state) {
const board = Object.keys(state.board).sort().map(s => {
const p = state.board[s];
return `${s}${p.color}${p.type}`;
}).join(",");
return `${board}|${state.turn}|${state.castling.w.k ? "K" : ""}${state.castling.w.q ? "Q" : ""}${state.castling.b.k ? "k" : ""}${state.castling.b.q ? "q" : ""}|${state.ep || "-"}`;
}
getPiece(s) {
return this.state.board[s] || null;
}
movesFrom(from) {
const p = this.state.board[from];
if (!p || p.color !== this.state.turn) return [];
return this.legalMoves(this.state.turn).filter(m => m.from === from);
}
legalMoves(color = this.state.turn) {
const pseudo = this.pseudoMoves(this.state, color);
const legal = [];
for (const move of pseudo) {
const next = this.applyToClone(this.state, move);
if (!this.inCheck(next, color)) legal.push(move);
}
return legal;
}
pseudoMoves(state, color) {
const result = [];
for (const [from, piece] of Object.entries(state.board)) {
if (piece.color !== color) continue;
const pos = parseSquare(from);
if (!pos) continue;
if (piece.type === "p") this.pawnMoves(state, from, piece, pos, result);
else if (piece.type === "n") this.knightMoves(state, from, piece, pos, result);
else if (piece.type === "b") this.slideMoves(state, from, piece, pos, result, [[1,1],[1,-1],[-1,1],[-1,-1]]);
else if (piece.type === "r") this.slideMoves(state, from, piece, pos, result, [[1,0],[-1,0],[0,1],[0,-1]]);
else if (piece.type === "q") this.slideMoves(state, from, piece, pos, result, [[1,1],[1,-1],[-1,1],[-1,-1],[1,0],[-1,0],[0,1],[0,-1]]);
else if (piece.type === "k") this.kingMoves(state, from, piece, pos, result);
}
return result;
}
pushMove(state, result, from, to, promotion = null, special = null) {
const target = state.board[to];
if (target?.color === state.board[from]?.color) return;
if (target?.type === "k") return;
result.push({
from, to,
promotion,
special,
capture: Boolean(target) || special === "ep"
});
}
pawnMoves(state, from, piece, pos, result) {
const dir = piece.color === "w" ? 1 : -1;
const startRank = piece.color === "w" ? 1 : 6;
const promotionRank = piece.color === "w" ? 7 : 0;
const oneRank = pos.rank + dir;
if (oneRank >= 0 && oneRank <= 7) {
const one = square(pos.file, oneRank);
if (!state.board[one]) {
if (oneRank === promotionRank) {
for (const promotion of ["q","r","b","n"]) this.pushMove(state, result, from, one, promotion);
} else {
this.pushMove(state, result, from, one);
if (pos.rank === startRank) {
const two = square(pos.file, pos.rank + dir * 2);
if (!state.board[two]) this.pushMove(state, result, from, two, null, "double");
}
}
}
}
for (const df of [-1, 1]) {
const f = pos.file + df;
const r = pos.rank + dir;
if (f < 0 || f > 7 || r < 0 || r > 7) continue;
const to = square(f, r);
const target = state.board[to];
if (target && target.color !== piece.color && target.type !== "k") {
if (r === promotionRank) {
for (const promotion of ["q","r","b","n"]) this.pushMove(state, result, from, to, promotion);
} else {
this.pushMove(state, result, from, to);
}
} else if (state.ep === to) {
this.pushMove(state, result, from, to, null, "ep");
}
}
}
knightMoves(state, from, piece, pos, result) {
const jumps = [[1,2],[2,1],[-1,2],[-2,1],[1,-2],[2,-1],[-1,-2],[-2,-1]];
for (const [df, dr] of jumps) {
const f = pos.file + df, r = pos.rank + dr;
if (f >= 0 && f <= 7 && r >= 0 && r <= 7) this.pushMove(state, result, from, square(f,r));
}
}
slideMoves(state, from, piece, pos, result, dirs) {
for (const [df, dr] of dirs) {
let f = pos.file + df, r = pos.rank + dr;
while (f >= 0 && f <= 7 && r >= 0 && r <= 7) {
const to = square(f, r);
const target = state.board[to];
if (!target) this.pushMove(state, result, from, to);
else {
if (target.color !== piece.color && target.type !== "k") this.pushMove(state, result, from, to);
break;
}
f += df; r += dr;
}
}
}
kingMoves(state, from, piece, pos, result) {
for (let df = -1; df <= 1; df++) {
for (let dr = -1; dr <= 1; dr++) {
if (!df && !dr) continue;
const f = pos.file + df, r = pos.rank + dr;
if (f >= 0 && f <= 7 && r >= 0 && r <= 7) {
this.pushMove(state, result, from, square(f,r));
}
}
}
const rank = piece.color === "w" ? 0 : 7;
const enemy = other(piece.color);
if (pos.file === 4 && pos.rank === rank && !this.inCheck(state, piece.color)) {
if (state.castling[piece.color].k &&
!state.board[square(5,rank)] &&
!state.board[square(6,rank)] &&
state.board[square(7,rank)]?.type === "r" &&
state.board[square(7,rank)]?.color === piece.color) {
const through = square(5,rank), to = square(6,rank);
if (!this.isAttacked(state, through, enemy) && !this.isAttacked(state, to, enemy)) {
this.pushMove(state, result, from, to, null, "castle-k");
}
}
if (state.castling[piece.color].q &&
!state.board[square(1,rank)] &&
!state.board[square(2,rank)] &&
!state.board[square(3,rank)] &&
state.board[square(0,rank)]?.type === "r" &&
state.board[square(0,rank)]?.color === piece.color) {
const through = square(3,rank), to = square(2,rank);
if (!this.isAttacked(state, through, enemy) && !this.isAttacked(state, to, enemy)) {
this.pushMove(state, result, from, to, null, "castle-q");
}
}
}
}
applyToClone(state, move) {
const next = cloneState(state);
const piece = next.board[move.from];
if (!piece) return next;
delete next.board[move.from];
if (move.special === "ep") {
const to = parseSquare(move.to);
const captured = square(to.file, to.rank + (piece.color === "w" ? -1 : 1));
delete next.board[captured];
}
const captured = next.board[move.to];
delete next.board[move.to];
const moved = {
color: piece.color,
type: move.promotion || piece.type
};
next.board[move.to] = moved;
if (piece.type === "k") {
next.castling[piece.color].k = false;
next.castling[piece.color].q = false;
if (move.special === "castle-k") {
const rank = piece.color === "w" ? 0 : 7;
delete next.board[square(7,rank)];
next.board[square(5,rank)] = { color: piece.color, type: "r" };
}
if (move.special === "castle-q") {
const rank = piece.color === "w" ? 0 : 7;
delete next.board[square(0,rank)];
next.board[square(3,rank)] = { color: piece.color, type: "r" };
}
}
if (piece.type === "r") {
if (move.from === "a1") next.castling.w.q = false;
if (move.from === "h1") next.castling.w.k = false;
if (move.from === "a8") next.castling.b.q = false;
if (move.from === "h8") next.castling.b.k = false;
}
if (captured?.type === "r") {
if (move.to === "a1") next.castling.w.q = false;
if (move.to === "h1") next.castling.w.k = false;
if (move.to === "a8") next.castling.b.q = false;
if (move.to === "h8") next.castling.b.k = false;
}
next.ep = null;
if (piece.type === "p") {
const a = parseSquare(move.from), b = parseSquare(move.to);
if (Math.abs(b.rank - a.rank) === 2) {
next.ep = square(a.file, (a.rank + b.rank) / 2);
}
}
next.halfmove = piece.type === "p" || move.capture ? 0 : next.halfmove + 1;
if (piece.color === "b") next.fullmove += 1;
next.turn = other(piece.color);
return next;
}
findKing(state, color) {
for (const [s, p] of Object.entries(state.board)) {
if (p.color === color && p.type === "k") return s;
}
return null;
}
inCheck(state, color) {
const king = this.findKing(state, color);
return !king || this.isAttacked(state, king, other(color));
}
isAttacked(state, target, byColor) {
const t = parseSquare(target);
if (!t) return false;
for (const [from, piece] of Object.entries(state.board)) {
if (piece.color !== byColor) continue;
const p = parseSquare(from);
const df = t.file - p.file;
const dr = t.rank - p.rank;
if (piece.type === "p") {
const dir = byColor === "w" ? 1 : -1;
if (dr === dir && Math.abs(df) === 1) return true;
} else if (piece.type === "n") {
if ((Math.abs(df) === 1 && Math.abs(dr) === 2) || (Math.abs(df) === 2 && Math.abs(dr) === 1)) return true;
} else if (piece.type === "k") {
if (Math.max(Math.abs(df), Math.abs(dr)) === 1) return true;
} else {
const diagonal = Math.abs(df) === Math.abs(dr);
const straight = df === 0 || dr === 0;
const allowed = piece.type === "b" ? diagonal : piece.type === "r" ? straight : diagonal || straight;
if (!allowed) continue;
const sf = Math.sign(df), sr = Math.sign(dr);
let f = p.file + sf, r = p.rank + sr;
let clear = true;
while (f !== t.file || r !== t.rank) {
if (state.board[square(f,r)]) { clear = false; break; }
f += sf; r += sr;
}
if (clear) return true;
}
}
return false;
}
sanForMove(move, legalBefore = null) {
const piece = this.state.board[move.from];
if (!piece) return "";
if (move.special === "castle-k") return this.suffixAfter(move, "O-O");
if (move.special === "castle-q") return this.suffixAfter(move, "O-O-O");
const legal = legalBefore || this.legalMoves(this.state);
let san = piece.type === "p" ? "" : piece.type.toUpperCase();
const same = legal.filter(m =>
m.to === move.to &&
m.from !== move.from &&
this.state.board[m.from]?.type === piece.type
);
if (same.length) {
const from = parseSquare(move.from);
const fileConflict = same.some(m => parseSquare(m.from).file === from.file);
san += fileConflict ? move.from : move.from[0];
}
if (move.capture) {
if (piece.type === "p") san += move.from[0];
san += "x";
}
san += move.to;
if (move.promotion) san += `=${move.promotion.toUpperCase()}`;
return this.suffixAfter(move, san);
}
suffixAfter(move, san) {
const next = this.applyToClone(this.state, move);
if (this.inCheck(next, next.turn)) {
const replies = this.legalMovesFromState(next, next.turn);
return san + (replies.length ? "+" : "#");
}
return san;
}
legalMovesFromState(state, color) {
return this.pseudoMoves(state, color).filter(m => !this.inCheck(this.applyToClone(state, m), color));
}
makeMove(input) {
const from = String(input?.from || "").toLowerCase();
const to = String(input?.to || "").toLowerCase();
const promotion = String(input?.promotion || "q").toLowerCase();
const legal = this.legalMoves(this.state);
const move = legal.find(m =>
m.from === from &&
m.to === to &&
(m.promotion ? m.promotion === promotion : !m.promotion)
);
if (!move) return { ok: false, error: "Недопустимый ход." };
const san = this.sanForMove(move, legal);
const piece = this.state.board[from];
const capturedPiece =
move.special === "ep"
? { type: "p", color: other(piece.color) }
: this.state.board[to] || null;
this.history.push({
...move,
san,
piece: { ...piece },
captured: capturedPiece ? { ...capturedPiece } : null,
before: cloneState(this.state)
});
this.state = this.applyToClone(this.state, move);
this.state.lastMove = {
from,
to,
san,
piece: { ...piece },
captured: capturedPiece ? { ...capturedPiece } : null
};
const key = this.positionKey(this.state);
this.positions.set(key, (this.positions.get(key) || 0) + 1);
return {
ok: true,
move: this.state.lastMove,
status: this.getStatus()
};
}
undo() {
const last = this.history.pop();
if (!last) return false;
this.state = last.before;
this.positions = new Map([[this.positionKey(this.state), 1]]);
for (const h of this.history) {
const key = this.positionKey(h.before);
this.positions.set(key, (this.positions.get(key) || 0) + 1);
}
return true;
}
getStatus() {
const color = this.state.turn;
const moves = this.legalMoves(color);
const check = this.inCheck(this.state, color);
if (!moves.length && check) return { phase: "checkmate", turn: color, winner: other(color) };
if (!moves.length) return { phase: "draw", reason: "stalemate", turn: color };
if (this.state.halfmove >= 100) return { phase: "draw", reason: "50-move", turn: color };
const key = this.positionKey(this.state);
if ((this.positions.get(key) || 0) >= 3) return { phase: "draw", reason: "threefold", turn: color };
if (this.insufficientMaterial()) return { phase: "draw", reason: "insufficient-material", turn: color };
if (check) return { phase: "check", turn: color };
return { phase: "playing", turn: color };
}
insufficientMaterial() {
const pieces = Object.values(this.state.board);
const nonKings = pieces.filter(p => p.type !== "k");
if (!nonKings.length) return true;
if (nonKings.length === 1 && (nonKings[0].type === "b" || nonKings[0].type === "n")) return true;
if (nonKings.length === 2 && nonKings.every(p => p.type === "b")) {
const bishops = Object.entries(this.state.board)
.filter(([, p]) => p.type === "b")
.map(([s, p]) => ({ s, color: p.color }));
if (bishops.length === 2) {
const colors = bishops.map(({ s }) => {
const p = parseSquare(s);
return (p.file + p.rank) % 2;
});
if (colors[0] === colors[1]) return true;
}
}
return false;
}
material() {
const captured = { w: [], b: [] };
for (const h of this.history) {
if (h.captured) captured[h.piece.color].push(h.captured.type);
}
const sort = a => a.sort((x,y) => VALUES[y] - VALUES[x]);
sort(captured.w); sort(captured.b);
const score = {
w: captured.b.reduce((n,p) => n + VALUES[p], 0),
b: captured.w.reduce((n,p) => n + VALUES[p], 0)
};
return { captured, score };
}
getSnapshot() {
return {
board: cloneBoard(this.state.board),
turn: this.state.turn,
castling: {
w: { ...this.state.castling.w },
b: { ...this.state.castling.b }
},
ep: this.state.ep,
halfmove: this.state.halfmove,
fullmove: this.state.fullmove,
lastMove: this.state.lastMove ? { ...this.state.lastMove } : null,
moves: this.history.map(h => ({
number: this.history.indexOf(h) + 1,
from: h.from,
to: h.to,
san: h.san,
color: h.piece.color,
piece: h.piece.type,
capture: Boolean(h.captured)
})),
material: this.material(),
status: this.getStatus()
};
}
}
export { FILES, VALUES };
Binary file not shown.
+12
View File
@@ -0,0 +1,12 @@
{
"name": "college-chess-party",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "college-chess-party",
"version": "1.0.0"
}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "chess-party",
"version": "2.0.0",
"private": true,
"type": "module",
"description": "Chess Party — AI and multiplayer chess",
"scripts": {
"start": "node server.js"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"express": "^4.21.2"
}
}
+477
View File
@@ -0,0 +1,477 @@
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}`);
});