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