diff --git a/chess/public/js/app.js b/chess/public/js/app.js
index 4b9285e..9e335ba 100644
--- a/chess/public/js/app.js
+++ b/chess/public/js/app.js
@@ -1,2370 +1,538 @@
-import {
- ChessEngine,
- ChessRules,
-} from "/shared/chess-engine.js";
+import { ChessEngine, VALUES } 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: "♟",
- },
+const PIECES = {
+ w: { k:"♔", q:"♕", r:"♖", b:"♗", n:"♘", p:"♙" },
+ b: { k:"♚", q:"♛", r:"♜", b:"♝", n:"♞", p:"♟" }
};
+const $ = (id) => document.getElementById(id);
-/* =========================================================
- STORAGE
-========================================================= */
-
-const STORAGE_KEYS = {
- name: "chess-party:name",
- partyIds: "chess-party:party-ids",
+const state = {
+ mode: null,
+ engine: new ChessEngine(),
+ orientation: "w",
+ selected: null,
+ legal: [],
+ party: null,
+ clientId: localStorage.getItem("chess-client-id") || crypto.randomUUID(),
+ eventSource: null,
+ pendingPromotion: null,
+ aiBusy: false,
+ localGameOver: false
};
+localStorage.setItem("chess-client-id", state.clientId);
-/* =========================================================
- 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,
-};
-
-
-/* =========================================================
- ELEMENTS
-========================================================= */
-
-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"),
- newPartyGameButton: document.querySelector("#newPartyGameButton"),
-
- 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"),
-
- capturedByWhite: document.querySelector("#capturedByWhite"),
- capturedByBlack: document.querySelector("#capturedByBlack"),
-
- moveHistory: document.querySelector("#moveHistory"),
-
- promotionDialog: document.querySelector("#promotionDialog"),
- promotionOptions: document.querySelector("#promotionOptions"),
-
- toastHost: document.querySelector("#toastHost"),
-};
-
-
-/* =========================================================
- STATE
-========================================================= */
-
-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,
- },
-};
-
-
-/* =========================================================
- HELPERS
-========================================================= */
-
-function currentStatus() {
- return (
- appState.engine.state.status ||
- ChessEngine.evaluateStatus(
- appState.engine.state
- )
- );
+function show(view) {
+ $("homeView").classList.toggle("hidden", view !== "home");
+ $("gameView").classList.toggle("hidden", view !== "game");
}
-
-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 toast(text) {
+ const el = $("toast");
+ el.textContent = text;
+ el.classList.add("show");
+ clearTimeout(toast.timer);
+ toast.timer = setTimeout(() => el.classList.remove("show"), 2200);
}
-
-function isHumanTurn() {
- const color = currentHumanColor();
- const status = currentStatus();
-
- return Boolean(
- color &&
- status.phase === "playing" &&
- appState.engine.state.turn === color &&
- !appState.isAiThinking &&
- !appState.gameOver
- );
+function resetSelection() {
+ state.selected = null;
+ state.legal = [];
}
-
-function isFinished() {
- const status = currentStatus();
-
- return Boolean(
- appState.gameOver ||
- status.phase === "checkmate" ||
- status.phase === "draw"
- );
+function boardOrder() {
+ const files = "abcdefgh".split("");
+ const ranks = [8,7,6,5,4,3,2,1];
+ if (state.orientation === "b") {
+ files.reverse();
+ ranks.reverse();
+ }
+ return { files, ranks };
}
-
-function getLegalMovesForSelected() {
- if (!appState.selectedSquare) {
- return [];
- }
-
- return appState.engine.getMovesFrom(
- appState.selectedSquare
- );
+function snapshot() {
+ return state.mode === "party" ? state.party?.game : state.engine.getSnapshot();
}
-
-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);
- const mins = minutes % 60;
-
- 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")}`
- );
-}
-
-
-function getIncrementForTimeControl(value) {
- const parts = String(value || "").split("+");
- return Number(parts[1] || 0);
-}
-
-
-function getTimeControlSeconds(value) {
- return TIME_CONTROLS[value] || 0;
-}
-
-
-/* =========================================================
- TOAST
-========================================================= */
-
-function showToast(message, type = "info") {
- if (!elements.toastHost) {
- console.log(message);
- return;
- }
-
- const toast = document.createElement("div");
-
- toast.className = `toast ${type}`;
- toast.textContent = message;
-
- elements.toastHost.appendChild(toast);
-
- window.setTimeout(() => {
- toast.remove();
- }, 3200);
-}
-
-
-/* =========================================================
- MODE
-========================================================= */
-
-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";
- }
-}
-
-
-/* =========================================================
- URL
-========================================================= */
-
-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
- );
-}
-
-
-/* =========================================================
- BOARD
-========================================================= */
-
function renderBoard() {
- if (!elements.board) {
- return;
- }
-
- elements.board.innerHTML = "";
-
- const selectedMoves =
- getLegalMovesForSelected();
-
- const moveTargets = new Map();
-
- for (const move of selectedMoves) {
- moveTargets.set(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";
-
- /*
- * ВАЖНО:
- * Теперь цвет клетки задаётся непосредственно
- * через .light / .dark.
- */
- const isDark =
- (x + y) % 2 === 0;
-
- square.className =
- `square ${
- isDark ? "dark" : "light"
- }`;
-
- square.dataset.square =
- squareName;
-
- /* Selected */
- if (
- appState.selectedSquare ===
- squareName
- ) {
- square.classList.add(
- "selected"
- );
- }
-
- /* Legal move */
- const move =
- moveTargets.get(squareName);
-
- if (move) {
- square.classList.add(
- move.capture
- ? "capture-target"
- : "legal-target"
- );
- }
-
- /* Last move */
- if (
- lastMove &&
- (
- lastMove.from === squareName ||
- lastMove.to === squareName
- )
- ) {
- square.classList.add(
- "last-move"
- );
- }
-
- /* Check */
- if (
- piece &&
- piece.type === "K" &&
- piece.color === checkColor
- ) {
- square.classList.add(
- "check-square"
- );
- }
-
- /* Piece */
- if (piece) {
- const pieceNode =
- document.createElement("span");
-
- pieceNode.className =
- `piece ${
- piece.color === "w"
- ? "white"
- : "black"
- }`;
-
- pieceNode.textContent =
- PIECE_GLYPHS[
- piece.color
- ][piece.type];
-
- pieceNode.setAttribute(
- "aria-hidden",
- "true"
- );
-
- square.appendChild(
- pieceNode
- );
- }
-
- square.addEventListener(
- "click",
- () => handleSquareClick(
- squareName
- )
- );
-
- elements.board.appendChild(
- square
- );
- }
+ const snap = snapshot();
+ if (!snap) return;
+
+ const board = $("board");
+ board.innerHTML = "";
+ const { files, ranks } = boardOrder();
+
+ const legalMap = new Map(state.legal.map(m => [m.to, m]));
+ const last = snap.lastMove;
+
+ for (const rank of ranks) {
+ for (const file of files) {
+ const name = `${file}${rank}`;
+ const f = "abcdefgh".indexOf(file);
+ const r = rank - 1;
+ const el = document.createElement("button");
+ el.type = "button";
+ el.className = `square ${((f+r)%2===0) ? "light" : "dark"}`;
+ el.dataset.square = name;
+
+ if (state.selected === name) el.classList.add("selected");
+ if (state.selected && state.legal.some(m => m.to === name)) {
+ el.classList.add(snap.board[name] ? "capture" : "legal");
+ }
+ if (last?.from === name) el.classList.add("last-from");
+ if (last?.to === name) el.classList.add("last-to");
+ if (state.selected && state.legal.some(m => m.to === name)) el.classList.add("trajectory");
+
+ const piece = snap.board[name];
+ if (piece) {
+ const span = document.createElement("span");
+ span.className = "piece";
+ span.textContent = PIECES[piece.color][piece.type];
+ el.appendChild(span);
+ }
+
+ const fileLabel = document.createElement("span");
+ fileLabel.className = "coord file";
+ fileLabel.textContent = file;
+ el.appendChild(fileLabel);
+
+ const rankLabel = document.createElement("span");
+ rankLabel.className = "coord rank";
+ rankLabel.textContent = rank;
+ el.appendChild(rankLabel);
+
+ el.addEventListener("click", () => handleSquare(name));
+ board.appendChild(el);
}
+ }
}
-
-/* =========================================================
- GAME STATUS
-========================================================= */
-
-function formatStatus() {
- if (appState.gameOver) {
- return getGameOverMessage();
- }
-
- const status = currentStatus();
-
- if (status.phase === "checkmate") {
- return "Мат";
- }
-
- if (status.phase === "draw") {
- return "Ничья";
- }
-
- if (status.check) {
- return appState.engine.state.turn === "w"
- ? "Белые под шахом"
- : "Чёрные под шахом";
- }
-
- return appState.engine.state.turn === "w"
- ? "Ход белых"
- : "Ход чёрных";
+function canUserMove() {
+ if (state.mode === "ai") return state.engine.state.turn === "w" && !state.aiBusy && !state.localGameOver;
+ if (state.mode === "party") {
+ return state.party?.you?.role === (state.party.game.turn === "w" ? "white" : "black")
+ && !state.party.gameOverReason;
+ }
+ return false;
}
+function handleSquare(name) {
+ if (!canUserMove()) return;
-function getGameOverMessage() {
- const snapshot =
- appState.partySnapshot;
+ const snap = snapshot();
+ const piece = snap.board[name];
- if (!snapshot) {
- return (
- appState.gameOverMessage ||
- "Игра окончена."
- );
+ if (state.selected) {
+ const candidate = state.legal.filter(m => m.to === name);
+ if (candidate.length) {
+ if (candidate.some(m => m.promotion)) {
+ state.pendingPromotion = { from: state.selected, to: name };
+ $("promotionModal").classList.remove("hidden");
+ } else {
+ playMove(state.selected, name, candidate[0].promotion || null);
+ }
+ return;
}
+ }
- const reason =
- snapshot.gameOverReason;
-
- const winner =
- snapshot.winner;
-
- switch (reason) {
- case "timeout":
- return winner === "w"
- ? "Белые победили по времени."
- : "Чёрные победили по времени.";
-
- case "checkmate":
- return winner === "w"
- ? "Белые победили матом."
- : "Чёрные победили матом.";
-
- case "draw":
- return "Ничья.";
-
- default:
- return (
- appState.gameOverMessage ||
- "Игра окончена."
- );
- }
+ if (piece && piece.color === snap.turn) {
+ state.selected = name;
+ if (state.mode === "ai") state.legal = state.engine.movesFrom(name);
+ else state.legal = state.partyEngineMoves(name);
+ } else {
+ resetSelection();
+ }
+ renderBoard();
+ updateStatus();
}
-
-/* =========================================================
- ROSTER
-========================================================= */
-
-function renderRoster() {
- if (appState.mode === "ai") {
- const human =
- appState.playerName || "Вы";
-
- const whiteName =
- appState.playerColor === "w"
- ? human
- : `Компьютер (${appState.aiLevel})`;
-
- const blackName =
- appState.playerColor === "b"
- ? human
- : `Компьютер (${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 =
- "
В режиме AI зрителей нет";
- }
-
- 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
- ? ""
- : " · офлайн"
- }`
- : "Свободно";
-
- const blackName =
- black
- ? `${black.name}${
- black.connected
- ? ""
- : " · офлайн"
- }`
- : "Свободно";
-
- 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 =
- "Пока никого нет";
-
- return;
- }
-
- elements.spectatorList.innerHTML =
- spectators
- .map(
- spectator =>
- `${escapeHtml(
- spectator.name
- )}${
- spectator.connected
- ? ""
- : " · офлайн"
- }`
- )
- .join("");
+function partyEngineMoves(from) {
+ // The server is authoritative. The snapshot contains enough board state to
+ // calculate normal movement hints locally; the server validates the final move.
+ const temp = new ChessEngine();
+ temp.state = JSON.parse(JSON.stringify({
+ board: state.party.game.board,
+ turn: state.party.game.turn,
+ castling: state.party.game.castling,
+ ep: state.party.game.ep,
+ halfmove: state.party.game.halfmove,
+ fullmove: state.party.game.fullmove,
+ lastMove: state.party.game.lastMove
+ }));
+ return temp.movesFrom(from);
}
+async function playMove(from, to, promotion = null) {
+ resetSelection();
+ $("promotionModal").classList.add("hidden");
-/* =========================================================
- 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 =
- "Ходов пока нет";
-
- return;
- }
-
- elements.moveHistory.innerHTML =
- history
- .map((move, index) => {
- if (typeof move === "string") {
- return `${escapeHtml(move)}`;
- }
-
- 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 (
- `${escapeHtml(
- `${index + 1}. ${text}`
- )}`
- );
- })
- .join("");
-}
-
-
-/* =========================================================
- PARTY SUMMARY
-========================================================= */
-
-function renderPartySummary() {
- if (!elements.partySummary) {
- return;
- }
-
- if (!appState.party) {
- elements.partySummary.textContent =
- "Создайте комнату, чтобы играть белыми. Следующий игрок присоединится за чёрных. До 20 зрителей.";
-
- return;
- }
-
- const role =
- appState.party.role;
-
- const roleText =
- role === "white"
- ? "вы играете белыми"
- : role === "black"
- ? "вы играете чёрными"
- : "вы зритель";
-
- const timeLabel =
- appState.partySnapshot
- ?.timeControl?.label ||
- "Без часов";
-
- elements.partySummary.textContent =
- `Комната ${appState.party.code} · ${roleText} · ${timeLabel}.`;
-}
-
-
-/* =========================================================
- CLOCKS
-========================================================= */
-
-function renderClocks() {
- let clocks;
- let 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 ||
- "Без часов",
- };
- }
-
- const enabled =
- Boolean(
- timeControl?.initial > 0
- );
-
- if (!enabled) {
- if (elements.whiteClock) {
- elements.whiteClock.textContent = "∞";
-
- elements.whiteClock.classList.remove(
- "active",
- "low",
- "danger"
- );
- }
-
- if (elements.blackClock) {
- elements.blackClock.textContent = "∞";
-
- elements.blackClock.classList.remove(
- "active",
- "low",
- "danger"
- );
- }
-
- 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(
- "low",
- white <= 10
- );
-
- elements.whiteClock.classList.toggle(
- "danger",
- white <= 5
- );
- }
-
- 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
- );
-
- elements.blackClock.classList.toggle(
- "danger",
- black <= 5
- );
- }
-}
-
-
-/* =========================================================
- CONNECTION BADGE
-========================================================= */
-
-function renderConnectionBadge() {
- if (!elements.connectionBadge) {
- return;
- }
-
- if (appState.mode === "ai") {
- elements.connectionBadge.textContent =
- appState.isAiThinking
- ? "Компьютер думает"
- : `AI · ${appState.aiLevel}`;
-
- elements.connectionBadge.className =
- "status-chip connection-solo";
-
- return;
- }
-
- if (!appState.party) {
- elements.connectionBadge.textContent =
- "Party ожидание";
-
- elements.connectionBadge.className =
- "status-chip connection-solo";
-
- return;
- }
-
- const role =
- appState.party.role === "white"
- ? "Белые"
- : appState.party.role === "black"
- ? "Чёрные"
- : "Зритель";
-
- elements.connectionBadge.textContent =
- `${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"
- );
-}
-
-
-/* =========================================================
- RENDER
-========================================================= */
-
-function render() {
- renderBoard();
- renderRoster();
- renderPartySummary();
- renderClocks();
- renderConnectionBadge();
- renderTurnBadge();
- renderCaptured();
- renderMoveHistory();
-
- 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
- );
-}
-
-
-/* =========================================================
- PROMOTION
-========================================================= */
-
-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 ||
- appState.engine.state.turn
- ][promotion];
-
- button.addEventListener(
- "click",
- () => {
- closePromotionDialog();
- submitMove(move);
- }
- );
-
- elements.promotionOptions.appendChild(
- button
- );
- }
-
- elements.promotionDialog?.classList.remove(
- "hidden"
- );
-}
-
-
-function closePromotionDialog() {
- appState.pendingPromotionMoves = null;
-
- elements.promotionDialog?.classList.add(
- "hidden"
- );
-}
-
-
-/* =========================================================
- MOVE
-========================================================= */
-
-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 ||
- "Ход отклонён.",
- "error"
- );
-
- return;
- }
-
- hydratePartyState(
- response.party,
- false
- );
-
- return;
- }
-
- const result =
- appState.engine.makeMove({
- from: move.from,
- to: move.to,
- promotion:
- move.promotion || null,
- });
-
+ if (state.mode === "ai") {
+ const result = state.engine.makeMove({ from, to, promotion: promotion || "q" });
if (!result.ok) {
- showToast(
- result.error ||
- "Недопустимый ход.",
- "error"
- );
-
- return;
+ toast(result.error);
+ renderBoard();
+ return;
}
+ renderAll();
+ if (!state.engine.getStatus().phase.match(/checkmate|draw/)) {
+ state.aiBusy = true;
+ renderAll();
+ setTimeout(aiMove, 220);
+ }
+ return;
+ }
- updateAiClockAfterMove();
-
- render();
-
- scheduleAiTurn();
+ try {
+ const response = await fetch("/api/party/move", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ partyCode: state.party.code,
+ clientId: state.clientId,
+ from, to, promotion: promotion || "q"
+ })
+ });
+ const data = await response.json();
+ if (!data.ok) toast(data.error || "Ход не выполнен.");
+ if (data.party) applyParty(data.party);
+ } catch {
+ toast("Нет соединения с сервером.");
+ }
}
+function aiMove() {
+ const moves = state.engine.legalMoves("b");
+ if (!moves.length) {
+ state.aiBusy = false;
+ renderAll();
+ return;
+ }
-/* =========================================================
- BOARD INPUT
-========================================================= */
+ // Small deterministic evaluation: captures, promotion, checks, center.
+ let best = moves[0];
+ let bestScore = -Infinity;
-function handleSquareClick(square) {
- if (
- appState.pendingPromotionMoves ||
- appState.gameOver
- ) {
- return;
- }
+ for (const move of moves) {
+ const piece = state.engine.state.board[move.from];
+ const captured = state.engine.state.board[move.to];
+ let score = Math.random() * 0.15;
+ if (captured) score += VALUES[captured.type] * 2.5;
+ if (move.promotion) score += 9;
+ if (["d4","e4","d5","e5"].includes(move.to)) score += .5;
+ if (piece?.type === "p" && Math.abs(Number(move.to[1]) - Number(move.from[1])) === 2) score += .1;
+ if (score > bestScore) { bestScore = score; best = move; }
+ }
- if (!isHumanTurn()) {
- 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();
-
- return;
- }
-
- clearSelection();
- renderBoard();
+ state.engine.makeMove({
+ from: best.from,
+ to: best.to,
+ promotion: best.promotion || "q"
+ });
+ state.aiBusy = false;
+ renderAll();
}
+function renderMoves(snap) {
+ const list = $("movesList");
+ list.innerHTML = "";
+ const moves = snap.moves || [];
-/* =========================================================
- AI
-========================================================= */
-
-function scheduleAiTurn() {
- stopAiMoveTimer();
-
- if (appState.mode !== "ai") {
- return;
- }
-
- const status = currentStatus();
-
- if (
- !status ||
- status.phase !== "playing" ||
- appState.gameOver
- ) {
- appState.isAiThinking = false;
- render();
- return;
- }
-
- if (
- appState.engine.state.turn ===
- appState.playerColor
- ) {
- appState.isAiThinking = false;
- render();
- return;
- }
-
- appState.isAiThinking = true;
- render();
-
- appState.aiMoveTimer =
- window.setTimeout(
- () => {
- appState.aiMoveTimer = null;
-
- if (
- appState.mode !== "ai" ||
- appState.gameOver
- ) {
- appState.isAiThinking = false;
- render();
- return;
- }
-
- const move =
- chooseComputerMove(
- appState.engine,
- appState.aiLevel
- );
-
- appState.isAiThinking = false;
-
- if (!move) {
- render();
- return;
- }
-
- submitMove(move);
- },
- 400
- );
+ for (let i = 0; i < moves.length; i += 2) {
+ const row = document.createElement("div");
+ row.className = "move";
+ row.innerHTML = `
+ ${Math.floor(i/2)+1}.
+ ${escapeHtml(moves[i]?.san || "")}
+ ${escapeHtml(moves[i+1]?.san || "")}
+ `;
+ list.appendChild(row);
+ }
+ $("moveCount").textContent = moves.length;
+ list.scrollTop = list.scrollHeight;
}
-
-function stopAiMoveTimer() {
- if (appState.aiMoveTimer) {
- window.clearTimeout(
- appState.aiMoveTimer
- );
-
- appState.aiMoveTimer = null;
- }
-
- appState.isAiThinking = false;
+function escapeHtml(s) {
+ return String(s).replace(/[&<>"']/g, c => ({
+ "&":"&","<":"<",">":">",'"':""","'":"'"
+ }[c]));
}
-
-/* =========================================================
- AI CLOCK
-========================================================= */
-
-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 renderMaterial(snap) {
+ const mat = snap.material || { captured:{w:[],b:[]},score:{w:0,b:0} };
+ const icon = (color, type) => PIECES[color][type];
+ $("material").innerHTML = `
+ ${mat.captured.w.map(p => icon("w",p)).join("") || "—"}
+ Δ ${mat.score.w - mat.score.b}
+ ${mat.captured.b.map(p => icon("b",p)).join("") || "—"}
+ `;
}
+function updateStatus() {
+ const snap = snapshot();
+ if (!snap) return;
-function stopAiClock() {
- if (appState.aiClockTimer) {
- window.clearInterval(
- appState.aiClockTimer
- );
+ const status = snap.status || { phase:"playing", turn:snap.turn };
+ let text = state.selected ? "Выберите клетку назначения." : "Выберите фигуру.";
- appState.aiClockTimer = null;
- }
+ if (status.phase === "check") text = "Шах.";
+ if (status.phase === "checkmate") text = "Мат.";
+ if (status.phase === "draw") text = `Ничья: ${status.reason || "правило"}.`;
+ if (state.mode === "ai" && state.aiBusy) text = "ИИ думает…";
+ if (state.mode === "ai" && state.localGameOver) text = "Партия завершена сдачей.";
+ if (state.mode === "party" && state.party?.gameOverReason === "resign") text = "Партия завершена сдачей.";
+ if (state.mode === "party" && state.party?.gameOverReason === "timeout") text = "Время закончилось.";
+ if (state.mode === "party" && state.party?.drawOffer) text = `${state.party.drawOffer.name} предложил(а) ничью.`;
+
+ $("gameStatus").textContent = text;
+ $("turnBadge").textContent = snap.turn === "w" ? "Ход белых" : "Ход чёрных";
}
+function renderPlayers() {
+ if (state.mode === "ai") {
+ $("whiteName").textContent = "Вы";
+ $("blackName").textContent = "ИИ";
+ $("whiteMeta").textContent = "Белые";
+ $("blackMeta").textContent = "Чёрные";
+ $("whiteClock").textContent = "—";
+ $("blackClock").textContent = "—";
+ return;
+ }
-function updateAiClock() {
- if (
- appState.mode !== "ai" ||
- appState.gameOver
- ) {
- return;
- }
-
- const clocks =
- appState.localClocks;
-
- if (!clocks.running) {
- return;
- }
-
- const now = Date.now();
-
- const elapsed =
- Math.max(
- 0,
- (now - clocks.lastTick) / 1000
- );
-
- if (!elapsed) {
- return;
- }
-
- clocks.lastTick = now;
-
- const color =
- clocks.turn;
-
- const key =
- color === "w"
- ? "white"
- : "black";
-
- clocks[key] =
- Math.max(
- 0,
- clocks[key] - elapsed
- );
-
- if (clocks[key] <= 0) {
- clocks.running = false;
-
- appState.gameOver = true;
-
- appState.gameOverMessage =
- color === "w"
- ? "Чёрные победили по времени."
- : "Белые победили по времени.";
-
- stopAiClock();
-
- showToast(
- appState.gameOverMessage,
- "error"
- );
-
- render();
- }
+ const p = state.party;
+ $("whiteName").textContent = p.players.white?.name || "Ожидание…";
+ $("blackName").textContent = p.players.black?.name || "Ожидание…";
+ $("whiteMeta").textContent = p.players.white?.connected ? "Белые • онлайн" : "Белые • офлайн";
+ $("blackMeta").textContent = p.players.black?.connected ? "Чёрные • онлайн" : "Чёрные • офлайн";
+ $("whiteClock").textContent = formatClock(p.clocks.white, p.timeControl.initial);
+ $("blackClock").textContent = formatClock(p.clocks.black, p.timeControl.initial);
}
-
-function updateAiClockAfterMove() {
- const clocks =
- appState.localClocks;
-
- if (!clocks.running) {
- return;
- }
-
- updateAiClock();
-
- if (appState.gameOver) {
- return;
- }
-
- const previousTurn =
- clocks.turn;
-
- const key =
- previousTurn === "w"
- ? "white"
- : "black";
-
- clocks[key] +=
- getIncrementForTimeControl(
- appState.aiTimeControl
- );
-
- clocks.turn =
- appState.engine.state.turn;
-
- clocks.lastTick =
- Date.now();
+function formatClock(seconds, enabled) {
+ if (!enabled) return "—";
+ const total = Math.max(0, Math.ceil(seconds));
+ const m = Math.floor(total / 60);
+ const s = total % 60;
+ return `${String(m).padStart(2,"0")}:${String(s).padStart(2,"0")}`;
}
-
-/* =========================================================
- LOCAL GAME
-========================================================= */
-
-function resetLocalGame() {
- stopAiMoveTimer();
- stopAiClock();
-
- closePromotionDialog();
-
- appState.engine =
- new ChessEngine();
-
- appState.partySnapshot = null;
- appState.selectedSquare = null;
-
- appState.orientation =
- appState.playerColor;
-
- appState.gameOver = false;
- appState.gameOverMessage = null;
-
- startAiClock();
- scheduleAiTurn();
+function renderAll() {
+ renderBoard();
+ renderMoves(snapshot());
+ renderMaterial(snapshot());
+ renderPlayers();
+ updateStatus();
}
-
-/* =========================================================
- API
-========================================================= */
-
-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:
- `Сервер вернул ${response.status} вместо JSON.`,
- };
- }
-
- return await response.json();
- } catch (error) {
- return {
- ok: false,
-
- error:
- error instanceof Error
- ? error.message
- : "Ошибка сети.",
- };
- }
+function startAI() {
+ closeParty();
+ state.mode = "ai";
+ state.engine = new ChessEngine();
+ state.orientation = "w";
+ state.localGameOver = false;
+ resetSelection();
+ $("chatPanel").classList.add("hidden");
+ show("game");
+ renderAll();
}
-
-/* =========================================================
- PARTY STATE
-========================================================= */
-
-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",
- };
-
- appState.partySnapshot =
- partyPayload;
-
- appState.engine =
- new ChessEngine(
- partyPayload.game
- );
-
- appState.selectedSquare = null;
-
- 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"
- );
- }
-
- if (
- previousCode &&
- previousCode !== partyPayload.code
- ) {
- console.info(
- "Party changed:",
- previousCode,
- "→",
- partyPayload.code
- );
- }
-}
-
-
-/* =========================================================
- PARTY SSE
-========================================================= */
-
-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 {
- const party =
- JSON.parse(
- event.data
- );
-
- hydratePartyState(
- party,
- false
- );
- } catch (error) {
- console.error(
- "Party SSE error:",
- error
- );
- }
- }
- );
-
- stream.onerror = () => {
- if (
- appState.mode === "party" &&
- elements.connectionBadge
- ) {
- elements.connectionBadge.textContent =
- `Party ${code} · переподключение`;
- }
- };
-
- appState.partyStream = stream;
-}
-
-
-function closePartyStream() {
- if (appState.partyStream) {
- appState.partyStream.close();
- appState.partyStream = null;
- }
-}
-
-
-/* =========================================================
- CREATE PARTY
-========================================================= */
-
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 ||
- "Не удалось создать комнату.",
- "error"
- );
-
- return;
- }
-
- setStoredPartyId(
- response.party.code,
- response.clientId
- );
-
- hydratePartyState(
- response.party,
- false
- );
-
- connectPartyStream();
-
- showToast(
- `Комната ${response.party.code} создана.`,
- "success"
- );
+ const name = $("nameInput").value.trim() || "Guest";
+ const timeControl = $("timeControl").value;
+ await enterParty("/api/party/create", { name, timeControl });
}
-
-/* =========================================================
- JOIN PARTY
-========================================================= */
-
-async function joinParty(code) {
- const normalizedCode =
- String(code || "")
- .trim()
- .toUpperCase();
-
- if (!normalizedCode) {
- showToast(
- "Введите код комнаты.",
- "error"
- );
-
- return;
- }
-
- if (
- appState.party?.code ===
- normalizedCode
- ) {
- showToast(
- `Вы уже в комнате ${normalizedCode}.`
- );
-
- return;
- }
-
- if (appState.party) {
- await leaveParty(true);
- }
-
- 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 ||
- "Не удалось войти в комнату.",
- "error"
- );
-
- return;
- }
-
- setStoredPartyId(
- response.party.code,
- response.clientId
- );
-
- hydratePartyState(
- response.party,
- false
- );
-
- connectPartyStream();
-
- showToast(
- `Вы вошли в комнату ${response.party.code}.`,
- "success"
- );
+async function joinParty() {
+ const name = $("nameInput").value.trim() || "Guest";
+ const partyCode = $("partyCodeInput").value.trim().toUpperCase();
+ if (!partyCode) {
+ $("setupError").textContent = "Введите код комнаты.";
+ return;
+ }
+ await enterParty("/api/party/join", { name, partyCode, clientId: state.clientId });
}
-
-/* =========================================================
- LEAVE PARTY
-========================================================= */
-
-async function leaveParty(
- silent = false
-) {
- if (!appState.party) {
- return;
+async function enterParty(url, body) {
+ $("setupError").textContent = "";
+ try {
+ const response = await fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body)
+ });
+ const data = await response.json();
+ if (!data.ok) {
+ $("setupError").textContent = data.error || "Ошибка.";
+ return;
}
- 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(
- "Вы покинули комнату."
- );
- }
+ state.mode = "party";
+ state.party = data.party;
+ state.orientation = state.party.you?.role === "black" ? "b" : "w";
+ resetSelection();
+ $("chatPanel").classList.remove("hidden");
+ show("game");
+ connectEvents();
+ renderAll();
+ } catch {
+ $("setupError").textContent = "Сервер недоступен.";
+ }
}
-
-/* =========================================================
- 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(
- "Ссылка скопирована.",
- "success"
- );
- } catch {
- showToast(
- `Код комнаты: ${appState.party.code}`
- );
- }
+function connectEvents() {
+ if (state.eventSource) state.eventSource.close();
+ state.eventSource = new EventSource(
+ `/api/party/events?partyCode=${encodeURIComponent(state.party.code)}&clientId=${encodeURIComponent(state.clientId)}`
+ );
+ state.eventSource.addEventListener("party", e => {
+ try { applyParty(JSON.parse(e.data)); } catch {}
+ });
+ state.eventSource.onerror = () => {};
}
-
-/* =========================================================
- STORAGE
-========================================================= */
-
-function getStoredPartyIds() {
- try {
- return JSON.parse(
- localStorage.getItem(
- STORAGE_KEYS.partyIds
- ) || "{}"
- );
- } catch {
- return {};
- }
+function applyParty(party) {
+ state.party = party;
+ // Important: orientation is never changed by a received move.
+ // It only changes when the user presses "Повернуть".
+ renderAll();
+ renderChat();
}
-
-function setStoredPartyId(
- code,
- clientId
-) {
- if (!code || !clientId) {
- return;
- }
-
- const ids =
- getStoredPartyIds();
-
- ids[code] = clientId;
-
- localStorage.setItem(
- STORAGE_KEYS.partyIds,
- JSON.stringify(ids)
- );
+function renderChat() {
+ if (state.mode !== "party") return;
+ const box = $("chatMessages");
+ box.innerHTML = "";
+ for (const msg of state.party.chat || []) {
+ const row = document.createElement("div");
+ row.className = "chat-message";
+ row.innerHTML = `${escapeHtml(msg.name)} ${escapeHtml(msg.text)}`;
+ box.appendChild(row);
+ }
+ box.scrollTop = box.scrollHeight;
}
-
-function removeStoredPartyId(code) {
- if (!code) {
- return;
- }
-
- const ids =
- getStoredPartyIds();
-
- delete ids[code];
-
- localStorage.setItem(
- STORAGE_KEYS.partyIds,
- JSON.stringify(ids)
- );
+async function partyAction(endpoint) {
+ if (!state.party) return;
+ try {
+ const response = await fetch(endpoint, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ partyCode: state.party.code, clientId: state.clientId })
+ });
+ const data = await response.json();
+ if (!data.ok) toast(data.error || "Операция не выполнена.");
+ if (data.party) applyParty(data.party);
+ } catch {
+ toast("Нет соединения с сервером.");
+ }
}
+function newGame() {
+ if (state.mode === "ai") {
+ state.engine = new ChessEngine();
+ state.aiBusy = false;
+ state.localGameOver = false;
+ state.orientation = "w";
+ resetSelection();
+ renderAll();
+ return;
+ }
-/* =========================================================
- EVENTS
-========================================================= */
+ if (state.mode === "party") {
+ partyAction("/api/party/rematch");
+ return;
+ }
-function bindEvents() {
- elements.playerNameInput?.addEventListener(
- "input",
- event => {
- appState.playerName =
- event.target.value.trim() ||
- "Guest";
-
- localStorage.setItem(
- STORAGE_KEYS.name,
- appState.playerName
- );
-
- renderRoster();
- }
- );
-
-
- 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();
- }
- }
- );
-
-
- elements.modeAiButton?.addEventListener(
- "click",
- async () => {
- if (appState.party) {
- await leaveParty(true);
- return;
- }
-
- 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(
- "Новая партия с ИИ готова.",
- "success"
- );
- }
- );
-
-
- elements.newGameToolbarButton?.addEventListener(
- "click",
- async () => {
- if (appState.mode === "party") {
- showToast(
- "Для новой партии создайте новую комнату."
- );
-
- return;
- }
-
- resetLocalGame();
-
- showToast(
- "Новая партия.",
- "success"
- );
- }
- );
-
-
- elements.createPartyButton?.addEventListener(
- "click",
- createParty
- );
-
-
- elements.newPartyGameButton?.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();
- }
- );
-
-
- 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") {
- return;
- }
-
- event.preventDefault();
-
- joinParty(
- elements.partyCodeInput.value
- );
- }
- );
-
-
- elements.promotionDialog?.addEventListener(
- "click",
- event => {
- if (
- event.target ===
- elements.promotionDialog
- ) {
- closePromotionDialog();
- }
- }
- );
-
-
- window.addEventListener(
- "beforeunload",
- () => {
- stopAiMoveTimer();
- stopAiClock();
- closePartyStream();
- }
- );
+ $("partySetup").classList.add("hidden");
+ show("home");
}
-
-/* =========================================================
- INIT
-========================================================= */
-
-async function init() {
- bindEvents();
-
- const savedName =
- localStorage.getItem(
- STORAGE_KEYS.name
- );
-
- if (savedName) {
- appState.playerName =
- savedName;
-
- if (elements.playerNameInput) {
- elements.playerNameInput.value =
- savedName;
- }
- }
-
- 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);
- return;
- }
-
- setMode("ai");
- resetLocalGame();
+function closeParty() {
+ state.eventSource?.close();
+ state.eventSource = null;
+ state.party = null;
}
+function backHome() {
+ if (state.mode === "party") {
+ partyAction("/api/party/leave");
+ closeParty();
+ }
+ state.mode = null;
+ state.engine = new ChessEngine();
+ resetSelection();
+ $("partySetup").classList.add("hidden");
+ show("home");
+}
-/* =========================================================
- START
-========================================================= */
+$("newGameBtn").addEventListener("click", newGame);
+$("flipBtn").addEventListener("click", () => {
+ state.orientation = state.orientation === "w" ? "b" : "w";
+ renderBoard();
+});
+$("backHomeBtn").addEventListener("click", backHome);
+$("resignBtn").addEventListener("click", () => {
+ if (state.mode === "party") partyAction("/api/party/resign");
+ else {
+ const snap = state.engine.getSnapshot();
+ if (!snap.status.phase.match(/checkmate|draw/)) {
+ state.localGameOver = true;
+ state.aiBusy = false;
+ resetSelection();
+ renderAll();
+ toast("Вы сдались.");
+ }
+ }
+});
+$("drawBtn").addEventListener("click", () => {
+ if (state.mode === "party") partyAction("/api/party/draw");
+ else toast("Предложение ничьей доступно в Party.");
+});
+$("rematchBtn").addEventListener("click", newGame);
+
+document.querySelectorAll(".mode-card").forEach(btn => {
+ btn.addEventListener("click", () => {
+ if (btn.dataset.mode === "ai") startAI();
+ else $("partySetup").classList.remove("hidden");
+ });
+});
+
+$("createPartyBtn").addEventListener("click", createParty);
+$("joinPartyBtn").addEventListener("click", joinParty);
+$("partyCodeInput").addEventListener("input", e => {
+ e.target.value = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g,"");
+});
+
+$("chatForm").addEventListener("submit", async e => {
+ e.preventDefault();
+ if (!state.party) return;
+ const input = $("chatInput");
+ const text = input.value.trim();
+ if (!text) return;
+ input.value = "";
+ try {
+ await fetch("/api/party/chat", {
+ method:"POST",
+ headers:{"Content-Type":"application/json"},
+ body:JSON.stringify({
+ partyCode:state.party.code,
+ clientId:state.clientId,
+ text
+ })
+ });
+ } catch {
+ toast("Не удалось отправить сообщение.");
+ }
+});
+
+document.querySelectorAll("[data-promotion]").forEach(btn => {
+ btn.addEventListener("click", () => {
+ const p = state.pendingPromotion;
+ if (!p) return;
+ state.pendingPromotion = null;
+ playMove(p.from, p.to, btn.dataset.promotion);
+ });
+});
+
+show("home");
-init();