Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52edd18b3e | ||
|
|
299f47b1b3 | ||
|
|
d0e0e3274a | ||
|
|
d38ae7cbe6 | ||
|
|
7c94426848 | ||
|
|
1fbdd1317c | ||
|
|
1f41362338 | ||
|
|
2dfea24f42 | ||
|
|
039b64f9e5 | ||
|
|
6ed96533b0 | ||
|
|
8dcc964d04 | ||
|
|
226ad23f7b | ||
|
|
5f75752b26 | ||
|
|
5e57feb66f | ||
|
|
4e6e2fc9e2 | ||
|
|
e6e57a3cc0 | ||
|
|
0720bcc229 | ||
|
|
093b4ac46f | ||
|
|
3b02071fe7 | ||
|
|
728da46b10 | ||
|
|
328fe6b417 | ||
|
|
0aba79015d | ||
|
|
3d7bfd1797 | ||
|
|
debf3bbc68 | ||
|
|
1d87e4539b | ||
|
|
11a1b0cb41 | ||
|
|
83500d2d86 | ||
|
|
da311edec3 | ||
|
|
34606b50cc | ||
|
|
0a8475d245 |
@@ -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>
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 => ({
|
||||||
|
"&":"&","<":"<",">":">",'"':""","'":"'"
|
||||||
|
}[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");
|
||||||
|
|
||||||
@@ -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)}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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.
Generated
+12
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
-304
@@ -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>
|
|
||||||
@@ -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}`);
|
||||||
|
});
|
||||||
|
|
||||||
Reference in New Issue
Block a user