diff --git a/chess/public/js/app.js b/chess/public/js/app.js
index 8d050a6..d74ffbc 100644
--- a/chess/public/js/app.js
+++ b/chess/public/js/app.js
@@ -1,664 +1,2047 @@
-import { ChessEngine, ChessRules } from "/shared/chess-engine.js";
-import { chooseComputerMove } from "/js/ai.js";
+import { ChessEngine } from "/shared/chess-engine.js";
-const PIECE_GLYPHS = {
- w: { K: "♔", Q: "♕", R: "♖", B: "♗", N: "♘", P: "♙" },
- b: { K: "♚", Q: "♛", R: "♜", B: "♝", N: "♞", P: "♟" },
-};
-const STORAGE_KEYS = { name: "chess-party:name", partyIds: "chess-party:party-ids" };
-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 };
+const PIECES = {
-const elements = {
- modeBadge: document.querySelector("#modeBadge"),
- connectionBadge: document.querySelector("#connectionBadge"),
- board: document.querySelector("#board"),
- statusMessage: document.querySelector("#statusMessage"),
- turnBadge: document.querySelector("#turnBadge"),
- modeAiButton: document.querySelector("#modeAiButton"),
- modePartyButton: document.querySelector("#modePartyButton"),
- playerNameInput: document.querySelector("#playerNameInput"),
- aiControls: document.querySelector("#aiControls"),
- partyControls: document.querySelector("#partyControls"),
- aiLevelSelect: document.querySelector("#aiLevelSelect"),
- playerColorSelect: document.querySelector("#playerColorSelect"),
- aiTimeControl: document.querySelector("#aiClockSelect"),
- partyTimeControl: document.querySelector("#partyClockSelect"),
- startAiButton: document.querySelector("#startAiButton"),
- newGameToolbarButton: document.querySelector("#newGameToolbarButton"),
- createPartyButton: document.querySelector("#createPartyButton"),
- partyCodeInput: document.querySelector("#partyCodeInput"),
- joinPartyButton: document.querySelector("#joinPartyButton"),
- leavePartyButton: document.querySelector("#leavePartyButton"),
- partySummary: document.querySelector("#partySummary"),
- copyInviteButton: document.querySelector("#copyInviteButton"),
- flipBoardButton: document.querySelector("#flipBoardButton"),
- whitePlayerLabel: document.querySelector("#whitePlayerLabel"),
- blackPlayerLabel: document.querySelector("#blackPlayerLabel"),
- rosterWhite: document.querySelector("#rosterWhite"),
- rosterBlack: document.querySelector("#rosterBlack"),
- spectatorList: document.querySelector("#spectatorList"),
- spectatorCount: document.querySelector("#spectatorCount"),
- whiteClock: document.querySelector("#whiteClock"),
- blackClock: document.querySelector("#blackClock"),
- promotionDialog: document.querySelector("#promotionDialog"),
- promotionOptions: document.querySelector("#promotionOptions"),
- toastHost: document.querySelector("#toastHost"),
-};
+ w: {
+ k: "♔",
+ q: "♕",
+ r: "♖",
+ b: "♗",
+ n: "♘",
+ p: "♙"
+ },
-const appState = {
- mode: "ai", engine: new ChessEngine(), orientation: "w", selectedSquare: null,
- aiLevel: "intermediate", playerColor: "w", aiTimeControl: "10+0",
- isAiThinking: false, aiMoveTimer: null, aiClockTimer: null, pendingPromotionMoves: null,
- playerName: "Guest", party: null, partySnapshot: null, partyStream: null,
- gameOver: false, gameOverMessage: null,
- localClocks: { white: 0, black: 0, running: false, turn: "w", lastTick: 0 },
-};
-
-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;
- if (appState.party.role === "white") return "w";
- if (appState.party.role === "black") return "b";
- return null;
-}
-
-function isHumanTurn() {
- const color = currentHumanColor();
- const status = currentStatus();
- return Boolean(color && status.phase === "playing" && appState.engine.state.turn === color && !appState.isAiThinking && !appState.gameOver);
-}
-
-function getLegalMovesForSelected() {
- if (!appState.selectedSquare) return [];
- return appState.engine.getMovesFrom(appState.selectedSquare);
-}
-
-function clearSelection() {
- appState.selectedSquare = null;
- appState.pendingPromotionMoves = null;
-}
-
-function escapeHtml(value) {
- return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
-}
-
-function formatClock(seconds) {
- const total = Math.max(0, Math.ceil(Number(seconds) || 0));
- const minutes = Math.floor(total / 60);
- const secs = total % 60;
- if (minutes >= 60) {
- const hours = Math.floor(minutes / 60);
- return `${String(hours).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
+ b: {
+ k: "♚",
+ q: "♛",
+ r: "♜",
+ b: "♝",
+ n: "♞",
+ p: "♟"
}
- return `${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
+};
+
+
+const state = {
+
+ mode: "ai",
+
+ engine: new ChessEngine(),
+
+ orientation: "w",
+
+ selected: null,
+
+ legalMoves: [],
+
+ aiColor: "b",
+
+ aiThinking: false,
+
+ aiTimer: null,
+
+ gameStarted: false,
+
+ party: null,
+
+ partyClientId: null,
+
+ partyEvents: null,
+
+ promotionResolver: null,
+
+ clocks: {
+ white: 600,
+ black: 600,
+ running: false,
+ turn: "w"
+ },
+
+ clockTimer: null
+};
+
+
+const $ = id =>
+ document.getElementById(id);
+
+
+const boardElement =
+ $("board");
+
+const statusMessage =
+ $("statusMessage");
+
+const turnBadge =
+ $("turnBadge");
+
+const connectionBadge =
+ $("connectionBadge");
+
+const modeBadge =
+ $("modeBadge");
+
+const aiControls =
+ $("aiControls");
+
+const partyControls =
+ $("partyControls");
+
+const whitePlayerLabel =
+ $("whitePlayerLabel");
+
+const blackPlayerLabel =
+ $("blackPlayerLabel");
+
+const whiteClock =
+ $("whiteClock");
+
+const blackClock =
+ $("blackClock");
+
+const spectatorCount =
+ $("spectatorCount");
+
+const spectatorList =
+ $("spectatorList");
+
+const toastHost =
+ $("toastHost");
+
+const promotionDialog =
+ $("promotionDialog");
+
+const promotionOptions =
+ $("promotionOptions");
+
+
+const files =
+ "abcdefgh";
+
+
+function showToast(
+ message,
+ type = ""
+) {
+
+ const element =
+ document.createElement("div");
+
+ element.className =
+ `toast ${type}`;
+
+ element.textContent =
+ message;
+
+ toastHost.appendChild(
+ element
+ );
+
+ setTimeout(
+ () => element.remove(),
+ 3200
+ );
}
-function getIncrementForTimeControl(value) { return Number(String(value || "").split("+")[1] || 0); }
-function getTimeControlSeconds(value) { return TIME_CONTROLS[value] || 0; }
-function showToast(message, type = "info") {
- if (!elements.toastHost) return console.log(message);
- const toast = document.createElement("div");
- toast.className = `toast ${type}`;
- toast.textContent = message;
- elements.toastHost.appendChild(toast);
- window.setTimeout(() => toast.remove(), 3200);
+function setStatus(message) {
+
+ statusMessage.textContent =
+ message;
}
-function setMode(mode) {
- appState.mode = mode;
- const isAi = mode === "ai";
- const isParty = mode === "party";
- elements.modeAiButton?.classList.toggle("active", isAi);
- elements.modePartyButton?.classList.toggle("active", isParty);
- elements.aiControls?.classList.toggle("hidden", !isAi);
- elements.partyControls?.classList.toggle("hidden", !isParty);
- if (elements.modeBadge) elements.modeBadge.textContent = isAi ? "AI Arena" : "Party Lounge";
+
+function colorName(color) {
+
+ return color === "w"
+ ? "белых"
+ : "черных";
}
-function updatePartyUrl(code = null) {
- const url = new URL(window.location.href);
- if (code) url.searchParams.set("party", code);
- else url.searchParams.delete("party");
- window.history.replaceState({}, "", url);
+
+function getSquare(file, rank) {
+
+ return `${files[file]}${rank + 1}`;
}
+
+function getCoordinates(
+ index
+) {
+
+ if (state.orientation === "w") {
+
+ return {
+ file: index % 8,
+ rank: 7 - Math.floor(index / 8)
+ };
+ }
+
+ return {
+ file: 7 - index % 8,
+ rank: Math.floor(index / 8)
+ };
+}
+
+
function renderBoard() {
- if (!elements.board) return;
- elements.board.innerHTML = "";
-
- const selectedMoves = getLegalMovesForSelected();
- const moveTargets = new Map(selectedMoves.map(move => [move.to, move]));
- const orientation = appState.orientation;
-
- 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];
- const status = currentStatus();
-
- const checkColor = status.check ? appState.engine.state.turn : null;
- const lastMove = appState.engine.state.lastMove;
- for (const y of yOrder) {
- for (const x of xOrder) {
- const squareName = ChessRules.toSquare(x, y);
- const piece = appState.engine.state.board[y][x];
- const square = document.createElement("button");
-
- square.type = "button";
- square.className = `square ${(x + y) % 2 === 0 ? "dark" : "light"}`;
- square.dataset.square = squareName;
+ boardElement.innerHTML = "";
- if (appState.selectedSquare === squareName) square.classList.add("selected");
-
- const move = moveTargets.get(squareName);
- if (move) square.classList.add(move.capture ? "capture-target" : "legal-target");
-
- if (lastMove && (lastMove.from === squareName || lastMove.to === squareName)) square.classList.add("last-move");
- if (piece && piece.type === "K" && piece.color === checkColor) square.classList.add("check-square");
+ const snapshot =
+ state.engine.state;
- if (piece) {
- const pieceNode = document.createElement("span");
- pieceNode.className = `piece ${piece.color === "w" ? "white" : "black"}`;
- pieceNode.textContent = PIECE_GLYPHS[piece.color][piece.type];
- square.appendChild(pieceNode);
+ const status =
+ state.engine.getStatus();
+
+ let checkedKing = null;
+
+ if (
+ status.phase === "check"
+ ) {
+
+ for (
+ const [squareName, piece]
+ of Object.entries(
+ snapshot.board
+ )
+ ) {
+
+ if (
+ piece.type === "k" &&
+ piece.color === snapshot.turn
+ ) {
+ checkedKing = squareName;
+ break;
}
-
- square.addEventListener("click", () => handleSquareClick(squareName));
- elements.board.appendChild(square);
}
}
-}
-function formatStatus() {
- if (appState.gameOver) return getGameOverMessage();
- const status = currentStatus();
- if (status.phase === "checkmate") return "Checkmate";
- if (status.phase === "draw") return "Draw";
- if (status.check) return appState.engine.state.turn === "w" ? "White is in check" : "Black is in check";
- return appState.engine.state.turn === "w" ? "White to move" : "Black to move";
-}
+ const legalMap =
+ new Map();
-function getGameOverMessage() {
- const snapshot = appState.partySnapshot;
- if (!snapshot) return appState.gameOverMessage || "Game over.";
- const { gameOverReason: reason, winner } = snapshot;
- switch (reason) {
- case "timeout": return winner === "w" ? "White wins on time." : "Black wins on time.";
- case "checkmate": return winner === "w" ? "White wins by checkmate." : "Black wins by checkmate.";
- case "draw": return "Draw.";
- default: return appState.gameOverMessage || "Game over.";
- }
-}
+ for (
+ const move
+ of state.legalMoves
+ ) {
-function renderRoster() {
- if (appState.mode === "ai") {
- const human = appState.playerName || "You";
- const whiteName = appState.playerColor === "w" ? human : `Computer (${appState.aiLevel})`;
- const blackName = appState.playerColor === "b" ? human : `Computer (${appState.aiLevel})`;
- if (elements.whitePlayerLabel) elements.whitePlayerLabel.textContent = whiteName;
- if (elements.blackPlayerLabel) elements.blackPlayerLabel.textContent = blackName;
- if (elements.rosterWhite) elements.rosterWhite.textContent = whiteName;
- if (elements.rosterBlack) elements.rosterBlack.textContent = blackName;
- if (elements.spectatorList) elements.spectatorList.innerHTML = "
None in AI mode";
- if (elements.spectatorCount) elements.spectatorCount.textContent = "0 / 20";
- return;
- }
- const white = appState.partySnapshot?.players?.white;
- const black = appState.partySnapshot?.players?.black;
- const whiteName = white ? `${white.name}${white.connected ? "" : " · offline"}` : "Open seat";
- const blackName = black ? `${black.name}${black.connected ? "" : " · offline"}` : "Open seat";
-
- if (elements.whitePlayerLabel) elements.whitePlayerLabel.textContent = whiteName;
- if (elements.blackPlayerLabel) elements.blackPlayerLabel.textContent = blackName;
- if (elements.rosterWhite) elements.rosterWhite.textContent = whiteName;
- if (elements.rosterBlack) elements.rosterBlack.textContent = blackName;
-
- const spectators = appState.partySnapshot?.spectators || [];
- if (elements.spectatorCount) elements.spectatorCount.textContent = `${spectators.length} / 20`;
- if (!elements.spectatorList) return;
-
- if (!spectators.length) elements.spectatorList.innerHTML = "None yet";
- else elements.spectatorList.innerHTML = spectators.map(s => `${escapeHtml(s.name)}${s.connected ? "" : " · offline"}`).join("");
-}
-
-function renderPartySummary() {
- if (!elements.partySummary) return;
- if (!appState.party) {
- elements.partySummary.textContent = "Create a room to become White. The next player joins as Black. Up to 20 spectators can watch live.";
- return;
- }
- const role = appState.party.role;
- const roleText = role === "white" ? "playing as White" : role === "black" ? "playing as Black" : "watching as a spectator";
- const timeLabel = appState.partySnapshot?.timeControl?.label || "Без часов";
- elements.partySummary.textContent = `Party ${appState.party.code} · ${roleText}. ${timeLabel}.`;
-}
-
-function renderClocks() {
- let clocks, timeControl;
- if (appState.mode === "party") {
- clocks = appState.partySnapshot?.clocks;
- timeControl = appState.partySnapshot?.timeControl;
- } else {
- clocks = appState.localClocks;
- timeControl = { initial: getTimeControlSeconds(appState.aiTimeControl), label: elements.aiTimeControl?.selectedOptions?.[0]?.textContent || "Без часов" };
+ legalMap.set(
+ move.to,
+ move
+ );
}
- const enabled = Boolean(timeControl?.initial > 0);
- if (!enabled) {
- if (elements.whiteClock) { elements.whiteClock.textContent = "∞"; elements.whiteClock.classList.remove("active", "low"); }
- if (elements.blackClock) { elements.blackClock.textContent = "∞"; elements.blackClock.classList.remove("active", "low"); }
- return;
- }
- const white = clocks?.white ?? 0, 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("low", white <= 10);
- }
- if (elements.blackClock) {
- elements.blackClock.textContent = formatClock(black);
- elements.blackClock.classList.toggle("active", clocks?.turn === "b" && clocks?.running);
- elements.blackClock.classList.toggle("low", black <= 10);
- }
-}
+ for (
+ let index = 0;
+ index < 64;
+ index++
+ ) {
-function renderConnectionBadge() {
- if (!elements.connectionBadge) return;
- if (appState.mode === "ai") {
- elements.connectionBadge.textContent = appState.isAiThinking ? "Computer thinking" : `AI: ${appState.aiLevel}`;
- return;
- }
- if (!appState.party) { elements.connectionBadge.textContent = "Party idle"; return; }
- const role = appState.party.role === "white" ? "White" : appState.party.role === "black" ? "Black" : "Spectator";
- elements.connectionBadge.textContent = `Party ${appState.party.code} · ${role}`;
-}
+ const {
+ file,
+ rank
+ } = getCoordinates(index);
-function render() {
- renderBoard();
- renderRoster();
- renderPartySummary();
- renderClocks();
- renderConnectionBadge();
+ const squareName =
+ getSquare(file, rank);
- const status = formatStatus();
- if (elements.turnBadge) elements.turnBadge.textContent = status;
- if (elements.statusMessage) elements.statusMessage.textContent = status;
-
- elements.copyInviteButton?.classList.toggle("hidden", !appState.party);
- elements.leavePartyButton?.classList.toggle("hidden", !appState.party);
-}
+ const element =
+ document.createElement("button");
-function openPromotionDialog(moves) {
- appState.pendingPromotionMoves = moves;
- if (!elements.promotionOptions) return;
- elements.promotionOptions.innerHTML = "";
-
- for (const move of moves) {
- const button = document.createElement("button");
- button.type = "button";
- button.className = "promotion-button";
- const promotion = String(move.promotion || "Q").toUpperCase();
- button.textContent = PIECE_GLYPHS[move.color][promotion];
-
- button.addEventListener("click", () => {
- closePromotionDialog();
- submitMove(move);
- });
- elements.promotionOptions.appendChild(button);
- }
- elements.promotionDialog?.classList.remove("hidden");
-}
+ element.type =
+ "button";
-function closePromotionDialog() {
- appState.pendingPromotionMoves = null;
- elements.promotionDialog?.classList.add("hidden");
-}
+ element.className =
+ "square";
-async function submitMove(move) {
- if (appState.gameOver) return;
- clearSelection();
-
- if (appState.mode === "party" && appState.party) {
- const response = await postJson("/api/party/move", {
- partyCode: appState.party.code, clientId: appState.party.clientId,
- from: move.from, to: move.to, promotion: move.promotion || null,
- });
-
- if (!response.ok) {
- if (response.party) hydratePartyState(response.party, false);
- showToast(response.error || "Move rejected.", "error");
- return;
+ if (
+ (file + rank) % 2 === 0
+ ) {
+ element.classList.add(
+ "light"
+ );
+ } else {
+ element.classList.add(
+ "dark"
+ );
}
- hydratePartyState(response.party, false);
- return;
- }
- const result = appState.engine.makeMove({ from: move.from, to: move.to, promotion: move.promotion || null });
- if (!result.ok) {
- showToast(result.error || "Illegal move.", "error");
- return;
- }
+ if (
+ state.selected === squareName
+ ) {
+ element.classList.add(
+ "selected"
+ );
+ }
- updateAiClockAfterMove();
- render();
- scheduleAiTurn();
+ if (
+ snapshot.lastMove &&
+ (
+ snapshot.lastMove.from === squareName ||
+ snapshot.lastMove.to === squareName
+ )
+ ) {
+ element.classList.add(
+ "last-move"
+ );
+ }
+
+ if (
+ checkedKing === squareName
+ ) {
+ element.classList.add(
+ "check-square"
+ );
+ }
+
+ const legal =
+ legalMap.get(
+ squareName
+ );
+
+ if (legal) {
+
+ if (legal.capture) {
+
+ element.classList.add(
+ "capture-target"
+ );
+
+ } else {
+
+ element.classList.add(
+ "legal-target"
+ );
+ }
+ }
+
+ const piece =
+ snapshot.board[squareName];
+
+ if (piece) {
+
+ const span =
+ document.createElement("span");
+
+ span.className =
+ `piece ${
+ piece.color === "w"
+ ? "white"
+ : "black"
+ }`;
+
+ span.textContent =
+ PIECES[
+ piece.color
+ ][
+ piece.type
+ ];
+
+ element.appendChild(
+ span
+ );
+ }
+
+ element.dataset.square =
+ squareName;
+
+ element.addEventListener(
+ "click",
+ () => handleSquareClick(squareName)
+ );
+
+ boardElement.appendChild(
+ element
+ );
+ }
}
-function handleSquareClick(square) {
- if (appState.pendingPromotionMoves || appState.gameOver) return;
- if (!isHumanTurn()) {
+
+function canUserMove() {
+
+ if (
+ !state.gameStarted
+ ) {
+ return false;
+ }
+
+ const status =
+ state.engine.getStatus();
+
+ if (
+ status.phase === "checkmate" ||
+ status.phase === "draw"
+ ) {
+ return false;
+ }
+
+ if (
+ state.aiThinking
+ ) {
+ return false;
+ }
+
+ if (
+ state.mode === "party"
+ ) {
+
+ if (!state.party) {
+ return false;
+ }
+
+ const role =
+ state.party.you?.role;
+
+ if (
+ role !== "white" &&
+ role !== "black"
+ ) {
+ return false;
+ }
+
+ return (
+ state.engine.state.turn ===
+ (
+ role === "white"
+ ? "w"
+ : "b"
+ )
+ );
+ }
+
+ return (
+ state.engine.state.turn !==
+ state.aiColor
+ );
+}
+
+
+function handleSquareClick(
+ squareName
+) {
+
+ if (!canUserMove()) {
+ return;
+ }
+
+ const piece =
+ state.engine.getPiece(
+ squareName
+ );
+
+ if (!state.selected) {
+
+ if (
+ piece &&
+ piece.color ===
+ state.engine.state.turn
+ ) {
+
+ selectSquare(
+ squareName
+ );
+ }
+
+ return;
+ }
+
+
+ if (
+ state.selected === squareName
+ ) {
+
clearSelection();
- renderBoard();
+
return;
}
- const piece = appState.engine.getPiece(square);
- if (appState.selectedSquare) {
- const matchingMoves = getLegalMovesForSelected().filter(move => move.to === square);
- if (matchingMoves.length === 1) { submitMove(matchingMoves[0]); return; }
- if (matchingMoves.length > 1) { openPromotionDialog(matchingMoves); return; }
- }
- const humanColor = currentHumanColor();
- if (piece && piece.color === humanColor && piece.color === appState.engine.state.turn) {
- appState.selectedSquare = appState.selectedSquare === square ? null : square;
- renderBoard();
+ const move =
+ state.legalMoves.find(
+ item =>
+ item.to === squareName
+ );
+
+ if (move) {
+
+ if (
+ move.promotion
+ ) {
+
+ openPromotion(
+ move,
+ promotion =>
+ performMove(
+ move.from,
+ move.to,
+ promotion
+ )
+ );
+
+ } else {
+
+ performMove(
+ move.from,
+ move.to,
+ null
+ );
+ }
+
return;
}
+
+
+ if (
+ piece &&
+ piece.color ===
+ state.engine.state.turn
+ ) {
+
+ selectSquare(
+ squareName
+ );
+
+ return;
+ }
+
clearSelection();
+}
+
+
+function selectSquare(
+ squareName
+) {
+
+ state.selected =
+ squareName;
+
+ state.legalMoves =
+ state.engine.movesFrom(
+ squareName
+ );
+
renderBoard();
}
-function scheduleAiTurn() {
- stopAiMoveTimer();
- if (appState.mode !== "ai") return;
-
- const status = currentStatus();
- if (!status || status.phase !== "playing" || appState.gameOver || appState.engine.state.turn === appState.playerColor) {
- appState.isAiThinking = false;
- render();
+
+function clearSelection() {
+
+ state.selected =
+ null;
+
+ state.legalMoves =
+ [];
+
+ renderBoard();
+}
+
+
+function openPromotion(
+ move,
+ callback
+) {
+
+ promotionOptions.innerHTML =
+ "";
+
+ const color =
+ state.engine.state.turn;
+
+ for (
+ const type
+ of ["q", "r", "b", "n"]
+ ) {
+
+ const button =
+ document.createElement("button");
+
+ button.type =
+ "button";
+
+ button.textContent =
+ PIECES[color][type];
+
+ button.addEventListener(
+ "click",
+ () => {
+
+ promotionDialog.classList.add(
+ "hidden"
+ );
+
+ state.promotionResolver =
+ null;
+
+ callback(type);
+ }
+ );
+
+ promotionOptions.appendChild(
+ button
+ );
+ }
+
+ promotionDialog.classList.remove(
+ "hidden"
+ );
+}
+
+
+function performMove(
+ from,
+ to,
+ promotion
+) {
+
+ if (
+ state.mode === "party"
+ ) {
+
+ performPartyMove(
+ from,
+ to,
+ promotion
+ );
+
return;
}
- appState.isAiThinking = true;
- render();
- appState.aiMoveTimer = window.setTimeout(() => {
- appState.aiMoveTimer = null;
- if (appState.mode !== "ai" || appState.gameOver) {
- appState.isAiThinking = false;
- render();
- return;
+ const result =
+ state.engine.makeMove({
+ from,
+ to,
+ promotion
+ });
+
+ if (!result.ok) {
+
+ showToast(
+ result.error,
+ "error"
+ );
+
+ clearSelection();
+
+ return;
+ }
+
+ clearSelection();
+
+ state.gameStarted =
+ true;
+
+ updateGameUI();
+
+ if (
+ result.status.phase ===
+ "checkmate" ||
+ result.status.phase ===
+ "draw"
+ ) {
+
+ finishGame();
+
+ return;
+ }
+
+
+ if (
+ state.engine.state.turn ===
+ state.aiColor
+ ) {
+
+ scheduleAiMove();
+ }
+}
+
+
+function scheduleAiMove() {
+
+ if (
+ state.aiThinking
+ ) {
+ return;
+ }
+
+ state.aiThinking =
+ true;
+
+ setStatus(
+ "Компьютер думает..."
+ );
+
+ renderBoard();
+
+ clearTimeout(
+ state.aiTimer
+ );
+
+ state.aiTimer =
+ setTimeout(
+ () => {
+
+ try {
+ makeAiMove();
+ } finally {
+ state.aiThinking =
+ false;
+ }
+
+ },
+ getAiDelay()
+ );
+}
+
+
+function getAiDelay() {
+
+ const level =
+ $("aiLevelSelect").value;
+
+ if (level === "easy") {
+ return 350;
+ }
+
+ if (level === "hard") {
+ return 800;
+ }
+
+ return 550;
+}
+
+
+function makeAiMove() {
+
+ if (
+ state.mode !== "ai"
+ ) {
+ return;
+ }
+
+ const status =
+ state.engine.getStatus();
+
+ if (
+ status.phase === "checkmate" ||
+ status.phase === "draw"
+ ) {
+ return;
+ }
+
+ const legal =
+ state.engine.legalMoves(
+ state.aiColor
+ );
+
+ if (!legal.length) {
+ updateGameUI();
+ return;
+ }
+
+ const level =
+ $("aiLevelSelect").value;
+
+ let move;
+
+ if (level === "easy") {
+
+ move =
+ legal[
+ Math.floor(
+ Math.random() *
+ legal.length
+ )
+ ];
+
+ } else {
+
+ move =
+ chooseAiMove(
+ legal
+ );
+ }
+
+ const result =
+ state.engine.makeMove({
+ from: move.from,
+ to: move.to,
+ promotion:
+ move.promotion || null
+ });
+
+ if (!result.ok) {
+
+ showToast(
+ "ИИ не смог выполнить ход.",
+ "error"
+ );
+
+ return;
+ }
+
+ updateGameUI();
+
+ if (
+ result.status.phase ===
+ "checkmate" ||
+ result.status.phase ===
+ "draw"
+ ) {
+
+ finishGame();
+ }
+}
+
+
+function chooseAiMove(
+ moves
+) {
+
+ let bestScore =
+ -Infinity;
+
+ let bestMoves = [];
+
+
+ for (const move of moves) {
+
+ let score =
+ Math.random() * 0.4;
+
+ if (move.capture) {
+ score += 3;
}
- const move = chooseComputerMove(appState.engine, appState.aiLevel);
- appState.isAiThinking = false;
- if (!move) { render(); return; }
- submitMove(move);
- }, 400);
+ if (move.promotion) {
+ score += 8;
+ }
+
+ if (
+ move.to[1] === "4" ||
+ move.to[1] === "5"
+ ) {
+ score += .15;
+ }
+
+ if (score > bestScore) {
+
+ bestScore =
+ score;
+
+ bestMoves = [
+ move
+ ];
+
+ } else if (
+ Math.abs(
+ score - bestScore
+ ) < .1
+ ) {
+
+ bestMoves.push(
+ move
+ );
+ }
+ }
+
+ return bestMoves[
+ Math.floor(
+ Math.random() *
+ bestMoves.length
+ )
+ ];
}
-function stopAiMoveTimer() {
- if (appState.aiMoveTimer) { window.clearTimeout(appState.aiMoveTimer); appState.aiMoveTimer = null; }
- appState.isAiThinking = false;
+
+function finishGame() {
+
+ state.aiThinking =
+ false;
+
+ clearTimeout(
+ state.aiTimer
+ );
+
+ updateGameUI();
}
-function startAiClock() {
- stopAiClock();
- const seconds = getTimeControlSeconds(appState.aiTimeControl);
- appState.localClocks = { white: seconds, black: seconds, running: seconds > 0, turn: appState.engine.state.turn, lastTick: Date.now() };
- render();
- if (!seconds) return;
- appState.aiClockTimer = window.setInterval(() => { updateAiClock(); renderClocks(); }, 250);
+function updateGameUI() {
+
+ const status =
+ state.engine.getStatus();
+
+ const turn =
+ state.engine.state.turn;
+
+ turnBadge.textContent =
+ `Ход ${colorName(turn)}`;
+
+ turnBadge.className =
+ `status-chip ${
+ turn === "w"
+ ? "turn-white"
+ : "turn-black"
+ }`;
+
+
+ if (
+ status.phase === "checkmate"
+ ) {
+
+ setStatus(
+ `Мат. Победили ${colorName(status.winner)}.`
+ );
+
+ } else if (
+ status.phase === "draw"
+ ) {
+
+ setStatus(
+ drawMessage(
+ status.reason
+ )
+ );
+
+ } else if (
+ status.phase === "check"
+ ) {
+
+ setStatus(
+ `Шах — ход ${colorName(turn)}.`
+ );
+
+ } else if (
+ state.mode === "party"
+ ) {
+
+ const role =
+ state.party?.you?.role;
+
+ if (
+ role === "spectator"
+ ) {
+
+ setStatus(
+ `Ход ${colorName(turn)}. Вы зритель.`
+ );
+
+ } else {
+
+ const myColor =
+ role === "white"
+ ? "w"
+ : "b";
+
+ setStatus(
+ myColor === turn
+ ? "Ваш ход."
+ : "Ход соперника."
+ );
+ }
+
+ } else if (
+ state.gameStarted
+ ) {
+
+ setStatus(
+ turn === state.aiColor
+ ? "Ход компьютера."
+ : "Ваш ход."
+ );
+
+ } else {
+
+ setStatus(
+ "Новая игра готова."
+ );
+ }
+
+ renderBoard();
+
+ updateClocks();
}
-function stopAiClock() {
- if (appState.aiClockTimer) { window.clearInterval(appState.aiClockTimer); appState.aiClockTimer = null; }
-}
-function updateAiClock() {
- if (appState.mode !== "ai" || appState.gameOver || !appState.localClocks.running) return;
- const clocks = appState.localClocks;
- const now = Date.now();
- const elapsed = Math.max(0, (now - clocks.lastTick) / 1000);
- if (!elapsed) return;
+function drawMessage(
+ reason
+) {
- clocks.lastTick = now;
- const key = clocks.turn === "w" ? "white" : "black";
- clocks[key] = Math.max(0, clocks[key] - elapsed);
+ switch (reason) {
- if (clocks[key] <= 0) {
- clocks.running = false;
- appState.gameOver = true;
- appState.gameOverMessage = clocks.turn === "w" ? "Black wins on time." : "White wins on time.";
- stopAiClock();
- showToast(appState.gameOverMessage, "error");
- render();
+ case "stalemate":
+ return "Ничья — пат.";
+
+ case "50-move":
+ return "Ничья — правило 50 ходов.";
+
+ case "threefold":
+ return "Ничья — троекратное повторение.";
+
+ case "insufficient-material":
+ return "Ничья — недостаточно материала.";
+
+ default:
+ return "Ничья.";
}
}
-function updateAiClockAfterMove() {
- const clocks = appState.localClocks;
- if (!clocks.running) return;
- updateAiClock();
- if (appState.gameOver) return;
- const key = clocks.turn === "w" ? "white" : "black";
- clocks[key] += getIncrementForTimeControl(appState.aiTimeControl);
- clocks.turn = appState.engine.state.turn;
- clocks.lastTick = Date.now();
-}
+function startNewAiGame() {
-function resetLocalGame() {
- stopAiMoveTimer();
- stopAiClock();
- closePromotionDialog();
- appState.engine = new ChessEngine();
- appState.partySnapshot = null;
- appState.orientation = appState.playerColor;
- appState.gameOver = false;
- appState.gameOverMessage = null;
- startAiClock();
- scheduleAiTurn();
- render();
-}
+ state.mode =
+ "ai";
-async function postJson(url, body) {
- 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, error: `Server returned ${response.status} instead of JSON.` };
- return await response.json();
- } catch (error) {
- return { ok: false, error: error instanceof Error ? error.message : "Network error." };
+ state.engine =
+ new ChessEngine();
+
+ state.selected =
+ null;
+
+ state.legalMoves =
+ [];
+
+ state.gameStarted =
+ true;
+
+ state.aiThinking =
+ false;
+
+ clearTimeout(
+ state.aiTimer
+ );
+
+ const selectedColor =
+ $("playerColorSelect").value;
+
+ state.aiColor =
+ selectedColor === "w"
+ ? "b"
+ : "w";
+
+ state.orientation =
+ selectedColor;
+
+ setupAiPlayers();
+
+ resetClockFromSelect(
+ $("aiClockSelect").value
+ );
+
+ updateGameUI();
+
+ if (
+ state.aiColor === "w"
+ ) {
+
+ scheduleAiMove();
}
}
-function hydratePartyState(partyPayload, announce = true) {
- if (!partyPayload) return;
- const previousCode = appState.party?.code;
- const previousClientId = appState.party?.clientId;
- appState.party = {
- code: partyPayload.code,
- clientId: partyPayload.you?.id || previousClientId || null,
- role: partyPayload.you?.role || appState.party?.role || "spectator",
+function setupAiPlayers() {
+
+ const name =
+ getPlayerName();
+
+ if (
+ state.aiColor === "b"
+ ) {
+
+ whitePlayerLabel.textContent =
+ name;
+
+ blackPlayerLabel.textContent =
+ "Компьютер";
+
+ } else {
+
+ whitePlayerLabel.textContent =
+ "Компьютер";
+
+ blackPlayerLabel.textContent =
+ name;
+ }
+}
+
+
+function getPlayerName() {
+
+ const value =
+ $("playerNameInput")
+ .value
+ .trim();
+
+ return value || "Вы";
+}
+
+
+function resetClockFromSelect(
+ value
+) {
+
+ const seconds =
+ parseClock(
+ value
+ );
+
+ state.clocks = {
+ white: seconds,
+ black: seconds,
+ running:
+ seconds > 0,
+ turn: "w"
};
- appState.partySnapshot = partyPayload;
- appState.engine = new ChessEngine(partyPayload.game);
- appState.gameOver = Boolean(partyPayload.gameOverReason);
- appState.gameOverMessage = appState.gameOver ? getGameOverMessage() : null;
-
- if (appState.party.role === "white") appState.orientation = "w";
- else if (appState.party.role === "black") appState.orientation = "b";
-
- setMode("party");
- updatePartyUrl(partyPayload.code);
- render();
-
- if (announce && partyPayload.gameOverReason) showToast(getGameOverMessage(), "success");
+ updateClocks();
}
-function connectPartyStream() {
- if (!appState.party) return;
- closePartyStream();
-
- const { code, clientId } = appState.party;
- const url = `/api/party/events?partyCode=${encodeURIComponent(code)}&clientId=${encodeURIComponent(clientId)}`;
- const stream = new EventSource(url);
-
- stream.addEventListener("party", event => {
- try { hydratePartyState(JSON.parse(event.data), false); } catch (error) { console.error("Party SSE error:", error); }
- });
-
- stream.onerror = () => {
- if (appState.mode === "party" && elements.connectionBadge) elements.connectionBadge.textContent = `Party ${code} · reconnecting`;
- };
- appState.partyStream = stream;
+
+function parseClock(
+ value
+) {
+
+ if (
+ value === "none"
+ ) {
+ return 0;
+ }
+
+ const [minutes] =
+ value.split("+");
+
+ return (
+ Number(minutes) *
+ 60
+ );
}
-function closePartyStream() {
- if (appState.partyStream) { appState.partyStream.close(); appState.partyStream = null; }
+
+function updateClocks() {
+
+ const white =
+ state.clocks.white;
+
+ const black =
+ state.clocks.black;
+
+ whiteClock.textContent =
+ formatTime(white);
+
+ blackClock.textContent =
+ formatTime(black);
+
+ whiteClock.classList.toggle(
+ "active",
+ state.clocks.running &&
+ state.clocks.turn === "w"
+ );
+
+ blackClock.classList.toggle(
+ "active",
+ state.clocks.running &&
+ state.clocks.turn === "b"
+ );
+
+ whiteClock.classList.toggle(
+ "low",
+ white > 0 &&
+ white < 30
+ );
+
+ blackClock.classList.toggle(
+ "low",
+ black > 0 &&
+ black < 30
+ );
}
+
+function formatTime(
+ seconds
+) {
+
+ if (!seconds) {
+ return "—";
+ }
+
+ const value =
+ Math.max(
+ 0,
+ Math.ceil(seconds)
+ );
+
+ const minutes =
+ Math.floor(
+ value / 60
+ );
+
+ const remaining =
+ value % 60;
+
+ return (
+ String(minutes)
+ .padStart(2, "0") +
+ ":" +
+ String(remaining)
+ .padStart(2, "0")
+ );
+}
+
+
+function startClockTimer() {
+
+ clearInterval(
+ state.clockTimer
+ );
+
+ state.clockTimer =
+ setInterval(
+ () => {
+
+ if (
+ !state.clocks.running
+ ) {
+ return;
+ }
+
+ if (
+ state.clocks.white <= 0 ||
+ state.clocks.black <= 0
+ ) {
+ return;
+ }
+
+ const key =
+ state.clocks.turn === "w"
+ ? "white"
+ : "black";
+
+ state.clocks[key] =
+ Math.max(
+ 0,
+ state.clocks[key] -
+ 1
+ );
+
+ updateClocks();
+
+ },
+ 1000
+ );
+}
+
+
+function switchMode(
+ mode
+) {
+
+ if (
+ mode === "ai"
+ ) {
+
+ state.mode =
+ "ai";
+
+ modeAiButton.classList.add(
+ "active"
+ );
+
+ modePartyButton.classList.remove(
+ "active"
+ );
+
+ aiControls.classList.remove(
+ "hidden"
+ );
+
+ partyControls.classList.add(
+ "hidden"
+ );
+
+ modeBadge.textContent =
+ "AI Арена";
+
+ connectionBadge.textContent =
+ "Соло";
+
+ connectionBadge.className =
+ "status-chip connection-solo";
+
+ return;
+ }
+
+
+ state.mode =
+ "party";
+
+ modePartyButton.classList.add(
+ "active"
+ );
+
+ modeAiButton.classList.remove(
+ "active"
+ );
+
+ aiControls.classList.add(
+ "hidden"
+ );
+
+ partyControls.classList.remove(
+ "hidden"
+ );
+
+ modeBadge.textContent =
+ "Party";
+
+ connectionBadge.textContent =
+ "Онлайн";
+
+ connectionBadge.className =
+ "status-chip connection-party";
+
+ state.gameStarted =
+ false;
+
+ state.engine =
+ new ChessEngine();
+
+ state.selected =
+ null;
+
+ state.legalMoves =
+ [];
+
+ updateGameUI();
+}
+
+
async function createParty() {
- if (appState.party) await leaveParty(true);
- const timeControl = elements.partyTimeControl?.value || "10+0";
- const response = await postJson("/api/party/create", { name: appState.playerName, timeControl });
- if (!response.ok) { showToast(response.error || "Unable to create party.", "error"); return; }
-
- setStoredPartyId(response.party.code, response.clientId);
- hydratePartyState(response.party, false);
- connectPartyStream();
- showToast(`Party ${response.party.code} created.`, "success");
-}
-async function joinParty(code) {
- const normalizedCode = String(code || "").trim().toUpperCase();
- if (!normalizedCode) { showToast("Enter a party code first.", "error"); return; }
- if (appState.party?.code === normalizedCode) { showToast(`You are already in party ${normalizedCode}.`); return; }
- if (appState.party) await leaveParty(true);
+ const name =
+ getPlayerName();
- const storedIds = getStoredPartyIds();
- const response = await postJson("/api/party/join", { name: appState.playerName, partyCode: normalizedCode, clientId: storedIds[normalizedCode] || null });
-
- if (!response.ok) { showToast(response.error || "Unable to join party.", "error"); return; }
- setStoredPartyId(response.party.code, response.clientId);
- hydratePartyState(response.party, false);
- connectPartyStream();
- showToast(`Joined party ${response.party.code}.`, "success");
-}
+ try {
-async function leaveParty(silent = false) {
- if (!appState.party) return;
- const party = appState.party;
- closePartyStream();
- await postJson("/api/party/leave", { partyCode: party.code, clientId: party.clientId });
- removeStoredPartyId(party.code);
-
- appState.party = null; appState.partySnapshot = null;
- updatePartyUrl(null);
- setMode("ai");
- resetLocalGame();
- if (!silent) showToast("Left the party room.");
-}
+ const response =
+ await fetch(
+ "/api/party/create",
+ {
+ method: "POST",
-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("Invite link copied.", "success"); }
- catch { showToast(`Party code: ${appState.party.code}`); }
-}
+ headers: {
+ "Content-Type":
+ "application/json"
+ },
-function getStoredPartyIds() {
- try { return JSON.parse(localStorage.getItem(STORAGE_KEYS.partyIds) || "{}"); } catch { return {}; }
-}
-function setStoredPartyId(code, clientId) {
- if (!code || !clientId) return;
- const ids = getStoredPartyIds();
- ids[code] = clientId;
- localStorage.setItem(STORAGE_KEYS.partyIds, JSON.stringify(ids));
-}
-function removeStoredPartyId(code) {
- if (!code) return;
- const ids = getStoredPartyIds();
- delete ids[code];
- localStorage.setItem(STORAGE_KEYS.partyIds, JSON.stringify(ids));
-}
+ body: JSON.stringify({
+ name,
+ timeControl:
+ $("partyClockSelect")
+ .value
+ })
+ }
+ );
-function bindEvents() {
- elements.playerNameInput?.addEventListener("input", event => {
- appState.playerName = event.target.value.trim() || "Guest";
- localStorage.setItem(STORAGE_KEYS.name, appState.playerName);
- renderRoster();
- });
+ const data =
+ await response.json();
- elements.aiLevelSelect?.addEventListener("change", event => { appState.aiLevel = event.target.value; render(); });
- elements.playerColorSelect?.addEventListener("change", event => { appState.playerColor = event.target.value; if (appState.mode === "ai") resetLocalGame(); });
- elements.aiTimeControl?.addEventListener("change", event => { appState.aiTimeControl = event.target.value; if (appState.mode === "ai") resetLocalGame(); });
+ if (!data.ok) {
+ throw new Error(
+ data.error
+ );
+ }
- elements.modeAiButton?.addEventListener("click", async () => {
- if (appState.party) await leaveParty(true);
- setMode("ai"); resetLocalGame();
- });
-
- elements.modePartyButton?.addEventListener("click", () => { setMode("party"); render(); });
-
- elements.startAiButton?.addEventListener("click", async () => {
- if (appState.party) await leaveParty(true);
- setMode("ai"); resetLocalGame();
- showToast("New AI game ready.", "success");
- });
-
- // Binding the top toolbar New Game button
- elements.newGameToolbarButton?.addEventListener("click", async () => {
- if (appState.party) await leaveParty(true);
- setMode("ai"); resetLocalGame();
- showToast("Новая игра", "success");
- });
+ state.party =
+ data.party;
- elements.createPartyButton?.addEventListener("click", createParty);
- 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();
- });
+ state.partyClientId =
+ data.party.you?.id ||
+ null;
- elements.partyCodeInput?.addEventListener("input", event => event.target.value = event.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ""));
- elements.partyCodeInput?.addEventListener("keydown", event => { if (event.key === "Enter") { event.preventDefault(); joinParty(elements.partyCodeInput.value); }});
- elements.promotionDialog?.addEventListener("click", event => { if (event.target === elements.promotionDialog) closePromotionDialog(); });
- window.addEventListener("beforeunload", () => { stopAiMoveTimer(); stopAiClock(); closePartyStream(); });
-}
+ state.engine =
+ createEngineFromSnapshot(
+ data.party.game
+ );
-async function init() {
- bindEvents();
- const savedName = localStorage.getItem(STORAGE_KEYS.name);
- if (savedName) {
- appState.playerName = savedName;
- if (elements.playerNameInput) elements.playerNameInput.value = savedName;
+ state.gameStarted =
+ true;
+
+ state.orientation =
+ "w";
+
+ $("partyCodeInput").value =
+ data.party.code;
+
+ $("leavePartyButton")
+ .classList.remove(
+ "hidden"
+ );
+
+ $("copyInviteButton")
+ .classList.remove(
+ "hidden"
+ );
+
+ $("partySummary")
+ .textContent =
+ `Комната ${data.party.code}. Вы играете белыми.`;
+
+ applyPartyState(
+ data.party
+ );
+
+ connectPartyEvents();
+
+ showToast(
+ `Комната ${data.party.code} создана.`,
+ "success"
+ );
+
+ } catch (error) {
+
+ showToast(
+ error.message ||
+ "Не удалось создать комнату.",
+ "error"
+ );
}
- const params = new URLSearchParams(window.location.search);
- const partyCode = params.get("party");
- if (partyCode) {
- setMode("party");
- if (elements.partyCodeInput) elements.partyCodeInput.value = partyCode.toUpperCase();
- await joinParty(partyCode);
+}
+
+
+async function joinParty() {
+
+ const code =
+ $("partyCodeInput")
+ .value
+ .trim()
+ .toUpperCase();
+
+ if (
+ !/^[A-Z0-9]{6}$/.test(code)
+ ) {
+
+ showToast(
+ "Введите 6-значный код комнаты.",
+ "error"
+ );
+
return;
}
- setMode("ai");
- resetLocalGame();
+
+ try {
+
+ const response =
+ await fetch(
+ "/api/party/join",
+ {
+ method: "POST",
+
+ headers: {
+ "Content-Type":
+ "application/json"
+ },
+
+ body: JSON.stringify({
+ partyCode: code,
+ name:
+ getPlayerName(),
+ clientId:
+ state.partyClientId
+ })
+ }
+ );
+
+ const data =
+ await response.json();
+
+ if (!data.ok) {
+ throw new Error(
+ data.error
+ );
+ }
+
+ state.party =
+ data.party;
+
+ state.partyClientId =
+ data.party.you?.id ||
+ null;
+
+ state.gameStarted =
+ true;
+
+ applyPartyState(
+ data.party
+ );
+
+ $("leavePartyButton")
+ .classList.remove(
+ "hidden"
+ );
+
+ $("copyInviteButton")
+ .classList.remove(
+ "hidden"
+ );
+
+ connectPartyEvents();
+
+ showToast(
+ `Вы вошли в комнату ${code}.`,
+ "success"
+ );
+
+ } catch (error) {
+
+ showToast(
+ error.message ||
+ "Не удалось войти в комнату.",
+ "error"
+ );
+ }
}
-init();
+function createEngineFromSnapshot(
+ snapshot
+) {
+
+ const engine =
+ new ChessEngine(
+ snapshot.fen
+ );
+
+ engine.state.lastMove =
+ snapshot.lastMove
+ ? {
+ ...snapshot.lastMove
+ }
+ : null;
+
+ return engine;
+}
+
+
+function applyPartyState(
+ party
+) {
+
+ state.party =
+ party;
+
+ state.engine =
+ createEngineFromSnapshot(
+ party.game
+ );
+
+ state.clocks = {
+ ...party.clocks
+ };
+
+ state.selected =
+ null;
+
+ state.legalMoves =
+ [];
+
+ state.gameStarted =
+ true;
+
+ updatePartyPlayers();
+
+ updateSpectators();
+
+ updateGameUI();
+}
+
+
+function updatePartyPlayers() {
+
+ const players =
+ state.party?.players || {};
+
+ whitePlayerLabel.textContent =
+ players.white?.name ||
+ "Ожидание игрока";
+
+ blackPlayerLabel.textContent =
+ players.black?.name ||
+ "Ожидание игрока";
+
+ if (
+ state.party?.you?.role ===
+ "white"
+ ) {
+
+ state.orientation =
+ "w";
+
+ } else if (
+ state.party?.you?.role ===
+ "black"
+ ) {
+
+ state.orientation =
+ "b";
+ }
+
+ $("partySummary")
+ .textContent =
+ state.party?.code
+ ? `Комната ${state.party.code}`
+ : "Создайте комнату.";
+}
+
+
+function updateSpectators() {
+
+ const spectators =
+ state.party?.spectators ||
+ [];
+
+ spectatorCount.textContent =
+ `${spectators.length} / ${
+ state.party?.maxSpectators || 20
+ }`;
+
+ spectatorList.innerHTML =
+ "";
+
+ if (!spectators.length) {
+
+ const li =
+ document.createElement("li");
+
+ li.className =
+ "empty-state";
+
+ li.textContent =
+ "Пока никого нет";
+
+ spectatorList.appendChild(
+ li
+ );
+
+ return;
+ }
+
+ for (
+ const spectator
+ of spectators
+ ) {
+
+ const li =
+ document.createElement("li");
+
+ li.textContent =
+ spectator.name;
+
+ spectatorList.appendChild(
+ li
+ );
+ }
+}
+
+
+function connectPartyEvents() {
+
+ if (
+ state.partyEvents
+ ) {
+ state.partyEvents.close();
+ }
+
+ if (
+ !state.party?.code ||
+ !state.partyClientId
+ ) {
+ return;
+ }
+
+ const params =
+ new URLSearchParams({
+ partyCode:
+ state.party.code,
+
+ clientId:
+ state.partyClientId
+ });
+
+ const source =
+ new EventSource(
+ `/api/party/events?${params}`
+ );
+
+ state.partyEvents =
+ source;
+
+ source.addEventListener(
+ "party",
+ event => {
+
+ try {
+
+ const party =
+ JSON.parse(
+ event.data
+ );
+
+ applyPartyState(
+ party
+ );
+
+ } catch {
+ showToast(
+ "Ошибка обновления игры.",
+ "error"
+ );
+ }
+ }
+ );
+
+ source.onerror =
+ () => {
+ connectionBadge.textContent =
+ "Переподключение...";
+
+ connectionBadge.className =
+ "status-chip connection-solo";
+ };
+}
+
+
+async function performPartyMove(
+ from,
+ to,
+ promotion
+) {
+
+ if (
+ !state.party ||
+ !state.partyClientId
+ ) {
+ 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.partyClientId,
+
+ from,
+ to,
+
+ promotion:
+ promotion || null
+ })
+ }
+ );
+
+ const data =
+ await response.json();
+
+ if (
+ data.party
+ ) {
+ applyPartyState(
+ data.party
+ );
+ }
+
+ if (!data.ok) {
+
+ showToast(
+ data.error ||
+ "Ход отклонён.",
+ "error"
+ );
+ }
+
+ } catch (error) {
+
+ showToast(
+ error.message ||
+ "Ошибка соединения.",
+ "error"
+ );
+ }
+}
+
+
+async function leaveParty() {
+
+ if (
+ !state.party ||
+ !state.partyClientId
+ ) {
+ return;
+ }
+
+ try {
+
+ await fetch(
+ "/api/party/leave",
+ {
+ method: "POST",
+
+ headers: {
+ "Content-Type":
+ "application/json"
+ },
+
+ body: JSON.stringify({
+ partyCode:
+ state.party.code,
+
+ clientId:
+ state.partyClientId
+ })
+ }
+ );
+
+ } catch {
+ // ignore
+ }
+
+ if (
+ state.partyEvents
+ ) {
+
+ state.partyEvents.close();
+
+ state.partyEvents =
+ null;
+ }
+
+ state.party =
+ null;
+
+ state.partyClientId =
+ null;
+
+ $("leavePartyButton")
+ .classList.add(
+ "hidden"
+ );
+
+ $("copyInviteButton")
+ .classList.add(
+ "hidden"
+ );
+
+ $("partySummary")
+ .textContent =
+ "Создайте комнату, чтобы играть белыми.";
+
+ state.engine =
+ new ChessEngine();
+
+ state.gameStarted =
+ false;
+
+ state.selected =
+ null;
+
+ state.legalMoves =
+ [];
+
+ switchMode("party");
+
+ updateGameUI();
+}
+
+
+async function copyInvite() {
+
+ if (
+ !state.party?.code
+ ) {
+ return;
+ }
+
+ const url =
+ `${location.origin}${location.pathname}?party=${state.party.code}`;
+
+ try {
+
+ await navigator.clipboard.writeText(
+ url
+ );
+
+ showToast(
+ "Ссылка скопирована.",
+ "success"
+ );
+
+ } catch {
+
+ showToast(
+ `Код комнаты: ${state.party.code}`
+ );
+ }
+}
+
+
+function loadPartyFromUrl() {
+
+ const code =
+ new URLSearchParams(
+ location.search
+ ).get("party");
+
+ if (code) {
+
+ switchMode("party");
+
+ $("partyCodeInput").value =
+ code.toUpperCase();
+ }
+}
+
+
+function flipBoard() {
+
+ state.orientation =
+ state.orientation === "w"
+ ? "b"
+ : "w";
+
+ renderBoard();
+}
+
+
+function bindEvents() {
+
+ $("modeAiButton")
+ .addEventListener(
+ "click",
+ () => switchMode("ai")
+ );
+
+ $("modePartyButton")
+ .addEventListener(
+ "click",
+ () => switchMode("party")
+ );
+
+ $("startAiButton")
+ .addEventListener(
+ "click",
+ startNewAiGame
+ );
+
+ $("newGameToolbarButton")
+ .addEventListener(
+ "click",
+ () => {
+
+ if (
+ state.mode === "party"
+ ) {
+
+ showToast(
+ "В Party новую игру создаёт владелец комнаты."
+ );
+
+ return;
+ }
+
+ startNewAiGame();
+ }
+ );
+
+ $("flipBoardButton")
+ .addEventListener(
+ "click",
+ flipBoard
+ );
+
+ $("createPartyButton")
+ .addEventListener(
+ "click",
+ createParty
+ );
+
+ $("joinPartyButton")
+ .addEventListener(
+ "click",
+ joinParty
+ );
+
+ $("leavePartyButton")
+ .addEventListener(
+ "click",
+ leaveParty
+ );
+
+ $("copyInviteButton")
+ .addEventListener(
+ "click",
+ copyInvite
+ );
+
+ promotionDialog.addEventListener(
+ "click",
+ event => {
+
+ if (
+ event.target ===
+ promotionDialog
+ ) {
+
+ promotionDialog.classList.add(
+ "hidden"
+ );
+ }
+ }
+ );
+}
+
+
+function initialize() {
+
+ bindEvents();
+
+ state.engine =
+ new ChessEngine();
+
+ state.orientation =
+ "w";
+
+ state.gameStarted =
+ false;
+
+ resetClockFromSelect(
+ $("aiClockSelect").value
+ );
+
+ setupAiPlayers();
+
+ updateGameUI();
+
+ startClockTimer();
+
+ loadPartyFromUrl();
+}
+
+
+initialize();