diff --git a/chess/public/js/app.js b/chess/public/js/app.js
index 9e335ba..8d050a6 100644
--- a/chess/public/js/app.js
+++ b/chess/public/js/app.js
@@ -1,538 +1,664 @@
-import { ChessEngine, VALUES } from "/shared/chess-engine.js";
+import { ChessEngine, ChessRules } from "/shared/chess-engine.js";
+import { chooseComputerMove } from "/js/ai.js";
-const PIECES = {
- w: { k:"♔", q:"♕", r:"♖", b:"♗", n:"♘", p:"♙" },
- b: { k:"♚", q:"♛", r:"♜", b:"♝", n:"♞", p:"♟" }
+const PIECE_GLYPHS = {
+ w: { K: "♔", Q: "♕", R: "♖", B: "♗", N: "♘", P: "♙" },
+ b: { K: "♚", Q: "♛", R: "♜", B: "♝", N: "♞", P: "♟" },
};
-const $ = (id) => document.getElementById(id);
+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 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
+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"),
};
-localStorage.setItem("chess-client-id", state.clientId);
+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 show(view) {
- $("homeView").classList.toggle("hidden", view !== "home");
- $("gameView").classList.toggle("hidden", view !== "game");
+function currentStatus() {
+ return appState.engine.state.status || ChessEngine.evaluateStatus(appState.engine.state);
}
-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 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 resetSelection() {
- state.selected = null;
- state.legal = [];
+function isHumanTurn() {
+ const color = currentHumanColor();
+ const status = currentStatus();
+ return Boolean(color && status.phase === "playing" && appState.engine.state.turn === color && !appState.isAiThinking && !appState.gameOver);
}
-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);
+ return `${String(hours).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
+ }
+ return `${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
+}
+
+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 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 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 renderBoard() {
- const snap = snapshot();
- if (!snap) return;
+ 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;
- const board = $("board");
- board.innerHTML = "";
- const { files, ranks } = boardOrder();
+ 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;
- const legalMap = new Map(state.legal.map(m => [m.to, m]));
- const last = snap.lastMove;
+ 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");
- 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 (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);
+ }
- 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);
+ square.addEventListener("click", () => handleSquareClick(squareName));
+ elements.board.appendChild(square);
+ }
}
- }
}
-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 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";
}
-function handleSquare(name) {
- if (!canUserMove()) return;
-
- const snap = snapshot();
- const piece = snap.board[name];
-
- if (state.selected) {
- const candidate = state.legal.filter(m => m.to === name);
- if (candidate.length) {
- if (candidate.some(m => m.promotion)) {
- state.pendingPromotion = { from: state.selected, to: name };
- $("promotionModal").classList.remove("hidden");
- } else {
- playMove(state.selected, name, candidate[0].promotion || null);
- }
- return;
+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.";
}
- }
-
- if (piece && piece.color === snap.turn) {
- state.selected = name;
- if (state.mode === "ai") state.legal = state.engine.movesFrom(name);
- else state.legal = state.partyEngineMoves(name);
- } else {
- resetSelection();
- }
- renderBoard();
- updateStatus();
}
-function partyEngineMoves(from) {
- // The server is authoritative. The snapshot contains enough board state to
- // calculate normal movement hints locally; the server validates the final move.
- const temp = new ChessEngine();
- temp.state = JSON.parse(JSON.stringify({
- board: state.party.game.board,
- turn: state.party.game.turn,
- castling: state.party.game.castling,
- ep: state.party.game.ep,
- halfmove: state.party.game.halfmove,
- fullmove: state.party.game.fullmove,
- lastMove: state.party.game.lastMove
- }));
- return temp.movesFrom(from);
+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("");
}
-async function playMove(from, to, promotion = null) {
- resetSelection();
- $("promotionModal").classList.add("hidden");
+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}.`;
+}
- if (state.mode === "ai") {
- const result = state.engine.makeMove({ from, to, promotion: promotion || "q" });
+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 || "Без часов" };
+ }
+
+ 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);
+ }
+}
+
+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}`;
+}
+
+function render() {
+ renderBoard();
+ renderRoster();
+ renderPartySummary();
+ renderClocks();
+ renderConnectionBadge();
+
+ 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);
+}
+
+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");
+}
+
+function closePromotionDialog() {
+ appState.pendingPromotionMoves = null;
+ elements.promotionDialog?.classList.add("hidden");
+}
+
+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;
+ }
+ hydratePartyState(response.party, false);
+ return;
+ }
+
+ const result = appState.engine.makeMove({ from: move.from, to: move.to, promotion: move.promotion || null });
if (!result.ok) {
- toast(result.error);
- renderBoard();
- return;
+ showToast(result.error || "Illegal move.", "error");
+ return;
}
- renderAll();
- if (!state.engine.getStatus().phase.match(/checkmate|draw/)) {
- state.aiBusy = true;
- renderAll();
- setTimeout(aiMove, 220);
- }
- return;
- }
- try {
- const response = await fetch("/api/party/move", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- partyCode: state.party.code,
- clientId: state.clientId,
- from, to, promotion: promotion || "q"
- })
+ updateAiClockAfterMove();
+ render();
+ scheduleAiTurn();
+}
+
+function handleSquareClick(square) {
+ if (appState.pendingPromotionMoves || appState.gameOver) return;
+ 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();
+}
+
+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();
+ 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);
+}
+
+function stopAiMoveTimer() {
+ if (appState.aiMoveTimer) { window.clearTimeout(appState.aiMoveTimer); appState.aiMoveTimer = null; }
+ appState.isAiThinking = false;
+}
+
+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 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;
+
+ clocks.lastTick = now;
+ const key = clocks.turn === "w" ? "white" : "black";
+ clocks[key] = Math.max(0, clocks[key] - elapsed);
+
+ 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();
+ }
+}
+
+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 resetLocalGame() {
+ stopAiMoveTimer();
+ stopAiClock();
+ closePromotionDialog();
+ appState.engine = new ChessEngine();
+ appState.partySnapshot = null;
+ appState.orientation = appState.playerColor;
+ appState.gameOver = false;
+ appState.gameOverMessage = null;
+ startAiClock();
+ scheduleAiTurn();
+ render();
+}
+
+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." };
+ }
+}
+
+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.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");
+}
+
+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); }
});
- const data = await response.json();
- if (!data.ok) toast(data.error || "Ход не выполнен.");
- if (data.party) applyParty(data.party);
- } catch {
- toast("Нет соединения с сервером.");
- }
+
+ stream.onerror = () => {
+ if (appState.mode === "party" && elements.connectionBadge) elements.connectionBadge.textContent = `Party ${code} · reconnecting`;
+ };
+ appState.partyStream = stream;
}
-function aiMove() {
- const moves = state.engine.legalMoves("b");
- if (!moves.length) {
- state.aiBusy = false;
- renderAll();
- return;
- }
-
- // Small deterministic evaluation: captures, promotion, checks, center.
- let best = moves[0];
- let bestScore = -Infinity;
-
- for (const move of moves) {
- const piece = state.engine.state.board[move.from];
- const captured = state.engine.state.board[move.to];
- let score = Math.random() * 0.15;
- if (captured) score += VALUES[captured.type] * 2.5;
- if (move.promotion) score += 9;
- if (["d4","e4","d5","e5"].includes(move.to)) score += .5;
- if (piece?.type === "p" && Math.abs(Number(move.to[1]) - Number(move.from[1])) === 2) score += .1;
- if (score > bestScore) { bestScore = score; best = move; }
- }
-
- state.engine.makeMove({
- from: best.from,
- to: best.to,
- promotion: best.promotion || "q"
- });
- state.aiBusy = false;
- renderAll();
-}
-
-function renderMoves(snap) {
- const list = $("movesList");
- list.innerHTML = "";
- const moves = snap.moves || [];
-
- for (let i = 0; i < moves.length; i += 2) {
- const row = document.createElement("div");
- row.className = "move";
- row.innerHTML = `
- ${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 escapeHtml(s) {
- return String(s).replace(/[&<>"']/g, c => ({
- "&":"&","<":"<",">":">",'"':""","'":"'"
- }[c]));
-}
-
-function renderMaterial(snap) {
- const mat = snap.material || { captured:{w:[],b:[]},score:{w:0,b:0} };
- const icon = (color, type) => PIECES[color][type];
- $("material").innerHTML = `
- ${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;
-
- const status = snap.status || { phase:"playing", turn:snap.turn };
- let text = state.selected ? "Выберите клетку назначения." : "Выберите фигуру.";
-
- if (status.phase === "check") text = "Шах.";
- if (status.phase === "checkmate") text = "Мат.";
- if (status.phase === "draw") text = `Ничья: ${status.reason || "правило"}.`;
- if (state.mode === "ai" && state.aiBusy) text = "ИИ думает…";
- if (state.mode === "ai" && state.localGameOver) text = "Партия завершена сдачей.";
- if (state.mode === "party" && state.party?.gameOverReason === "resign") text = "Партия завершена сдачей.";
- if (state.mode === "party" && state.party?.gameOverReason === "timeout") text = "Время закончилось.";
- if (state.mode === "party" && state.party?.drawOffer) text = `${state.party.drawOffer.name} предложил(а) ничью.`;
-
- $("gameStatus").textContent = text;
- $("turnBadge").textContent = snap.turn === "w" ? "Ход белых" : "Ход чёрных";
-}
-
-function renderPlayers() {
- if (state.mode === "ai") {
- $("whiteName").textContent = "Вы";
- $("blackName").textContent = "ИИ";
- $("whiteMeta").textContent = "Белые";
- $("blackMeta").textContent = "Чёрные";
- $("whiteClock").textContent = "—";
- $("blackClock").textContent = "—";
- return;
- }
-
- const p = state.party;
- $("whiteName").textContent = p.players.white?.name || "Ожидание…";
- $("blackName").textContent = p.players.black?.name || "Ожидание…";
- $("whiteMeta").textContent = p.players.white?.connected ? "Белые • онлайн" : "Белые • офлайн";
- $("blackMeta").textContent = p.players.black?.connected ? "Чёрные • онлайн" : "Чёрные • офлайн";
- $("whiteClock").textContent = formatClock(p.clocks.white, p.timeControl.initial);
- $("blackClock").textContent = formatClock(p.clocks.black, p.timeControl.initial);
-}
-
-function formatClock(seconds, enabled) {
- if (!enabled) return "—";
- const total = Math.max(0, Math.ceil(seconds));
- const m = Math.floor(total / 60);
- const s = total % 60;
- return `${String(m).padStart(2,"0")}:${String(s).padStart(2,"0")}`;
-}
-
-function renderAll() {
- renderBoard();
- renderMoves(snapshot());
- renderMaterial(snapshot());
- renderPlayers();
- updateStatus();
-}
-
-function startAI() {
- closeParty();
- state.mode = "ai";
- state.engine = new ChessEngine();
- state.orientation = "w";
- state.localGameOver = false;
- resetSelection();
- $("chatPanel").classList.add("hidden");
- show("game");
- renderAll();
+function closePartyStream() {
+ if (appState.partyStream) { appState.partyStream.close(); appState.partyStream = null; }
}
async function createParty() {
- const name = $("nameInput").value.trim() || "Guest";
- const timeControl = $("timeControl").value;
- await enterParty("/api/party/create", { name, timeControl });
+ 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() {
- const name = $("nameInput").value.trim() || "Guest";
- const partyCode = $("partyCodeInput").value.trim().toUpperCase();
- if (!partyCode) {
- $("setupError").textContent = "Введите код комнаты.";
- return;
- }
- await enterParty("/api/party/join", { name, partyCode, clientId: state.clientId });
+async function 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 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");
}
-async function enterParty(url, body) {
- $("setupError").textContent = "";
- try {
- const response = await fetch(url, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body)
+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.");
+}
+
+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}`); }
+}
+
+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));
+}
+
+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();
- if (!data.ok) {
- $("setupError").textContent = data.error || "Ошибка.";
- return;
+
+ 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);
+ 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");
+ });
+
+ 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();
+ });
+
+ 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(); });
+}
+
+async function init() {
+ bindEvents();
+ const savedName = localStorage.getItem(STORAGE_KEYS.name);
+ if (savedName) {
+ appState.playerName = savedName;
+ if (elements.playerNameInput) elements.playerNameInput.value = savedName;
}
-
- state.mode = "party";
- state.party = data.party;
- state.orientation = state.party.you?.role === "black" ? "b" : "w";
- resetSelection();
- $("chatPanel").classList.remove("hidden");
- show("game");
- connectEvents();
- renderAll();
- } catch {
- $("setupError").textContent = "Сервер недоступен.";
- }
-}
-
-function connectEvents() {
- if (state.eventSource) state.eventSource.close();
- state.eventSource = new EventSource(
- `/api/party/events?partyCode=${encodeURIComponent(state.party.code)}&clientId=${encodeURIComponent(state.clientId)}`
- );
- state.eventSource.addEventListener("party", e => {
- try { applyParty(JSON.parse(e.data)); } catch {}
- });
- state.eventSource.onerror = () => {};
-}
-
-function applyParty(party) {
- state.party = party;
- // Important: orientation is never changed by a received move.
- // It only changes when the user presses "Повернуть".
- renderAll();
- renderChat();
-}
-
-function renderChat() {
- if (state.mode !== "party") return;
- const box = $("chatMessages");
- box.innerHTML = "";
- for (const msg of state.party.chat || []) {
- const row = document.createElement("div");
- row.className = "chat-message";
- row.innerHTML = `${escapeHtml(msg.name)} ${escapeHtml(msg.text)}`;
- box.appendChild(row);
- }
- box.scrollTop = box.scrollHeight;
-}
-
-async function partyAction(endpoint) {
- if (!state.party) return;
- try {
- const response = await fetch(endpoint, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ partyCode: state.party.code, clientId: state.clientId })
- });
- const data = await response.json();
- if (!data.ok) toast(data.error || "Операция не выполнена.");
- if (data.party) applyParty(data.party);
- } catch {
- toast("Нет соединения с сервером.");
- }
-}
-
-function newGame() {
- if (state.mode === "ai") {
- state.engine = new ChessEngine();
- state.aiBusy = false;
- state.localGameOver = false;
- state.orientation = "w";
- resetSelection();
- renderAll();
- return;
- }
-
- if (state.mode === "party") {
- partyAction("/api/party/rematch");
- return;
- }
-
- $("partySetup").classList.add("hidden");
- show("home");
-}
-
-function closeParty() {
- state.eventSource?.close();
- state.eventSource = null;
- state.party = null;
-}
-
-function backHome() {
- if (state.mode === "party") {
- partyAction("/api/party/leave");
- closeParty();
- }
- state.mode = null;
- state.engine = new ChessEngine();
- resetSelection();
- $("partySetup").classList.add("hidden");
- show("home");
-}
-
-$("newGameBtn").addEventListener("click", newGame);
-$("flipBtn").addEventListener("click", () => {
- state.orientation = state.orientation === "w" ? "b" : "w";
- renderBoard();
-});
-$("backHomeBtn").addEventListener("click", backHome);
-$("resignBtn").addEventListener("click", () => {
- if (state.mode === "party") partyAction("/api/party/resign");
- else {
- const snap = state.engine.getSnapshot();
- if (!snap.status.phase.match(/checkmate|draw/)) {
- state.localGameOver = true;
- state.aiBusy = false;
- resetSelection();
- renderAll();
- toast("Вы сдались.");
+ 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;
}
- }
-});
-$("drawBtn").addEventListener("click", () => {
- if (state.mode === "party") partyAction("/api/party/draw");
- else toast("Предложение ничьей доступно в Party.");
-});
-$("rematchBtn").addEventListener("click", newGame);
+ setMode("ai");
+ resetLocalGame();
+}
-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();