Files
website-collection/chess/public/js/app.js
T

2371 lines
49 KiB
JavaScript
Raw Normal View History

2026-08-22 17:37:25 +00:00
import {
ChessEngine,
ChessRules,
} from "/shared/chess-engine.js";
import {
chooseComputerMove,
} from "/js/ai.js";
/* =========================================================
PIECES
========================================================= */
const PIECE_GLYPHS = {
w: {
K: "♔",
Q: "♕",
R: "♖",
B: "♗",
N: "♘",
P: "♙",
},
b: {
K: "♚",
Q: "♛",
R: "♜",
B: "♝",
N: "♞",
P: "♟",
},
};
/* =========================================================
STORAGE
========================================================= */
const STORAGE_KEYS = {
name: "chess-party:name",
partyIds: "chess-party:party-ids",
};
2026-08-22 17:51:45 +00:00
/* =========================================================
TIME CONTROLS
========================================================= */
const TIME_CONTROLS = {
none: 0,
"1+0": 60,
"3+0": 180,
"3+2": 180,
"5+0": 300,
"5+3": 300,
"10+0": 600,
"15+10": 900,
"30+0": 1800,
"30+20": 1800,
};
2026-08-22 17:37:25 +00:00
/* =========================================================
ELEMENTS
========================================================= */
const elements = {
2026-08-22 17:51:45 +00:00
modeBadge: document.querySelector("#modeBadge"),
connectionBadge: document.querySelector("#connectionBadge"),
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
board: document.querySelector("#board"),
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
statusMessage: document.querySelector("#statusMessage"),
turnBadge: document.querySelector("#turnBadge"),
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
modeAiButton: document.querySelector("#modeAiButton"),
modePartyButton: document.querySelector("#modePartyButton"),
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
playerNameInput: document.querySelector("#playerNameInput"),
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
aiControls: document.querySelector("#aiControls"),
partyControls: document.querySelector("#partyControls"),
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
aiLevelSelect: document.querySelector("#aiLevelSelect"),
playerColorSelect: document.querySelector("#playerColorSelect"),
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
aiTimeControl: document.querySelector("#aiClockSelect"),
partyTimeControl: document.querySelector("#partyClockSelect"),
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
startAiButton: document.querySelector("#startAiButton"),
2026-08-22 18:20:15 +00:00
newGameToolbarButton: document.querySelector("#newGameToolbarButton"),
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
createPartyButton: document.querySelector("#createPartyButton"),
2026-08-22 18:20:15 +00:00
newPartyGameButton: document.querySelector("#newPartyGameButton"),
2026-08-22 17:51:45 +00:00
partyCodeInput: document.querySelector("#partyCodeInput"),
joinPartyButton: document.querySelector("#joinPartyButton"),
leavePartyButton: document.querySelector("#leavePartyButton"),
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
partySummary: document.querySelector("#partySummary"),
copyInviteButton: document.querySelector("#copyInviteButton"),
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
flipBoardButton: document.querySelector("#flipBoardButton"),
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
whitePlayerLabel: document.querySelector("#whitePlayerLabel"),
blackPlayerLabel: document.querySelector("#blackPlayerLabel"),
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
rosterWhite: document.querySelector("#rosterWhite"),
rosterBlack: document.querySelector("#rosterBlack"),
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
spectatorList: document.querySelector("#spectatorList"),
spectatorCount: document.querySelector("#spectatorCount"),
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
whiteClock: document.querySelector("#whiteClock"),
blackClock: document.querySelector("#blackClock"),
2026-08-22 17:37:25 +00:00
2026-08-22 18:20:15 +00:00
capturedByWhite: document.querySelector("#capturedByWhite"),
capturedByBlack: document.querySelector("#capturedByBlack"),
moveHistory: document.querySelector("#moveHistory"),
2026-08-22 17:51:45 +00:00
promotionDialog: document.querySelector("#promotionDialog"),
promotionOptions: document.querySelector("#promotionOptions"),
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
toastHost: document.querySelector("#toastHost"),
2026-08-22 17:37:25 +00:00
};
/* =========================================================
STATE
========================================================= */
const appState = {
mode: "ai",
2026-08-22 17:51:45 +00:00
engine: new ChessEngine(),
2026-08-22 17:37:25 +00:00
orientation: "w",
2026-08-22 17:51:45 +00:00
selectedSquare: null,
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
aiLevel: "intermediate",
playerColor: "w",
aiTimeControl: "10+0",
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
isAiThinking: false,
aiMoveTimer: null,
aiClockTimer: null,
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
pendingPromotionMoves: null,
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
playerName: "Guest",
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
party: null,
partySnapshot: null,
partyStream: null,
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
gameOver: false,
gameOverMessage: null,
2026-08-22 17:37:25 +00:00
localClocks: {
2026-08-22 17:51:45 +00:00
white: 0,
black: 0,
2026-08-22 17:37:25 +00:00
running: false,
turn: "w",
2026-08-22 17:51:45 +00:00
lastTick: 0,
2026-08-22 17:37:25 +00:00
},
};
/* =========================================================
HELPERS
========================================================= */
function currentStatus() {
return (
appState.engine.state.status ||
ChessEngine.evaluateStatus(
appState.engine.state
)
);
}
function currentHumanColor() {
if (appState.mode === "ai") {
return appState.playerColor;
}
if (!appState.party) {
return null;
}
2026-08-22 17:51:45 +00:00
if (appState.party.role === "white") {
2026-08-22 17:37:25 +00:00
return "w";
}
2026-08-22 17:51:45 +00:00
if (appState.party.role === "black") {
2026-08-22 17:37:25 +00:00
return "b";
}
return null;
}
function isHumanTurn() {
2026-08-22 17:51:45 +00:00
const color = currentHumanColor();
const status = currentStatus();
2026-08-22 17:37:25 +00:00
return Boolean(
color &&
status.phase === "playing" &&
appState.engine.state.turn === color &&
!appState.isAiThinking &&
!appState.gameOver
);
}
2026-08-22 17:51:45 +00:00
function isFinished() {
const status = currentStatus();
return Boolean(
appState.gameOver ||
status.phase === "checkmate" ||
status.phase === "draw"
);
}
2026-08-22 17:37:25 +00:00
function getLegalMovesForSelected() {
if (!appState.selectedSquare) {
return [];
}
return appState.engine.getMovesFrom(
appState.selectedSquare
);
}
2026-08-22 17:51:45 +00:00
function clearSelection() {
appState.selectedSquare = null;
appState.pendingPromotionMoves = null;
}
2026-08-22 17:37:25 +00:00
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function formatClock(seconds) {
2026-08-22 17:51:45 +00:00
const total = Math.max(
0,
Math.ceil(Number(seconds) || 0)
);
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
const minutes = Math.floor(total / 60);
const secs = total % 60;
2026-08-22 17:37:25 +00:00
if (minutes >= 60) {
2026-08-22 17:51:45 +00:00
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
2026-08-22 17:37:25 +00:00
return (
`${String(hours).padStart(2, "0")}:` +
`${String(mins).padStart(2, "0")}:` +
`${String(secs).padStart(2, "0")}`
);
}
return (
`${String(minutes).padStart(2, "0")}:` +
`${String(secs).padStart(2, "0")}`
);
}
2026-08-22 17:51:45 +00:00
function getIncrementForTimeControl(value) {
const parts = String(value || "").split("+");
return Number(parts[1] || 0);
}
function getTimeControlSeconds(value) {
return TIME_CONTROLS[value] || 0;
}
2026-08-22 17:37:25 +00:00
/* =========================================================
TOAST
========================================================= */
2026-08-22 17:51:45 +00:00
function showToast(message, type = "info") {
2026-08-22 17:37:25 +00:00
if (!elements.toastHost) {
2026-08-22 17:51:45 +00:00
console.log(message);
2026-08-22 17:37:25 +00:00
return;
}
2026-08-22 17:51:45 +00:00
const toast = document.createElement("div");
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
toast.className = `toast ${type}`;
toast.textContent = message;
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
elements.toastHost.appendChild(toast);
2026-08-22 17:37:25 +00:00
2026-08-22 18:20:15 +00:00
window.setTimeout(() => {
toast.remove();
}, 3200);
2026-08-22 17:37:25 +00:00
}
/* =========================================================
MODE
========================================================= */
function setMode(mode) {
appState.mode = mode;
2026-08-22 17:51:45 +00:00
const isAi = mode === "ai";
const isParty = mode === "party";
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
elements.modeAiButton?.classList.toggle(
"active",
isAi
);
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
elements.modePartyButton?.classList.toggle(
"active",
isParty
);
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
elements.aiControls?.classList.toggle(
"hidden",
!isAi
);
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
elements.partyControls?.classList.toggle(
"hidden",
!isParty
);
2026-08-22 17:37:25 +00:00
if (elements.modeBadge) {
elements.modeBadge.textContent =
2026-08-22 17:51:45 +00:00
isAi
2026-08-22 17:37:25 +00:00
? "AI Arena"
: "Party Lounge";
}
}
/* =========================================================
URL
========================================================= */
function updatePartyUrl(code = null) {
2026-08-22 17:51:45 +00:00
const url = new URL(window.location.href);
2026-08-22 17:37:25 +00:00
if (code) {
2026-08-22 17:51:45 +00:00
url.searchParams.set("party", code);
2026-08-22 17:37:25 +00:00
} else {
2026-08-22 17:51:45 +00:00
url.searchParams.delete("party");
2026-08-22 17:37:25 +00:00
}
window.history.replaceState(
{},
"",
url
);
}
/* =========================================================
BOARD
========================================================= */
function renderBoard() {
if (!elements.board) {
return;
}
elements.board.innerHTML = "";
const selectedMoves =
getLegalMovesForSelected();
2026-08-22 18:20:15 +00:00
const moveTargets = new Map();
2026-08-22 17:37:25 +00:00
2026-08-22 18:20:15 +00:00
for (const move of selectedMoves) {
moveTargets.set(move.to, move);
}
const orientation = appState.orientation;
2026-08-22 17:37:25 +00:00
const xOrder =
orientation === "w"
? [0, 1, 2, 3, 4, 5, 6, 7]
: [7, 6, 5, 4, 3, 2, 1, 0];
const yOrder =
orientation === "w"
? [7, 6, 5, 4, 3, 2, 1, 0]
: [0, 1, 2, 3, 4, 5, 6, 7];
2026-08-22 17:51:45 +00:00
const status = currentStatus();
2026-08-22 17:37:25 +00:00
const checkColor =
status.check
? appState.engine.state.turn
: null;
const lastMove =
appState.engine.state.lastMove;
2026-08-22 17:51:45 +00:00
for (const y of yOrder) {
for (const x of xOrder) {
2026-08-22 17:37:25 +00:00
const squareName =
2026-08-22 17:51:45 +00:00
ChessRules.toSquare(x, y);
2026-08-22 17:37:25 +00:00
const piece =
appState.engine.state.board[y][x];
const square =
2026-08-22 17:51:45 +00:00
document.createElement("button");
2026-08-22 17:37:25 +00:00
square.type = "button";
2026-08-22 18:20:15 +00:00
/*
* ВАЖНО:
* Теперь цвет клетки задаётся непосредственно
* через .light / .dark.
*/
const isDark =
(x + y) % 2 === 0;
2026-08-22 17:37:25 +00:00
square.className =
`square ${
2026-08-22 18:20:15 +00:00
isDark ? "dark" : "light"
2026-08-22 17:37:25 +00:00
}`;
square.dataset.square =
squareName;
2026-08-22 18:20:15 +00:00
/* Selected */
2026-08-22 17:37:25 +00:00
if (
appState.selectedSquare ===
squareName
) {
square.classList.add(
"selected"
);
}
2026-08-22 18:20:15 +00:00
/* Legal move */
2026-08-22 17:37:25 +00:00
const move =
2026-08-22 17:51:45 +00:00
moveTargets.get(squareName);
2026-08-22 17:37:25 +00:00
if (move) {
square.classList.add(
move.capture
? "capture-target"
: "legal-target"
);
}
2026-08-22 18:20:15 +00:00
/* Last move */
2026-08-22 17:37:25 +00:00
if (
lastMove &&
(
lastMove.from === squareName ||
lastMove.to === squareName
)
) {
square.classList.add(
"last-move"
);
}
2026-08-22 18:20:15 +00:00
/* Check */
2026-08-22 17:37:25 +00:00
if (
piece &&
piece.type === "K" &&
piece.color === checkColor
) {
square.classList.add(
"check-square"
);
}
2026-08-22 18:20:15 +00:00
/* Piece */
2026-08-22 17:37:25 +00:00
if (piece) {
const pieceNode =
2026-08-22 17:51:45 +00:00
document.createElement("span");
2026-08-22 17:37:25 +00:00
pieceNode.className =
`piece ${
piece.color === "w"
? "white"
: "black"
}`;
pieceNode.textContent =
PIECE_GLYPHS[
piece.color
][piece.type];
2026-08-22 18:20:15 +00:00
pieceNode.setAttribute(
"aria-hidden",
"true"
);
square.appendChild(
pieceNode
);
2026-08-22 17:37:25 +00:00
}
square.addEventListener(
"click",
2026-08-22 18:20:15 +00:00
() => handleSquareClick(
squareName
)
2026-08-22 17:37:25 +00:00
);
2026-08-22 18:20:15 +00:00
elements.board.appendChild(
square
);
2026-08-22 17:37:25 +00:00
}
}
}
/* =========================================================
2026-08-22 17:51:45 +00:00
GAME STATUS
2026-08-22 17:37:25 +00:00
========================================================= */
function formatStatus() {
if (appState.gameOver) {
return getGameOverMessage();
}
2026-08-22 17:51:45 +00:00
const status = currentStatus();
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
if (status.phase === "checkmate") {
2026-08-22 18:20:15 +00:00
return "Мат";
2026-08-22 17:37:25 +00:00
}
2026-08-22 17:51:45 +00:00
if (status.phase === "draw") {
2026-08-22 18:20:15 +00:00
return "Ничья";
2026-08-22 17:37:25 +00:00
}
if (status.check) {
2026-08-22 17:51:45 +00:00
return appState.engine.state.turn === "w"
2026-08-22 18:20:15 +00:00
? "Белые под шахом"
: "Чёрные под шахом";
2026-08-22 17:37:25 +00:00
}
2026-08-22 17:51:45 +00:00
return appState.engine.state.turn === "w"
2026-08-22 18:20:15 +00:00
? "Ход белых"
: "Ход чёрных";
2026-08-22 17:37:25 +00:00
}
function getGameOverMessage() {
2026-08-22 17:51:45 +00:00
const snapshot =
appState.partySnapshot;
if (!snapshot) {
2026-08-22 17:37:25 +00:00
return (
appState.gameOverMessage ||
2026-08-22 18:20:15 +00:00
"Игра окончена."
2026-08-22 17:37:25 +00:00
);
}
const reason =
2026-08-22 17:51:45 +00:00
snapshot.gameOverReason;
2026-08-22 17:37:25 +00:00
const winner =
2026-08-22 17:51:45 +00:00
snapshot.winner;
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
switch (reason) {
case "timeout":
return winner === "w"
2026-08-22 18:20:15 +00:00
? "Белые победили по времени."
: "Чёрные победили по времени.";
2026-08-22 17:51:45 +00:00
case "checkmate":
return winner === "w"
2026-08-22 18:20:15 +00:00
? "Белые победили матом."
: "Чёрные победили матом.";
2026-08-22 17:51:45 +00:00
case "draw":
2026-08-22 18:20:15 +00:00
return "Ничья.";
2026-08-22 17:51:45 +00:00
default:
return (
appState.gameOverMessage ||
2026-08-22 18:20:15 +00:00
"Игра окончена."
2026-08-22 17:51:45 +00:00
);
2026-08-22 17:37:25 +00:00
}
}
/* =========================================================
ROSTER
========================================================= */
function renderRoster() {
if (appState.mode === "ai") {
const human =
2026-08-22 18:20:15 +00:00
appState.playerName || "Вы";
2026-08-22 17:37:25 +00:00
const whiteName =
appState.playerColor === "w"
? human
2026-08-22 18:20:15 +00:00
: `Компьютер (${appState.aiLevel})`;
2026-08-22 17:37:25 +00:00
const blackName =
appState.playerColor === "b"
? human
2026-08-22 18:20:15 +00:00
: `Компьютер (${appState.aiLevel})`;
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
if (elements.whitePlayerLabel) {
2026-08-22 17:37:25 +00:00
elements.whitePlayerLabel.textContent =
whiteName;
}
2026-08-22 17:51:45 +00:00
if (elements.blackPlayerLabel) {
2026-08-22 17:37:25 +00:00
elements.blackPlayerLabel.textContent =
blackName;
}
2026-08-22 17:51:45 +00:00
if (elements.rosterWhite) {
2026-08-22 17:37:25 +00:00
elements.rosterWhite.textContent =
whiteName;
}
2026-08-22 17:51:45 +00:00
if (elements.rosterBlack) {
2026-08-22 17:37:25 +00:00
elements.rosterBlack.textContent =
blackName;
}
2026-08-22 17:51:45 +00:00
if (elements.spectatorList) {
2026-08-22 17:37:25 +00:00
elements.spectatorList.innerHTML =
2026-08-22 18:20:15 +00:00
"<li>В режиме AI зрителей нет</li>";
2026-08-22 17:37:25 +00:00
}
2026-08-22 17:51:45 +00:00
if (elements.spectatorCount) {
2026-08-22 17:37:25 +00:00
elements.spectatorCount.textContent =
"0 / 20";
}
return;
}
const white =
2026-08-22 17:51:45 +00:00
appState.partySnapshot?.players?.white;
2026-08-22 17:37:25 +00:00
const black =
2026-08-22 17:51:45 +00:00
appState.partySnapshot?.players?.black;
2026-08-22 17:37:25 +00:00
const whiteName =
white
? `${white.name}${
white.connected
? ""
2026-08-22 18:20:15 +00:00
: " · офлайн"
2026-08-22 17:37:25 +00:00
}`
2026-08-22 18:20:15 +00:00
: "Свободно";
2026-08-22 17:37:25 +00:00
const blackName =
black
? `${black.name}${
black.connected
? ""
2026-08-22 18:20:15 +00:00
: " · офлайн"
2026-08-22 17:37:25 +00:00
}`
2026-08-22 18:20:15 +00:00
: "Свободно";
2026-08-22 17:37:25 +00:00
2026-08-22 18:20:15 +00:00
if (elements.whitePlayerLabel) {
elements.whitePlayerLabel.textContent =
whiteName;
}
2026-08-22 17:37:25 +00:00
2026-08-22 18:20:15 +00:00
if (elements.blackPlayerLabel) {
elements.blackPlayerLabel.textContent =
blackName;
}
2026-08-22 17:37:25 +00:00
2026-08-22 18:20:15 +00:00
if (elements.rosterWhite) {
elements.rosterWhite.textContent =
whiteName;
}
2026-08-22 17:37:25 +00:00
2026-08-22 18:20:15 +00:00
if (elements.rosterBlack) {
elements.rosterBlack.textContent =
blackName;
}
2026-08-22 17:37:25 +00:00
const spectators =
2026-08-22 17:51:45 +00:00
appState.partySnapshot?.spectators || [];
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
if (elements.spectatorCount) {
2026-08-22 17:37:25 +00:00
elements.spectatorCount.textContent =
`${spectators.length} / 20`;
}
2026-08-22 17:51:45 +00:00
if (!elements.spectatorList) {
2026-08-22 17:37:25 +00:00
return;
}
if (!spectators.length) {
elements.spectatorList.innerHTML =
2026-08-22 18:20:15 +00:00
"<li>Пока никого нет</li>";
2026-08-22 17:37:25 +00:00
return;
}
elements.spectatorList.innerHTML =
spectators
.map(
spectator =>
`<li>${escapeHtml(
spectator.name
)}${
spectator.connected
? ""
2026-08-22 18:20:15 +00:00
: " · офлайн"
2026-08-22 17:37:25 +00:00
}</li>`
)
.join("");
}
2026-08-22 18:20:15 +00:00
/* =========================================================
CAPTURED PIECES
========================================================= */
function renderCaptured() {
if (
!elements.capturedByWhite &&
!elements.capturedByBlack
) {
return;
}
/*
* Поддерживаем несколько возможных форматов
* истории движка, если они присутствуют.
*/
const history =
appState.engine.state.moveHistory ||
appState.engine.state.history ||
[];
const whiteCaptured = [];
const blackCaptured = [];
for (const move of history) {
if (!move) {
continue;
}
const captured =
move.captured ||
move.capturedPiece ||
null;
if (!captured) {
continue;
}
const color =
captured.color ||
(captured.type
? (move.color === "w" ? "b" : "w")
: null);
const type =
captured.type ||
(
typeof captured === "string"
? captured
: null
);
if (!type) {
continue;
}
const glyph =
PIECE_GLYPHS[color]?.[type];
if (!glyph) {
continue;
}
if (move.color === "w") {
whiteCaptured.push(glyph);
} else {
blackCaptured.push(glyph);
}
}
if (elements.capturedByWhite) {
elements.capturedByWhite.textContent =
whiteCaptured.join(" ");
}
if (elements.capturedByBlack) {
elements.capturedByBlack.textContent =
blackCaptured.join(" ");
}
}
/* =========================================================
MOVE HISTORY
========================================================= */
function renderMoveHistory() {
if (!elements.moveHistory) {
return;
}
const history =
appState.engine.state.moveHistory ||
appState.engine.state.history ||
[];
if (!Array.isArray(history) || !history.length) {
elements.moveHistory.innerHTML =
"<li class=\"empty-state\">Ходов пока нет</li>";
return;
}
elements.moveHistory.innerHTML =
history
.map((move, index) => {
if (typeof move === "string") {
return `<li>${escapeHtml(move)}</li>`;
}
const from =
move?.from || "";
const to =
move?.to || "";
const promotion =
move?.promotion
? `=${String(move.promotion).toUpperCase()}`
: "";
const text =
from && to
? `${from}${to}${promotion}`
: JSON.stringify(move);
return (
`<li>${escapeHtml(
`${index + 1}. ${text}`
)}</li>`
);
})
.join("");
}
2026-08-22 17:37:25 +00:00
/* =========================================================
PARTY SUMMARY
========================================================= */
function renderPartySummary() {
if (!elements.partySummary) {
return;
}
if (!appState.party) {
elements.partySummary.textContent =
2026-08-22 18:20:15 +00:00
"Создайте комнату, чтобы играть белыми. Следующий игрок присоединится за чёрных. До 20 зрителей.";
2026-08-22 17:37:25 +00:00
return;
}
2026-08-22 17:51:45 +00:00
const role =
appState.party.role;
2026-08-22 17:37:25 +00:00
const roleText =
2026-08-22 17:51:45 +00:00
role === "white"
2026-08-22 18:20:15 +00:00
? "вы играете белыми"
2026-08-22 17:51:45 +00:00
: role === "black"
2026-08-22 18:20:15 +00:00
? "вы играете чёрными"
: "вы зритель";
2026-08-22 17:37:25 +00:00
const timeLabel =
appState.partySnapshot
?.timeControl?.label ||
2026-08-22 18:14:17 +00:00
"Без часов";
2026-08-22 17:37:25 +00:00
elements.partySummary.textContent =
2026-08-22 18:20:15 +00:00
`Комната ${appState.party.code} · ${roleText} · ${timeLabel}.`;
2026-08-22 17:37:25 +00:00
}
/* =========================================================
CLOCKS
========================================================= */
function renderClocks() {
let clocks;
let timeControl;
2026-08-22 17:51:45 +00:00
if (appState.mode === "party") {
2026-08-22 17:37:25 +00:00
clocks =
2026-08-22 17:51:45 +00:00
appState.partySnapshot?.clocks;
2026-08-22 17:37:25 +00:00
timeControl =
2026-08-22 17:51:45 +00:00
appState.partySnapshot?.timeControl;
2026-08-22 17:37:25 +00:00
} else {
clocks =
appState.localClocks;
timeControl = {
2026-08-22 17:51:45 +00:00
initial:
getTimeControlSeconds(
appState.aiTimeControl
),
2026-08-22 17:37:25 +00:00
label:
elements.aiTimeControl
?.selectedOptions?.[0]
?.textContent ||
2026-08-22 18:14:17 +00:00
"Без часов",
2026-08-22 17:37:25 +00:00
};
}
2026-08-22 17:51:45 +00:00
const enabled =
2026-08-22 17:37:25 +00:00
Boolean(
2026-08-22 17:51:45 +00:00
timeControl?.initial > 0
2026-08-22 17:37:25 +00:00
);
2026-08-22 17:51:45 +00:00
if (!enabled) {
2026-08-22 17:37:25 +00:00
if (elements.whiteClock) {
2026-08-22 17:51:45 +00:00
elements.whiteClock.textContent = "∞";
2026-08-22 18:20:15 +00:00
2026-08-22 17:51:45 +00:00
elements.whiteClock.classList.remove(
"active",
2026-08-22 18:20:15 +00:00
"low",
"danger"
2026-08-22 17:51:45 +00:00
);
2026-08-22 17:37:25 +00:00
}
if (elements.blackClock) {
2026-08-22 17:51:45 +00:00
elements.blackClock.textContent = "∞";
2026-08-22 18:20:15 +00:00
2026-08-22 17:51:45 +00:00
elements.blackClock.classList.remove(
"active",
2026-08-22 18:20:15 +00:00
"low",
"danger"
2026-08-22 17:51:45 +00:00
);
2026-08-22 17:37:25 +00:00
}
return;
}
const white =
clocks?.white ?? 0;
const black =
clocks?.black ?? 0;
if (elements.whiteClock) {
elements.whiteClock.textContent =
formatClock(white);
elements.whiteClock.classList.toggle(
"active",
clocks?.turn === "w" &&
clocks?.running
);
elements.whiteClock.classList.toggle(
2026-08-22 18:14:17 +00:00
"low",
white <= 10
2026-08-22 17:37:25 +00:00
);
2026-08-22 18:20:15 +00:00
elements.whiteClock.classList.toggle(
"danger",
white <= 5
);
2026-08-22 17:37:25 +00:00
}
if (elements.blackClock) {
elements.blackClock.textContent =
formatClock(black);
elements.blackClock.classList.toggle(
"active",
clocks?.turn === "b" &&
clocks?.running
);
elements.blackClock.classList.toggle(
2026-08-22 18:14:17 +00:00
"low",
black <= 10
2026-08-22 17:37:25 +00:00
);
2026-08-22 18:20:15 +00:00
elements.blackClock.classList.toggle(
"danger",
black <= 5
);
2026-08-22 17:37:25 +00:00
}
}
/* =========================================================
2026-08-22 17:51:45 +00:00
CONNECTION BADGE
2026-08-22 17:37:25 +00:00
========================================================= */
2026-08-22 17:51:45 +00:00
function renderConnectionBadge() {
if (!elements.connectionBadge) {
return;
2026-08-22 17:37:25 +00:00
}
2026-08-22 17:51:45 +00:00
if (appState.mode === "ai") {
elements.connectionBadge.textContent =
appState.isAiThinking
2026-08-22 18:20:15 +00:00
? "Компьютер думает"
: `AI · ${appState.aiLevel}`;
elements.connectionBadge.className =
"status-chip connection-solo";
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
return;
2026-08-22 17:37:25 +00:00
}
2026-08-22 17:51:45 +00:00
if (!appState.party) {
elements.connectionBadge.textContent =
2026-08-22 18:20:15 +00:00
"Party ожидание";
elements.connectionBadge.className =
"status-chip connection-solo";
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
return;
2026-08-22 17:37:25 +00:00
}
2026-08-22 17:51:45 +00:00
const role =
appState.party.role === "white"
2026-08-22 18:20:15 +00:00
? "Белые"
2026-08-22 17:51:45 +00:00
: appState.party.role === "black"
2026-08-22 18:20:15 +00:00
? "Чёрные"
: "Зритель";
2026-08-22 17:51:45 +00:00
elements.connectionBadge.textContent =
2026-08-22 18:20:15 +00:00
`${appState.party.code} · ${role}`;
elements.connectionBadge.className =
"status-chip connection-party";
}
/* =========================================================
STATUS BADGE
========================================================= */
function renderTurnBadge() {
if (!elements.turnBadge) {
return;
}
const turn =
appState.engine.state.turn;
elements.turnBadge.classList.toggle(
"turn-white",
turn === "w"
);
elements.turnBadge.classList.toggle(
"turn-black",
turn === "b"
);
2026-08-22 17:37:25 +00:00
}
/* =========================================================
RENDER
========================================================= */
function render() {
renderBoard();
renderRoster();
renderPartySummary();
renderClocks();
2026-08-22 17:51:45 +00:00
renderConnectionBadge();
2026-08-22 18:20:15 +00:00
renderTurnBadge();
renderCaptured();
renderMoveHistory();
2026-08-22 17:37:25 +00:00
2026-08-22 18:20:15 +00:00
const status =
formatStatus();
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
if (elements.turnBadge) {
2026-08-22 17:37:25 +00:00
elements.turnBadge.textContent =
2026-08-22 18:14:17 +00:00
status;
2026-08-22 17:37:25 +00:00
}
2026-08-22 17:51:45 +00:00
if (elements.statusMessage) {
2026-08-22 17:37:25 +00:00
elements.statusMessage.textContent =
2026-08-22 17:51:45 +00:00
status;
2026-08-22 17:37:25 +00:00
}
2026-08-22 17:51:45 +00:00
elements.copyInviteButton?.classList.toggle(
2026-08-22 17:37:25 +00:00
"hidden",
2026-08-22 17:51:45 +00:00
!appState.party
2026-08-22 17:37:25 +00:00
);
2026-08-22 17:51:45 +00:00
elements.leavePartyButton?.classList.toggle(
"hidden",
!appState.party
);
2026-08-22 17:37:25 +00:00
}
/* =========================================================
PROMOTION
========================================================= */
2026-08-22 17:51:45 +00:00
function openPromotionDialog(moves) {
2026-08-22 17:37:25 +00:00
appState.pendingPromotionMoves =
moves;
2026-08-22 17:51:45 +00:00
if (!elements.promotionOptions) {
2026-08-22 17:37:25 +00:00
return;
}
2026-08-22 17:51:45 +00:00
elements.promotionOptions.innerHTML = "";
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
for (const move of moves) {
2026-08-22 17:37:25 +00:00
const button =
2026-08-22 17:51:45 +00:00
document.createElement("button");
2026-08-22 17:37:25 +00:00
button.type = "button";
button.className =
"promotion-button";
2026-08-22 17:51:45 +00:00
const promotion =
String(move.promotion || "Q")
.toUpperCase();
2026-08-22 17:37:25 +00:00
button.textContent =
PIECE_GLYPHS[
2026-08-22 18:20:15 +00:00
move.color ||
appState.engine.state.turn
2026-08-22 17:51:45 +00:00
][promotion];
2026-08-22 17:37:25 +00:00
button.addEventListener(
"click",
() => {
closePromotionDialog();
submitMove(move);
}
);
elements.promotionOptions.appendChild(
button
);
}
2026-08-22 17:51:45 +00:00
elements.promotionDialog?.classList.remove(
"hidden"
);
2026-08-22 17:37:25 +00:00
}
function closePromotionDialog() {
2026-08-22 17:51:45 +00:00
appState.pendingPromotionMoves = null;
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
elements.promotionDialog?.classList.add(
"hidden"
);
2026-08-22 17:37:25 +00:00
}
/* =========================================================
MOVE
========================================================= */
2026-08-22 17:51:45 +00:00
async function submitMove(move) {
if (appState.gameOver) {
2026-08-22 17:37:25 +00:00
return;
}
clearSelection();
if (
appState.mode === "party" &&
appState.party
) {
const response =
await postJson(
"/api/party/move",
{
partyCode:
appState.party.code,
clientId:
appState.party.clientId,
2026-08-22 17:51:45 +00:00
from: move.from,
to: move.to,
2026-08-22 17:37:25 +00:00
promotion:
2026-08-22 17:51:45 +00:00
move.promotion || null,
2026-08-22 17:37:25 +00:00
}
);
if (!response.ok) {
if (response.party) {
hydratePartyState(
response.party,
false
);
}
showToast(
response.error ||
2026-08-22 18:20:15 +00:00
"Ход отклонён.",
2026-08-22 17:37:25 +00:00
"error"
);
return;
}
hydratePartyState(
response.party,
false
);
return;
}
const result =
appState.engine.makeMove({
2026-08-22 17:51:45 +00:00
from: move.from,
to: move.to,
2026-08-22 17:37:25 +00:00
promotion:
2026-08-22 17:51:45 +00:00
move.promotion || null,
2026-08-22 17:37:25 +00:00
});
if (!result.ok) {
showToast(
result.error ||
2026-08-22 18:20:15 +00:00
"Недопустимый ход.",
2026-08-22 17:37:25 +00:00
"error"
);
return;
}
updateAiClockAfterMove();
render();
scheduleAiTurn();
}
/* =========================================================
2026-08-22 17:51:45 +00:00
BOARD INPUT
2026-08-22 17:37:25 +00:00
========================================================= */
2026-08-22 17:51:45 +00:00
function handleSquareClick(square) {
2026-08-22 17:37:25 +00:00
if (
appState.pendingPromotionMoves ||
appState.gameOver
) {
return;
}
2026-08-22 17:51:45 +00:00
if (!isHumanTurn()) {
clearSelection();
renderBoard();
return;
}
2026-08-22 17:37:25 +00:00
const piece =
2026-08-22 17:51:45 +00:00
appState.engine.getPiece(square);
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
if (appState.selectedSquare) {
2026-08-22 17:37:25 +00:00
const matchingMoves =
getLegalMovesForSelected()
.filter(
move =>
2026-08-22 17:51:45 +00:00
move.to === square
2026-08-22 17:37:25 +00:00
);
2026-08-22 17:51:45 +00:00
if (matchingMoves.length === 1) {
2026-08-22 18:20:15 +00:00
submitMove(
matchingMoves[0]
);
2026-08-22 17:37:25 +00:00
return;
}
2026-08-22 17:51:45 +00:00
if (matchingMoves.length > 1) {
2026-08-22 17:37:25 +00:00
openPromotionDialog(
matchingMoves
);
2026-08-22 18:20:15 +00:00
2026-08-22 17:37:25 +00:00
return;
}
}
2026-08-22 17:51:45 +00:00
const humanColor =
currentHumanColor();
2026-08-22 17:37:25 +00:00
if (
piece &&
piece.color === humanColor &&
piece.color ===
appState.engine.state.turn
) {
appState.selectedSquare =
2026-08-22 17:51:45 +00:00
appState.selectedSquare === square
2026-08-22 17:37:25 +00:00
? null
: square;
renderBoard();
2026-08-22 18:20:15 +00:00
2026-08-22 17:37:25 +00:00
return;
}
clearSelection();
renderBoard();
}
/* =========================================================
AI
========================================================= */
function scheduleAiTurn() {
2026-08-22 17:51:45 +00:00
stopAiMoveTimer();
if (appState.mode !== "ai") {
2026-08-22 17:37:25 +00:00
return;
}
2026-08-22 17:51:45 +00:00
const status = currentStatus();
2026-08-22 17:37:25 +00:00
if (
!status ||
status.phase !== "playing" ||
appState.gameOver
) {
2026-08-22 17:51:45 +00:00
appState.isAiThinking = false;
2026-08-22 17:37:25 +00:00
render();
return;
}
if (
appState.engine.state.turn ===
appState.playerColor
) {
2026-08-22 17:51:45 +00:00
appState.isAiThinking = false;
2026-08-22 17:37:25 +00:00
render();
return;
}
2026-08-22 17:51:45 +00:00
appState.isAiThinking = true;
2026-08-22 17:37:25 +00:00
render();
2026-08-22 17:51:45 +00:00
appState.aiMoveTimer =
window.setTimeout(
() => {
appState.aiMoveTimer = null;
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
if (
appState.mode !== "ai" ||
appState.gameOver
) {
appState.isAiThinking = false;
render();
return;
}
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
const move =
chooseComputerMove(
appState.engine,
appState.aiLevel
);
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
appState.isAiThinking = false;
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
if (!move) {
render();
return;
}
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
submitMove(move);
},
400
);
}
function stopAiMoveTimer() {
if (appState.aiMoveTimer) {
window.clearTimeout(
appState.aiMoveTimer
);
appState.aiMoveTimer = null;
}
appState.isAiThinking = false;
2026-08-22 17:37:25 +00:00
}
/* =========================================================
AI CLOCK
========================================================= */
function startAiClock() {
stopAiClock();
const seconds =
2026-08-22 17:51:45 +00:00
getTimeControlSeconds(
2026-08-22 17:37:25 +00:00
appState.aiTimeControl
2026-08-22 17:51:45 +00:00
);
2026-08-22 17:37:25 +00:00
appState.localClocks = {
white: seconds,
black: seconds,
2026-08-22 17:51:45 +00:00
2026-08-22 17:37:25 +00:00
running: seconds > 0,
2026-08-22 17:51:45 +00:00
2026-08-22 17:37:25 +00:00
turn:
appState.engine.state.turn,
2026-08-22 17:51:45 +00:00
lastTick:
Date.now(),
2026-08-22 17:37:25 +00:00
};
render();
if (!seconds) {
return;
}
appState.aiClockTimer =
window.setInterval(
() => {
updateAiClock();
renderClocks();
},
250
);
}
function stopAiClock() {
2026-08-22 17:51:45 +00:00
if (appState.aiClockTimer) {
2026-08-22 17:37:25 +00:00
window.clearInterval(
appState.aiClockTimer
);
2026-08-22 17:51:45 +00:00
appState.aiClockTimer = null;
2026-08-22 17:37:25 +00:00
}
}
function updateAiClock() {
if (
2026-08-22 17:51:45 +00:00
appState.mode !== "ai" ||
appState.gameOver
2026-08-22 17:37:25 +00:00
) {
return;
}
const clocks =
appState.localClocks;
2026-08-22 17:51:45 +00:00
if (!clocks.running) {
2026-08-22 17:37:25 +00:00
return;
}
2026-08-22 17:51:45 +00:00
const now = Date.now();
2026-08-22 17:37:25 +00:00
const elapsed =
2026-08-22 17:51:45 +00:00
Math.max(
0,
(now - clocks.lastTick) / 1000
);
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
if (!elapsed) {
2026-08-22 17:37:25 +00:00
return;
}
2026-08-22 17:51:45 +00:00
clocks.lastTick = now;
const color =
clocks.turn;
const key =
color === "w"
2026-08-22 17:37:25 +00:00
? "white"
: "black";
2026-08-22 17:51:45 +00:00
clocks[key] =
2026-08-22 17:37:25 +00:00
Math.max(
0,
2026-08-22 17:51:45 +00:00
clocks[key] - elapsed
2026-08-22 17:37:25 +00:00
);
2026-08-22 17:51:45 +00:00
if (clocks[key] <= 0) {
clocks.running = false;
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
appState.gameOver = true;
2026-08-22 17:37:25 +00:00
appState.gameOverMessage =
2026-08-22 17:51:45 +00:00
color === "w"
2026-08-22 18:20:15 +00:00
? "Чёрные победили по времени."
: "Белые победили по времени.";
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
stopAiClock();
2026-08-22 17:37:25 +00:00
showToast(
appState.gameOverMessage,
"error"
);
render();
}
}
function updateAiClockAfterMove() {
const clocks =
appState.localClocks;
2026-08-22 17:51:45 +00:00
if (!clocks.running) {
2026-08-22 17:37:25 +00:00
return;
}
updateAiClock();
2026-08-22 17:51:45 +00:00
if (appState.gameOver) {
return;
}
2026-08-22 17:37:25 +00:00
const previousTurn =
clocks.turn;
2026-08-22 17:51:45 +00:00
const key =
2026-08-22 17:37:25 +00:00
previousTurn === "w"
? "white"
: "black";
2026-08-22 17:51:45 +00:00
clocks[key] +=
getIncrementForTimeControl(
appState.aiTimeControl
);
2026-08-22 17:37:25 +00:00
clocks.turn =
appState.engine.state.turn;
clocks.lastTick =
Date.now();
}
/* =========================================================
2026-08-22 17:51:45 +00:00
LOCAL GAME
2026-08-22 17:37:25 +00:00
========================================================= */
function resetLocalGame() {
2026-08-22 17:51:45 +00:00
stopAiMoveTimer();
2026-08-22 17:37:25 +00:00
stopAiClock();
closePromotionDialog();
appState.engine =
new ChessEngine();
2026-08-22 17:51:45 +00:00
appState.partySnapshot = null;
2026-08-22 18:20:15 +00:00
appState.selectedSquare = null;
2026-08-22 17:37:25 +00:00
appState.orientation =
appState.playerColor;
2026-08-22 17:51:45 +00:00
appState.gameOver = false;
appState.gameOverMessage = null;
2026-08-22 17:37:25 +00:00
startAiClock();
scheduleAiTurn();
}
/* =========================================================
API
========================================================= */
2026-08-22 17:51:45 +00:00
async function postJson(url, body) {
2026-08-22 17:37:25 +00:00
try {
const response =
await fetch(
url,
{
method: "POST",
headers: {
"Content-Type":
"application/json",
},
body:
JSON.stringify(body),
}
);
const contentType =
response.headers.get(
"content-type"
) || "";
if (
!contentType.includes(
"application/json"
)
) {
return {
ok: false,
2026-08-22 17:51:45 +00:00
2026-08-22 17:37:25 +00:00
error:
2026-08-22 18:20:15 +00:00
`Сервер вернул ${response.status} вместо JSON.`,
2026-08-22 17:37:25 +00:00
};
}
return await response.json();
} catch (error) {
return {
ok: false,
2026-08-22 17:51:45 +00:00
2026-08-22 17:37:25 +00:00
error:
error instanceof Error
? error.message
2026-08-22 18:20:15 +00:00
: "Ошибка сети.",
2026-08-22 17:37:25 +00:00
};
}
}
/* =========================================================
PARTY STATE
========================================================= */
function hydratePartyState(
partyPayload,
announce = true
) {
if (!partyPayload) {
return;
}
2026-08-22 17:51:45 +00:00
const previousCode =
appState.party?.code;
const previousClientId =
appState.party?.clientId;
2026-08-22 17:37:25 +00:00
appState.party = {
code:
partyPayload.code,
clientId:
partyPayload.you?.id ||
2026-08-22 17:51:45 +00:00
previousClientId ||
2026-08-22 17:37:25 +00:00
null,
role:
partyPayload.you?.role ||
appState.party?.role ||
"spectator",
};
appState.partySnapshot =
partyPayload;
appState.engine =
new ChessEngine(
partyPayload.game
);
2026-08-22 18:20:15 +00:00
appState.selectedSquare = null;
2026-08-22 17:37:25 +00:00
appState.gameOver =
Boolean(
partyPayload.gameOverReason
);
appState.gameOverMessage =
appState.gameOver
? getGameOverMessage()
: null;
if (
appState.party.role === "white"
) {
2026-08-22 17:51:45 +00:00
appState.orientation = "w";
} else if (
2026-08-22 17:37:25 +00:00
appState.party.role === "black"
) {
2026-08-22 17:51:45 +00:00
appState.orientation = "b";
2026-08-22 17:37:25 +00:00
}
setMode("party");
updatePartyUrl(
partyPayload.code
);
render();
if (
announce &&
partyPayload.gameOverReason
) {
showToast(
getGameOverMessage(),
"success"
);
}
2026-08-22 17:51:45 +00:00
if (
previousCode &&
previousCode !== partyPayload.code
) {
console.info(
"Party changed:",
previousCode,
"→",
partyPayload.code
);
}
2026-08-22 17:37:25 +00:00
}
/* =========================================================
2026-08-22 17:51:45 +00:00
PARTY SSE
2026-08-22 17:37:25 +00:00
========================================================= */
function connectPartyStream() {
if (!appState.party) {
return;
}
2026-08-22 17:51:45 +00:00
closePartyStream();
const {
code,
clientId,
} = appState.party;
2026-08-22 17:37:25 +00:00
const url =
`/api/party/events?partyCode=${
2026-08-22 17:51:45 +00:00
encodeURIComponent(code)
2026-08-22 17:37:25 +00:00
}&clientId=${
2026-08-22 17:51:45 +00:00
encodeURIComponent(clientId)
2026-08-22 17:37:25 +00:00
}`;
const stream =
new EventSource(url);
stream.addEventListener(
"party",
event => {
try {
const party =
JSON.parse(
event.data
);
hydratePartyState(
party,
false
);
} catch (error) {
console.error(
2026-08-22 17:51:45 +00:00
"Party SSE error:",
2026-08-22 17:37:25 +00:00
error
);
}
}
);
stream.onerror = () => {
if (
appState.mode === "party" &&
elements.connectionBadge
) {
elements.connectionBadge.textContent =
2026-08-22 18:20:15 +00:00
`Party ${code} · переподключение`;
2026-08-22 17:37:25 +00:00
}
};
2026-08-22 17:51:45 +00:00
appState.partyStream = stream;
}
function closePartyStream() {
if (appState.partyStream) {
appState.partyStream.close();
appState.partyStream = null;
}
2026-08-22 17:37:25 +00:00
}
/* =========================================================
CREATE PARTY
========================================================= */
async function createParty() {
if (appState.party) {
await leaveParty(true);
}
const timeControl =
2026-08-22 17:51:45 +00:00
elements.partyTimeControl?.value ||
"10+0";
2026-08-22 17:37:25 +00:00
const response =
await postJson(
"/api/party/create",
{
name:
appState.playerName,
timeControl,
}
);
if (!response.ok) {
showToast(
response.error ||
2026-08-22 18:20:15 +00:00
"Не удалось создать комнату.",
2026-08-22 17:37:25 +00:00
"error"
);
return;
}
setStoredPartyId(
response.party.code,
response.clientId
);
hydratePartyState(
response.party,
false
);
connectPartyStream();
showToast(
2026-08-22 18:20:15 +00:00
`Комната ${response.party.code} создана.`,
2026-08-22 17:37:25 +00:00
"success"
);
}
/* =========================================================
JOIN PARTY
========================================================= */
2026-08-22 17:51:45 +00:00
async function joinParty(code) {
2026-08-22 17:37:25 +00:00
const normalizedCode =
String(code || "")
.trim()
.toUpperCase();
if (!normalizedCode) {
showToast(
2026-08-22 18:20:15 +00:00
"Введите код комнаты.",
2026-08-22 17:37:25 +00:00
"error"
);
return;
}
if (
appState.party?.code ===
normalizedCode
) {
showToast(
2026-08-22 18:20:15 +00:00
`Вы уже в комнате ${normalizedCode}.`
2026-08-22 17:37:25 +00:00
);
return;
}
if (appState.party) {
await leaveParty(true);
}
const storedIds =
getStoredPartyIds();
const response =
await postJson(
"/api/party/join",
{
name:
appState.playerName,
partyCode:
normalizedCode,
clientId:
2026-08-22 17:51:45 +00:00
storedIds[normalizedCode] ||
null,
2026-08-22 17:37:25 +00:00
}
);
if (!response.ok) {
showToast(
response.error ||
2026-08-22 18:20:15 +00:00
"Не удалось войти в комнату.",
2026-08-22 17:37:25 +00:00
"error"
);
return;
}
setStoredPartyId(
response.party.code,
response.clientId
);
hydratePartyState(
response.party,
false
);
connectPartyStream();
showToast(
2026-08-22 18:20:15 +00:00
`Вы вошли в комнату ${response.party.code}.`,
2026-08-22 17:37:25 +00:00
"success"
);
}
/* =========================================================
2026-08-22 17:51:45 +00:00
LEAVE PARTY
2026-08-22 17:37:25 +00:00
========================================================= */
async function leaveParty(
silent = false
) {
if (!appState.party) {
return;
}
2026-08-22 17:51:45 +00:00
const party =
2026-08-22 17:37:25 +00:00
appState.party;
2026-08-22 17:51:45 +00:00
closePartyStream();
2026-08-22 17:37:25 +00:00
await postJson(
"/api/party/leave",
{
partyCode:
2026-08-22 17:51:45 +00:00
party.code,
2026-08-22 17:37:25 +00:00
clientId:
2026-08-22 17:51:45 +00:00
party.clientId,
2026-08-22 17:37:25 +00:00
}
);
removeStoredPartyId(
2026-08-22 17:51:45 +00:00
party.code
2026-08-22 17:37:25 +00:00
);
2026-08-22 17:51:45 +00:00
appState.party = null;
appState.partySnapshot = null;
2026-08-22 17:37:25 +00:00
updatePartyUrl(null);
setMode("ai");
resetLocalGame();
if (!silent) {
showToast(
2026-08-22 18:20:15 +00:00
"Вы покинули комнату."
2026-08-22 17:37:25 +00:00
);
}
}
/* =========================================================
INVITE
========================================================= */
async function copyInviteLink() {
if (!appState.party) {
return;
}
const inviteUrl =
`${window.location.origin}` +
`${window.location.pathname}` +
`?party=${encodeURIComponent(
appState.party.code
)}`;
try {
await navigator.clipboard.writeText(
inviteUrl
);
showToast(
2026-08-22 18:20:15 +00:00
"Ссылка скопирована.",
2026-08-22 17:37:25 +00:00
"success"
);
} catch {
showToast(
2026-08-22 18:20:15 +00:00
`Код комнаты: ${appState.party.code}`
2026-08-22 17:37:25 +00:00
);
}
}
/* =========================================================
STORAGE
========================================================= */
function getStoredPartyIds() {
try {
return JSON.parse(
localStorage.getItem(
STORAGE_KEYS.partyIds
) || "{}"
);
} catch {
return {};
}
}
function setStoredPartyId(
code,
clientId
) {
2026-08-22 17:51:45 +00:00
if (!code || !clientId) {
return;
}
2026-08-22 17:37:25 +00:00
const ids =
getStoredPartyIds();
2026-08-22 17:51:45 +00:00
ids[code] = clientId;
2026-08-22 17:37:25 +00:00
localStorage.setItem(
STORAGE_KEYS.partyIds,
JSON.stringify(ids)
);
}
2026-08-22 17:51:45 +00:00
function removeStoredPartyId(code) {
if (!code) {
return;
}
2026-08-22 17:37:25 +00:00
const ids =
getStoredPartyIds();
delete ids[code];
localStorage.setItem(
STORAGE_KEYS.partyIds,
JSON.stringify(ids)
);
}
/* =========================================================
EVENTS
========================================================= */
function bindEvents() {
2026-08-22 17:51:45 +00:00
elements.playerNameInput?.addEventListener(
"input",
event => {
appState.playerName =
event.target.value.trim() ||
2026-08-22 18:14:17 +00:00
"Guest";
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
localStorage.setItem(
STORAGE_KEYS.name,
appState.playerName
);
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
renderRoster();
}
);
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
elements.aiLevelSelect?.addEventListener(
"change",
event => {
appState.aiLevel =
event.target.value;
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
render();
}
);
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
elements.playerColorSelect?.addEventListener(
"change",
event => {
appState.playerColor =
event.target.value;
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
if (appState.mode === "ai") {
2026-08-22 17:37:25 +00:00
resetLocalGame();
}
2026-08-22 17:51:45 +00:00
}
);
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
elements.aiTimeControl?.addEventListener(
"change",
event => {
appState.aiTimeControl =
event.target.value;
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
if (appState.mode === "ai") {
resetLocalGame();
2026-08-22 17:37:25 +00:00
}
2026-08-22 17:51:45 +00:00
}
);
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
elements.modeAiButton?.addEventListener(
"click",
async () => {
if (appState.party) {
await leaveParty(true);
return;
2026-08-22 17:37:25 +00:00
}
2026-08-22 17:51:45 +00:00
setMode("ai");
resetLocalGame();
}
);
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
elements.modePartyButton?.addEventListener(
"click",
() => {
setMode("party");
render();
}
);
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
elements.startAiButton?.addEventListener(
"click",
async () => {
if (appState.party) {
await leaveParty(true);
}
setMode("ai");
resetLocalGame();
showToast(
2026-08-22 18:20:15 +00:00
"Новая партия с ИИ готова.",
"success"
);
}
);
elements.newGameToolbarButton?.addEventListener(
"click",
async () => {
if (appState.mode === "party") {
showToast(
"Для новой партии создайте новую комнату."
);
return;
}
resetLocalGame();
showToast(
"Новая партия.",
2026-08-22 17:51:45 +00:00
"success"
);
}
);
elements.createPartyButton?.addEventListener(
"click",
createParty
);
2026-08-22 18:20:15 +00:00
elements.newPartyGameButton?.addEventListener(
"click",
createParty
);
2026-08-22 17:51:45 +00:00
elements.joinPartyButton?.addEventListener(
"click",
() =>
joinParty(
elements.partyCodeInput?.value
)
);
elements.leavePartyButton?.addEventListener(
"click",
() => leaveParty(false)
);
elements.copyInviteButton?.addEventListener(
"click",
copyInviteLink
);
elements.flipBoardButton?.addEventListener(
"click",
() => {
appState.orientation =
appState.orientation === "w"
? "b"
: "w";
renderBoard();
}
);
elements.partyCodeInput?.addEventListener(
"input",
event => {
event.target.value =
event.target.value
.toUpperCase()
.replace(
/[^A-Z0-9]/g,
""
2026-08-22 17:37:25 +00:00
);
2026-08-22 17:51:45 +00:00
}
);
2026-08-22 17:37:25 +00:00
2026-08-22 17:51:45 +00:00
elements.partyCodeInput?.addEventListener(
"keydown",
event => {
if (event.key !== "Enter") {
return;
2026-08-22 17:37:25 +00:00
}
2026-08-22 17:51:45 +00:00
event.preventDefault();
joinParty(
elements.partyCodeInput.value
);
}
);
elements.promotionDialog?.addEventListener(
"click",
event => {
if (
event.target ===
elements.promotionDialog
) {
closePromotionDialog();
}
}
);
window.addEventListener(
"beforeunload",
() => {
stopAiMoveTimer();
stopAiClock();
closePartyStream();
}
);
2026-08-22 17:37:25 +00:00
}
/* =========================================================
INIT
========================================================= */
async function init() {
bindEvents();
const savedName =
localStorage.getItem(
STORAGE_KEYS.name
);
if (savedName) {
appState.playerName =
savedName;
2026-08-22 17:51:45 +00:00
if (elements.playerNameInput) {
2026-08-22 17:37:25 +00:00
elements.playerNameInput.value =
savedName;
}
}
const params =
new URLSearchParams(
window.location.search
);
const partyCode =
params.get("party");
if (partyCode) {
setMode("party");
2026-08-22 17:51:45 +00:00
if (elements.partyCodeInput) {
2026-08-22 17:37:25 +00:00
elements.partyCodeInput.value =
partyCode.toUpperCase();
}
2026-08-22 17:51:45 +00:00
await joinParty(partyCode);
2026-08-22 17:37:25 +00:00
return;
}
setMode("ai");
resetLocalGame();
}
2026-08-22 17:51:45 +00:00
/* =========================================================
START
========================================================= */
2026-08-22 17:37:25 +00:00
init();