diff --git a/chess/shared/chess-engine.js b/chess/shared/chess-engine.js index 25177a5..d70e761 100644 --- a/chess/shared/chess-engine.js +++ b/chess/shared/chess-engine.js @@ -1,537 +1,1795 @@ const FILES = "abcdefgh"; -const PIECES = ["p", "n", "b", "r", "q", "k"]; -const VALUES = { p: 1, n: 3, b: 3, r: 5, q: 9, k: 0 }; + +const VALUES = { + p: 1, + n: 3, + b: 3, + r: 5, + q: 9, + k: 0 +}; + +const PIECES = ["q", "r", "b", "n"]; function square(file, rank) { - return `${FILES[file]}${rank + 1}`; + return `${FILES[file]}${rank + 1}`; } -function parseSquare(s) { - if (!/^[a-h][1-8]$/.test(s || "")) return null; - return { file: FILES.indexOf(s[0]), rank: Number(s[1]) - 1 }; +function parseSquare(value) { + if (!/^[a-h][1-8]$/.test(value || "")) { + return null; + } + + return { + file: FILES.indexOf(value[0]), + rank: Number(value[1]) - 1 + }; } -function squareSafe(file, rank) { - if (file < 0 || file > 7 || rank < 0 || rank > 7) return null; - return square(file, rank); +function safeSquare(file, rank) { + if ( + file < 0 || + file > 7 || + rank < 0 || + rank > 7 + ) { + return null; + } + + return square(file, rank); } -function other(color) { - return color === "w" ? "b" : "w"; +function opposite(color) { + return color === "w" ? "b" : "w"; } -// ОПТИМИЗАЦИЯ: Поверхностное копирование. Фигуры иммутабельны при ходах, -// поэтому достаточно скопировать ссылки на объекты, что работает мгновенно. function cloneState(state) { - return { - board: { ...state.board }, - turn: state.turn, - castling: { - w: { ...state.castling.w }, - b: { ...state.castling.b } - }, - ep: state.ep, - halfmove: state.halfmove, - fullmove: state.fullmove, - lastMove: state.lastMove ? { ...state.lastMove } : null - }; + const board = {}; + + for (const [sq, piece] of Object.entries(state.board)) { + board[sq] = { + color: piece.color, + type: piece.type + }; + } + + return { + board, + + turn: state.turn, + + castling: { + w: { ...state.castling.w }, + b: { ...state.castling.b } + }, + + ep: state.ep, + + halfmove: state.halfmove, + fullmove: state.fullmove, + + lastMove: state.lastMove + ? { ...state.lastMove } + : null + }; } export class ChessEngine { - constructor(fen = null) { - if (fen) { - this.loadFen(fen); - } else { - this.reset(); - } - } - reset() { - this.loadFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"); - } + constructor(fen = null) { + this.reset(); - loadFen(fen) { - const [placement, turn, castling, enPassant, halfmove, fullmove] = fen.split(" "); - const board = {}; - let rank = 7, file = 0; - - for (const char of placement) { - if (char === "/") { - rank--; file = 0; - } else if (/\d/.test(char)) { - file += parseInt(char, 10); - } else { - const color = char === char.toUpperCase() ? "w" : "b"; - board[square(file, rank)] = { color, type: char.toLowerCase() }; - file++; - } - } - - this.state = { - board, - turn: turn === "w" ? "w" : "b", - castling: { - w: { k: castling.includes("K"), q: castling.includes("Q") }, - b: { k: castling.includes("k"), q: castling.includes("q") } - }, - ep: enPassant === "-" ? null : enPassant, - halfmove: parseInt(halfmove || 0, 10), - fullmove: parseInt(fullmove || 1, 10), - lastMove: null - }; - - this.history = []; - this.positions = new Map([[this.positionKey(this.state), 1]]); - } - - get fen() { - let fen = ""; - for (let r = 7; r >= 0; r--) { - let empty = 0; - for (let f = 0; f < 8; f++) { - const p = this.state.board[square(f, r)]; - if (p) { - if (empty > 0) { fen += empty; empty = 0; } - fen += p.color === "w" ? p.type.toUpperCase() : p.type; - } else { - empty++; + if (fen) { + this.loadFen(fen); } - } - if (empty > 0) fen += empty; - if (r > 0) fen += "/"; } - let castling = ""; - if (this.state.castling.w.k) castling += "K"; - if (this.state.castling.w.q) castling += "Q"; - if (this.state.castling.b.k) castling += "k"; - if (this.state.castling.b.q) castling += "q"; - - return `${fen} ${this.state.turn} ${castling || "-"} ${this.state.ep || "-"} ${this.state.halfmove} ${this.state.fullmove}`; - } - - get pgn() { - let pgn = ""; - for (let i = 0; i < this.history.length; i++) { - if (this.history[i].piece.color === "w") { - pgn += `${Math.floor(i / 2) + 1}. ${this.history[i].san} `; - } else { - pgn += `${this.history[i].san} `; - } + reset() { + this.loadFen( + "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" + ); } - const status = this.getStatus(); - if (status.phase === "checkmate") pgn += status.winner === "w" ? "1-0" : "0-1"; - else if (status.phase === "draw") pgn += "1/2-1/2"; - - return pgn.trim(); - } - positionKey(state) { - const board = Object.keys(state.board).sort().map(s => { - const p = state.board[s]; - return `${s}${p.color}${p.type}`; - }).join(","); - return `${board}|${state.turn}|${state.castling.w.k ? "K" : ""}${state.castling.w.q ? "Q" : ""}${state.castling.b.k ? "k" : ""}${state.castling.b.q ? "q" : ""}|${state.ep || "-"}`; - } + loadFen(fen) { - getPiece(s) { - return this.state.board[s] || null; - } + const parts = String(fen).trim().split(/\s+/); - movesFrom(from) { - const p = this.state.board[from]; - if (!p || p.color !== this.state.turn) return []; - return this.legalMoves(this.state.turn).filter(m => m.from === from); - } + const placement = parts[0]; + const turn = parts[1] || "w"; + const castling = parts[2] || "-"; + const ep = parts[3] || "-"; + const halfmove = Number(parts[4] || 0); + const fullmove = Number(parts[5] || 1); - legalMoves(color = this.state.turn) { - const pseudo = this.pseudoMoves(this.state, color); - const legal = []; - for (const move of pseudo) { - const next = this.applyToClone(this.state, move); - if (!this.inCheck(next, color)) legal.push(move); - } - return legal; - } + const board = {}; - pseudoMoves(state, color) { - const result = []; - for (const [from, piece] of Object.entries(state.board)) { - if (piece.color !== color) continue; - const pos = parseSquare(from); - - if (piece.type === "p") this.pawnMoves(state, from, piece, pos, result); - else if (piece.type === "n") this.knightMoves(state, from, piece, pos, result); - else if (piece.type === "b") this.slideMoves(state, from, piece, pos, result, [[1,1],[1,-1],[-1,1],[-1,-1]]); - else if (piece.type === "r") this.slideMoves(state, from, piece, pos, result, [[1,0],[-1,0],[0,1],[0,-1]]); - else if (piece.type === "q") this.slideMoves(state, from, piece, pos, result, [[1,1],[1,-1],[-1,1],[-1,-1],[1,0],[-1,0],[0,1],[0,-1]]); - else if (piece.type === "k") this.kingMoves(state, from, piece, pos, result); - } - return result; - } + let rank = 7; + let file = 0; - pushMove(state, result, from, to, promotion = null, special = null) { - const target = state.board[to]; - if (target?.color === state.board[from]?.color) return; - if (target?.type === "k") return; - result.push({ - from, to, promotion, special, - capture: Boolean(target) || special === "ep" - }); - } + for (const char of placement) { - pawnMoves(state, from, piece, pos, result) { - const dir = piece.color === "w" ? 1 : -1; - const startRank = piece.color === "w" ? 1 : 6; - const promotionRank = piece.color === "w" ? 7 : 0; + if (char === "/") { + rank--; + file = 0; + continue; + } - const one = squareSafe(pos.file, pos.rank + dir); - if (one && !state.board[one]) { - if (pos.rank + dir === promotionRank) { - for (const p of ["q","r","b","n"]) this.pushMove(state, result, from, one, p); - } else { - this.pushMove(state, result, from, one); - if (pos.rank === startRank) { - const two = squareSafe(pos.file, pos.rank + dir * 2); - if (two && !state.board[two]) this.pushMove(state, result, from, two, null, "double"); + if (/^\d$/.test(char)) { + file += Number(char); + continue; + } + + const color = + char === char.toUpperCase() + ? "w" + : "b"; + + board[square(file, rank)] = { + color, + type: char.toLowerCase() + }; + + file++; } - } + + this.state = { + board, + + turn: + turn === "b" + ? "b" + : "w", + + castling: { + w: { + k: castling.includes("K"), + q: castling.includes("Q") + }, + + b: { + k: castling.includes("k"), + q: castling.includes("q") + } + }, + + ep: + ep === "-" + ? null + : ep, + + halfmove, + fullmove, + + lastMove: null + }; + + this.history = []; + + this.positions = new Map(); + + this.positions.set( + this.positionKey(this.state), + 1 + ); } - for (const df of [-1, 1]) { - const to = squareSafe(pos.file + df, pos.rank + dir); - if (!to) continue; - - const target = state.board[to]; - if (target && target.color !== piece.color && target.type !== "k") { - if (pos.rank + dir === promotionRank) { - for (const p of ["q","r","b","n"]) this.pushMove(state, result, from, to, p); - } else { - this.pushMove(state, result, from, to); + get fen() { + + let result = ""; + + for (let rank = 7; rank >= 0; rank--) { + + let empty = 0; + + for (let file = 0; file < 8; file++) { + + const piece = + this.state.board[ + square(file, rank) + ]; + + if (!piece) { + empty++; + continue; + } + + if (empty) { + result += empty; + empty = 0; + } + + result += + piece.color === "w" + ? piece.type.toUpperCase() + : piece.type; + } + + if (empty) { + result += empty; + } + + if (rank > 0) { + result += "/"; + } } - } else if (state.ep === to) { - this.pushMove(state, result, from, to, null, "ep"); - } - } - } - knightMoves(state, from, piece, pos, result) { - const jumps = [[1,2],[2,1],[-1,2],[-2,1],[1,-2],[2,-1],[-1,-2],[-2,-1]]; - for (const [df, dr] of jumps) { - const to = squareSafe(pos.file + df, pos.rank + dr); - if (to) this.pushMove(state, result, from, to); + let rights = ""; + + if (this.state.castling.w.k) rights += "K"; + if (this.state.castling.w.q) rights += "Q"; + if (this.state.castling.b.k) rights += "k"; + if (this.state.castling.b.q) rights += "q"; + + return [ + result, + this.state.turn, + rights || "-", + this.state.ep || "-", + this.state.halfmove, + this.state.fullmove + ].join(" "); } - } + + positionKey(state) { + + const pieces = Object + .keys(state.board) + .sort() + .map(sq => { + const p = state.board[sq]; + + return `${sq}${p.color}${p.type}`; + }) + .join(","); + + let rights = ""; + + if (state.castling.w.k) rights += "K"; + if (state.castling.w.q) rights += "Q"; + if (state.castling.b.k) rights += "k"; + if (state.castling.b.q) rights += "q"; + + return [ + pieces, + state.turn, + rights || "-", + state.ep || "-" + ].join("|"); + } + + getPiece(squareName) { + return this.state.board[squareName] || null; + } + + movesFrom(from) { + + const piece = this.state.board[from]; + + if (!piece) { + return []; + } + + if (piece.color !== this.state.turn) { + return []; + } + + return this.legalMoves( + this.state.turn + ).filter( + move => move.from === from + ); + } + + legalMoves(color = this.state.turn) { + + const pseudo = + this.pseudoMoves( + this.state, + color + ); + + const result = []; + + for (const move of pseudo) { + + const next = + this.applyMoveToState( + this.state, + move + ); + + if (!this.inCheck(next, color)) { + result.push(move); + } + } + + return result; + } + + pseudoMoves(state, color) { + + const result = []; + + for (const [from, piece] of Object.entries(state.board)) { + + if (piece.color !== color) { + continue; + } + + const pos = parseSquare(from); + + if (!pos) { + continue; + } + + switch (piece.type) { + + case "p": + this.pawnMoves( + state, + from, + piece, + pos, + result + ); + break; + + case "n": + this.knightMoves( + state, + from, + piece, + pos, + result + ); + break; + + case "b": + this.slideMoves( + state, + from, + piece, + pos, + result, + [ + [1, 1], + [1, -1], + [-1, 1], + [-1, -1] + ] + ); + break; + + case "r": + this.slideMoves( + state, + from, + piece, + pos, + result, + [ + [1, 0], + [-1, 0], + [0, 1], + [0, -1] + ] + ); + break; + + case "q": + this.slideMoves( + state, + from, + piece, + pos, + result, + [ + [1, 1], + [1, -1], + [-1, 1], + [-1, -1], + [1, 0], + [-1, 0], + [0, 1], + [0, -1] + ] + ); + break; + + case "k": + this.kingMoves( + state, + from, + piece, + pos, + result + ); + break; + } + } + + return result; + } + + addMove( + state, + result, + from, + to, + promotion = null, + special = null + ) { - slideMoves(state, from, piece, pos, result, dirs) { - for (const [df, dr] of dirs) { - let f = pos.file + df, r = pos.rank + dr; - while (f >= 0 && f <= 7 && r >= 0 && r <= 7) { - const to = square(f, r); const target = state.board[to]; - if (!target) { - this.pushMove(state, result, from, to); + + if ( + target && + target.color === state.board[from].color + ) { + return; + } + + if ( + target && + target.type === "k" + ) { + return; + } + + result.push({ + from, + to, + promotion, + special, + capture: + Boolean(target) || + special === "ep" + }); + } + + pawnMoves( + state, + from, + piece, + pos, + result + ) { + + const direction = + piece.color === "w" + ? 1 + : -1; + + const startRank = + piece.color === "w" + ? 1 + : 6; + + const promotionRank = + piece.color === "w" + ? 7 + : 0; + + const one = + safeSquare( + pos.file, + pos.rank + direction + ); + + if ( + one && + !state.board[one] + ) { + + if ( + pos.rank + direction === + promotionRank + ) { + + for (const type of PIECES) { + this.addMove( + state, + result, + from, + one, + type + ); + } + + } else { + + this.addMove( + state, + result, + from, + one + ); + + if ( + pos.rank === startRank + ) { + + const two = + safeSquare( + pos.file, + pos.rank + + direction * 2 + ); + + if ( + two && + !state.board[two] + ) { + this.addMove( + state, + result, + from, + two, + null, + "double" + ); + } + } + } + } + + for (const df of [-1, 1]) { + + const to = + safeSquare( + pos.file + df, + pos.rank + direction + ); + + if (!to) { + continue; + } + + const target = + state.board[to]; + + if ( + target && + target.color !== piece.color && + target.type !== "k" + ) { + + if ( + pos.rank + direction === + promotionRank + ) { + + for (const type of PIECES) { + this.addMove( + state, + result, + from, + to, + type + ); + } + + } else { + + this.addMove( + state, + result, + from, + to + ); + } + + } else if ( + state.ep === to + ) { + + this.addMove( + state, + result, + from, + to, + null, + "ep" + ); + } + } + } + + knightMoves( + state, + from, + piece, + pos, + result + ) { + + const jumps = [ + [1, 2], + [2, 1], + [-1, 2], + [-2, 1], + [1, -2], + [2, -1], + [-1, -2], + [-2, -1] + ]; + + for (const [df, dr] of jumps) { + + const to = + safeSquare( + pos.file + df, + pos.rank + dr + ); + + if (to) { + this.addMove( + state, + result, + from, + to + ); + } + } + } + + slideMoves( + state, + from, + piece, + pos, + result, + directions + ) { + + for (const [df, dr] of directions) { + + let file = + pos.file + df; + + let rank = + pos.rank + dr; + + while ( + file >= 0 && + file < 8 && + rank >= 0 && + rank < 8 + ) { + + const to = + square(file, rank); + + const target = + state.board[to]; + + if (!target) { + + this.addMove( + state, + result, + from, + to + ); + + } else { + + if ( + target.color !== piece.color && + target.type !== "k" + ) { + this.addMove( + state, + result, + from, + to + ); + } + + break; + } + + file += df; + rank += dr; + } + } + } + + kingMoves( + state, + from, + piece, + pos, + result + ) { + + for (let df = -1; df <= 1; df++) { + for (let dr = -1; dr <= 1; dr++) { + + if (!df && !dr) { + continue; + } + + const to = + safeSquare( + pos.file + df, + pos.rank + dr + ); + + if (to) { + this.addMove( + state, + result, + from, + to + ); + } + } + } + + const rank = + piece.color === "w" + ? 0 + : 7; + + const enemy = + opposite(piece.color); + + if ( + pos.file !== 4 || + pos.rank !== rank + ) { + return; + } + + if ( + this.inCheck( + state, + piece.color + ) + ) { + return; + } + + if ( + state.castling[piece.color].k && + !state.board[square(5, rank)] && + !state.board[square(6, rank)] && + state.board[square(7, rank)]?.type === "r" + ) { + + if ( + !this.isAttacked( + state, + square(5, rank), + enemy + ) && + !this.isAttacked( + state, + square(6, rank), + enemy + ) + ) { + + this.addMove( + state, + result, + from, + square(6, rank), + null, + "castle-k" + ); + } + } + + if ( + state.castling[piece.color].q && + !state.board[square(1, rank)] && + !state.board[square(2, rank)] && + !state.board[square(3, rank)] && + state.board[square(0, rank)]?.type === "r" + ) { + + if ( + !this.isAttacked( + state, + square(3, rank), + enemy + ) && + !this.isAttacked( + state, + square(2, rank), + enemy + ) + ) { + + this.addMove( + state, + result, + from, + square(2, rank), + null, + "castle-q" + ); + } + } + } + + applyMoveToState( + state, + move + ) { + + const next = + cloneState(state); + + const piece = + next.board[move.from]; + + if (!piece) { + return next; + } + + delete next.board[move.from]; + + if (move.special === "ep") { + + const target = + parseSquare(move.to); + + const capturedSquare = + square( + target.file, + target.rank + + (piece.color === "w" ? -1 : 1) + ); + + delete next.board[capturedSquare]; + } + + const captured = + next.board[move.to]; + + next.board[move.to] = { + color: piece.color, + type: + move.promotion || + piece.type + }; + + if (piece.type === "k") { + + next.castling[piece.color].k = false; + next.castling[piece.color].q = false; + + const rank = + piece.color === "w" + ? 0 + : 7; + + if ( + move.special === "castle-k" + ) { + + delete next.board[ + square(7, rank) + ]; + + next.board[ + square(5, rank) + ] = { + color: piece.color, + type: "r" + }; + } + + if ( + move.special === "castle-q" + ) { + + delete next.board[ + square(0, rank) + ]; + + next.board[ + square(3, rank) + ] = { + color: piece.color, + type: "r" + }; + } + } + + if (piece.type === "r") { + + if (move.from === "a1") + next.castling.w.q = false; + + if (move.from === "h1") + next.castling.w.k = false; + + if (move.from === "a8") + next.castling.b.q = false; + + if (move.from === "h8") + next.castling.b.k = false; + } + + if (captured?.type === "r") { + + if (move.to === "a1") + next.castling.w.q = false; + + if (move.to === "h1") + next.castling.w.k = false; + + if (move.to === "a8") + next.castling.b.q = false; + + if (move.to === "h8") + next.castling.b.k = false; + } + + next.ep = null; + + if ( + piece.type === "p" + ) { + + const from = + parseSquare(move.from); + + const to = + parseSquare(move.to); + + if ( + Math.abs( + to.rank - + from.rank + ) === 2 + ) { + + next.ep = + square( + from.file, + (from.rank + to.rank) / 2 + ); + } + } + + next.halfmove = + piece.type === "p" || + move.capture + ? 0 + : next.halfmove + 1; + + if (piece.color === "b") { + next.fullmove++; + } + + next.turn = + opposite(piece.color); + + return next; + } + + inCheck( + state, + color + ) { + + const king = + Object.entries( + state.board + ).find( + ([, piece]) => + piece.color === color && + piece.type === "k" + ); + + if (!king) { + return true; + } + + return this.isAttacked( + state, + king[0], + opposite(color) + ); + } + + isAttacked( + state, + target, + attacker + ) { + + const pos = + parseSquare(target); + + if (!pos) { + return false; + } + + const knightMoves = [ + [1, 2], + [2, 1], + [-1, 2], + [-2, 1], + [1, -2], + [2, -1], + [-1, -2], + [-2, -1] + ]; + + for (const [df, dr] of knightMoves) { + + const sq = + safeSquare( + pos.file + df, + pos.rank + dr + ); + + const piece = + sq + ? state.board[sq] + : null; + + if ( + piece && + piece.color === attacker && + piece.type === "n" + ) { + return true; + } + } + + for (let df = -1; df <= 1; df++) { + for (let dr = -1; dr <= 1; dr++) { + + if (!df && !dr) { + continue; + } + + const sq = + safeSquare( + pos.file + df, + pos.rank + dr + ); + + const piece = + sq + ? state.board[sq] + : null; + + if ( + piece && + piece.color === attacker && + piece.type === "k" + ) { + return true; + } + } + } + + const pawnDirection = + attacker === "w" + ? -1 + : 1; + + for (const df of [-1, 1]) { + + const sq = + safeSquare( + pos.file + df, + pos.rank + pawnDirection + ); + + const piece = + sq + ? state.board[sq] + : null; + + if ( + piece && + piece.color === attacker && + piece.type === "p" + ) { + return true; + } + } + + const rays = [ + { + df: 1, + dr: 1, + pieces: ["b", "q"] + }, + { + df: -1, + dr: -1, + pieces: ["b", "q"] + }, + { + df: 1, + dr: -1, + pieces: ["b", "q"] + }, + { + df: -1, + dr: 1, + pieces: ["b", "q"] + }, + { + df: 1, + dr: 0, + pieces: ["r", "q"] + }, + { + df: -1, + dr: 0, + pieces: ["r", "q"] + }, + { + df: 0, + dr: 1, + pieces: ["r", "q"] + }, + { + df: 0, + dr: -1, + pieces: ["r", "q"] + } + ]; + + for (const ray of rays) { + + let file = + pos.file + ray.df; + + let rank = + pos.rank + ray.dr; + + while ( + file >= 0 && + file < 8 && + rank >= 0 && + rank < 8 + ) { + + const sq = + square(file, rank); + + const piece = + state.board[sq]; + + if (piece) { + + if ( + piece.color === attacker && + ray.pieces.includes(piece.type) + ) { + return true; + } + + break; + } + + file += ray.df; + rank += ray.dr; + } + } + + return false; + } + + makeMove(input) { + + const from = + String(input?.from || "") + .toLowerCase(); + + const to = + String(input?.to || "") + .toLowerCase(); + + let promotion = + String( + input?.promotion || "q" + ).toLowerCase(); + + if (!["q", "r", "b", "n"].includes(promotion)) { + promotion = "q"; + } + + const legal = + this.legalMoves( + this.state.turn + ); + + const candidates = + legal.filter( + move => + move.from === from && + move.to === to + ); + + let move = null; + + if (candidates.length === 1) { + + move = candidates[0]; + } else { - if (target.color !== piece.color && target.type !== "k") this.pushMove(state, result, from, to); - break; + + move = + candidates.find( + candidate => + candidate.promotion === promotion + ); } - f += df; r += dr; - } - } - } - kingMoves(state, from, piece, pos, result) { - for (let df = -1; df <= 1; df++) { - for (let dr = -1; dr <= 1; dr++) { - if (!df && !dr) continue; - const to = squareSafe(pos.file + df, pos.rank + dr); - if (to) this.pushMove(state, result, from, to); - } - } + if (!move) { - const rank = piece.color === "w" ? 0 : 7; - const enemy = other(piece.color); - - if (pos.file === 4 && pos.rank === rank && !this.inCheck(state, piece.color)) { - // King-side - if (state.castling[piece.color].k && !state.board[square(5,rank)] && !state.board[square(6,rank)] && - state.board[square(7,rank)]?.type === "r") { - if (!this.isAttacked(state, square(5,rank), enemy) && !this.isAttacked(state, square(6,rank), enemy)) { - this.pushMove(state, result, from, square(6,rank), null, "castle-k"); + return { + ok: false, + error: "Недопустимый ход." + }; } - } - // Queen-side - if (state.castling[piece.color].q && !state.board[square(1,rank)] && !state.board[square(2,rank)] && - !state.board[square(3,rank)] && state.board[square(0,rank)]?.type === "r") { - if (!this.isAttacked(state, square(3,rank), enemy) && !this.isAttacked(state, square(2,rank), enemy)) { - this.pushMove(state, result, from, square(2,rank), null, "castle-q"); + + const piece = + this.state.board[from]; + + const captured = + move.special === "ep" + ? { + color: opposite(piece.color), + type: "p" + } + : this.state.board[to] || null; + + const before = + cloneState( + this.state + ); + + this.state = + this.applyMoveToState( + this.state, + move + ); + + const san = + this.createSan( + before, + move, + legal + ); + + this.state.lastMove = { + from, + to, + san, + piece: { + ...piece + }, + + captured: + captured + ? { ...captured } + : null + }; + + this.history.push({ + ...move, + + san, + + piece: { + ...piece + }, + + captured: + captured + ? { ...captured } + : null, + + before + }); + + const key = + this.positionKey( + this.state + ); + + this.positions.set( + key, + (this.positions.get(key) || 0) + 1 + ); + + return { + ok: true, + move: this.state.lastMove, + status: this.getStatus() + }; + } + + createSan( + before, + move, + legalBefore + ) { + + const piece = + before.board[move.from]; + + if ( + move.special === "castle-k" + ) { + return this.sanSuffix( + before, + move, + "O-O" + ); } - } - } - } - applyToClone(state, move) { - const next = cloneState(state); - const piece = next.board[move.from]; - delete next.board[move.from]; - - if (move.special === "ep") { - const toSq = parseSquare(move.to); - delete next.board[square(toSq.file, toSq.rank + (piece.color === "w" ? -1 : 1))]; - } - - const captured = next.board[move.to]; - next.board[move.to] = { color: piece.color, type: move.promotion || piece.type }; - - if (piece.type === "k") { - next.castling[piece.color].k = false; - next.castling[piece.color].q = false; - if (move.special === "castle-k") { - const rank = piece.color === "w" ? 0 : 7; - delete next.board[square(7,rank)]; - next.board[square(5,rank)] = { color: piece.color, type: "r" }; - } - if (move.special === "castle-q") { - const rank = piece.color === "w" ? 0 : 7; - delete next.board[square(0,rank)]; - next.board[square(3,rank)] = { color: piece.color, type: "r" }; - } - } - - if (piece.type === "r") { - if (move.from === "a1") next.castling.w.q = false; - if (move.from === "h1") next.castling.w.k = false; - if (move.from === "a8") next.castling.b.q = false; - if (move.from === "h8") next.castling.b.k = false; - } - if (captured?.type === "r") { - if (move.to === "a1") next.castling.w.q = false; - if (move.to === "h1") next.castling.w.k = false; - if (move.to === "a8") next.castling.b.q = false; - if (move.to === "h8") next.castling.b.k = false; - } - - next.ep = null; - if (piece.type === "p" && Math.abs(parseSquare(move.to).rank - parseSquare(move.from).rank) === 2) { - next.ep = square(parseSquare(move.from).file, (parseSquare(move.from).rank + parseSquare(move.to).rank) / 2); - } - - next.halfmove = piece.type === "p" || move.capture ? 0 : next.halfmove + 1; - if (piece.color === "b") next.fullmove += 1; - next.turn = other(piece.color); - return next; - } - - inCheck(state, color) { - const kingEntry = Object.entries(state.board).find(([, p]) => p.color === color && p.type === "k"); - return kingEntry ? this.isAttacked(state, kingEntry[0], other(color)) : false; - } - - // ОПТИМИЗАЦИЯ: Reverse Raycasting (Обратная трассировка). Ищем атакующих вокруг клетки, - // вместо того чтобы перебирать все фигуры на доске. Работает в разы быстрее. - isAttacked(state, targetSq, byColor) { - const t = parseSquare(targetSq); - if (!t) return false; - - // 1. Проверяем коней - const knightJumps = [[1,2],[2,1],[-1,2],[-2,1],[1,-2],[2,-1],[-1,-2],[-2,-1]]; - for (const [df, dr] of knightJumps) { - const p = state.board[squareSafe(t.file + df, t.rank + dr)]; - if (p && p.color === byColor && p.type === "n") return true; - } - - // 2. Проверяем королей - for (let df = -1; df <= 1; df++) { - for (let dr = -1; dr <= 1; dr++) { - if (!df && !dr) continue; - const p = state.board[squareSafe(t.file + df, t.rank + dr)]; - if (p && p.color === byColor && p.type === "k") return true; - } - } - - // 3. Проверяем пешки (смотрим в сторону откуда могла прийти пешка врага) - const pDir = byColor === "w" ? -1 : 1; - for (const df of [-1, 1]) { - const p = state.board[squareSafe(t.file + df, t.rank + pDir)]; - if (p && p.color === byColor && p.type === "p") return true; - } - - // 4. Проверяем дальнобойные фигуры (Слоны, Ладьи, Ферзи) - const dirs = [ - { df: 1, dr: 1, types: ["b", "q"] }, { df: -1, dr: -1, types: ["b", "q"] }, - { df: 1, dr: -1, types: ["b", "q"] }, { df: -1, dr: 1, types: ["b", "q"] }, - { df: 1, dr: 0, types: ["r", "q"] }, { df: -1, dr: 0, types: ["r", "q"] }, - { df: 0, dr: 1, types: ["r", "q"] }, { df: 0, dr: -1, types: ["r", "q"] } - ]; - - for (const { df, dr, types } of dirs) { - let f = t.file + df, r = t.rank + dr; - while (f >= 0 && f <= 7 && r >= 0 && r <= 7) { - const p = state.board[square(f, r)]; - if (p) { - if (p.color === byColor && types.includes(p.type)) return true; - break; // Наткнулись на любую другую фигуру — луч блокирован + if ( + move.special === "castle-q" + ) { + return this.sanSuffix( + before, + move, + "O-O-O" + ); } - f += df; r += dr; - } + + let san = + piece.type === "p" + ? "" + : piece.type.toUpperCase(); + + const conflicts = + legalBefore.filter( + other => + other.from !== move.from && + other.to === move.to && + before.board[other.from]?.type === piece.type + ); + + if (conflicts.length) { + + const from = + parseSquare(move.from); + + const sameFile = + conflicts.some( + item => + parseSquare(item.from).file === from.file + ); + + const sameRank = + conflicts.some( + item => + parseSquare(item.from).rank === from.rank + ); + + if (!sameFile) { + san += move.from[0]; + } else if (!sameRank) { + san += move.from[1]; + } else { + san += move.from; + } + } + + if (move.capture) { + + if ( + piece.type === "p" + ) { + san += move.from[0]; + } + + san += "x"; + } + + san += move.to; + + if (move.promotion) { + san += + "=" + + move.promotion.toUpperCase(); + } + + return this.sanSuffix( + before, + move, + san + ); } - return false; - } + sanSuffix( + before, + move, + san + ) { - sanForMove(move, legalBefore = null) { - const piece = this.state.board[move.from]; - if (!piece) return ""; - if (move.special === "castle-k") return this.suffixAfter(move, "O-O"); - if (move.special === "castle-q") return this.suffixAfter(move, "O-O-O"); + const next = + this.applyMoveToState( + before, + move + ); - const legal = legalBefore || this.legalMoves(this.state.turn); - let san = piece.type === "p" ? "" : piece.type.toUpperCase(); + if ( + this.inCheck( + next, + next.turn + ) + ) { - const same = legal.filter(m => m.to === move.to && m.from !== move.from && this.state.board[m.from]?.type === piece.type); + const replies = + this.legalMovesForState( + next, + next.turn + ); - // Улучшенная дисамбигуация (правила SAN) - if (same.length) { - const fromSq = parseSquare(move.from); - const sameFile = same.some(m => parseSquare(m.from).file === fromSq.file); - const sameRank = same.some(m => parseSquare(m.from).rank === fromSq.rank); + return ( + san + + ( + replies.length + ? "+" + : "#" + ) + ); + } - if (!sameFile) { - san += move.from[0]; // Отличаются по вертикали - } else if (!sameRank) { - san += move.from[1]; // Отличаются по горизонтали - } else { - san += move.from; // Приходится указывать и то, и другое - } + return san; } - if (move.capture) { - if (piece.type === "p" && same.length === 0) san += move.from[0]; - san += "x"; + legalMovesForState( + state, + color + ) { + + const pseudo = + this.pseudoMoves( + state, + color + ); + + return pseudo.filter( + move => { + + const next = + this.applyMoveToState( + state, + move + ); + + return !this.inCheck( + next, + color + ); + } + ); } - san += move.to; - if (move.promotion) san += `=${move.promotion.toUpperCase()}`; + getStatus() { - return this.suffixAfter(move, san); - } + const color = + this.state.turn; - suffixAfter(move, san) { - const next = this.applyToClone(this.state, move); - if (this.inCheck(next, next.turn)) { - const replies = this.legalMoves(next.turn).filter(m => !this.inCheck(this.applyToClone(next, m), next.turn)); - return san + (replies.length ? "+" : "#"); + const moves = + this.legalMoves(color); + + const check = + this.inCheck( + this.state, + color + ); + + if (!moves.length) { + + if (check) { + + return { + phase: "checkmate", + turn: color, + winner: opposite(color) + }; + } + + return { + phase: "draw", + reason: "stalemate", + turn: color + }; + } + + if ( + this.state.halfmove >= 100 + ) { + + return { + phase: "draw", + reason: "50-move", + turn: color + }; + } + + const key = + this.positionKey( + this.state + ); + + if ( + (this.positions.get(key) || 0) >= 3 + ) { + + return { + phase: "draw", + reason: "threefold", + turn: color + }; + } + + if (this.insufficientMaterial()) { + + return { + phase: "draw", + reason: "insufficient-material", + turn: color + }; + } + + if (check) { + + return { + phase: "check", + turn: color + }; + } + + return { + phase: "playing", + turn: color + }; } - return san; - } - makeMove(input) { - const from = String(input?.from || "").toLowerCase(); - const to = String(input?.to || "").toLowerCase(); - const promotion = String(input?.promotion || "q").toLowerCase(); + insufficientMaterial() { - const legal = this.legalMoves(this.state.turn); - const move = legal.find(m => m.from === from && m.to === to && (m.promotion ? m.promotion === promotion : true)); + const pieces = + Object.values( + this.state.board + ).filter( + piece => + piece.type !== "k" + ); - if (!move) return { ok: false, error: "Недопустимый ход." }; + if (!pieces.length) { + return true; + } - const san = this.sanForMove(move, legal); - const piece = this.state.board[from]; - const capturedPiece = move.special === "ep" ? { type: "p", color: other(piece.color) } : this.state.board[to] || null; + if ( + pieces.length === 1 && + ["b", "n"].includes( + pieces[0].type + ) + ) { + return true; + } - this.history.push({ - ...move, san, piece: { ...piece }, - captured: capturedPiece ? { ...capturedPiece } : null, - before: cloneState(this.state) - }); + if ( + pieces.length === 2 && + pieces.every( + piece => + piece.type === "b" + ) + ) { - this.state = this.applyToClone(this.state, move); - this.state.lastMove = { from, to, san, piece: { ...piece }, captured: capturedPiece ? { ...capturedPiece } : null }; + const bishops = + Object.entries( + this.state.board + ).filter( + ([, piece]) => + piece.type === "b" + ); - const key = this.positionKey(this.state); - this.positions.set(key, (this.positions.get(key) || 0) + 1); + const a = + parseSquare( + bishops[0][0] + ); - return { ok: true, move: this.state.lastMove, status: this.getStatus() }; - } + const b = + parseSquare( + bishops[1][0] + ); - // ОПТИМИЗАЦИЯ: O(1) Undo (Мгновенная отмена хода) - undo() { - const last = this.history.pop(); - if (!last) return false; - - // Декрементируем счетчик текущей позиции - const currentKey = this.positionKey(this.state); - const count = this.positions.get(currentKey); - if (count === 1) this.positions.delete(currentKey); - else this.positions.set(currentKey, count - 1); + if ( + (a.file + a.rank) % 2 === + (b.file + b.rank) % 2 + ) { + return true; + } + } - this.state = last.before; - return true; - } - - getStatus() { - const color = this.state.turn; - const moves = this.legalMoves(color); - const check = this.inCheck(this.state, color); - - if (!moves.length) return check ? { phase: "checkmate", turn: color, winner: other(color) } : { phase: "draw", reason: "stalemate", turn: color }; - if (this.state.halfmove >= 100) return { phase: "draw", reason: "50-move", turn: color }; - - const key = this.positionKey(this.state); - if ((this.positions.get(key) || 0) >= 3) return { phase: "draw", reason: "threefold", turn: color }; - if (this.insufficientMaterial()) return { phase: "draw", reason: "insufficient-material", turn: color }; - if (check) return { phase: "check", turn: color }; - - return { phase: "playing", turn: color }; - } - - insufficientMaterial() { - const pieces = Object.values(this.state.board).filter(p => p.type !== "k"); - if (pieces.length === 0) return true; - if (pieces.length === 1 && ["n", "b"].includes(pieces[0].type)) return true; - - // Два слона одноцветных полей у разных сторон - if (pieces.length === 2 && pieces.every(p => p.type === "b")) { - const bishops = Object.entries(this.state.board).filter(([, p]) => p.type === "b"); - const c1 = parseSquare(bishops[0][0]); - const c2 = parseSquare(bishops[1][0]); - if ((c1.file + c1.rank) % 2 === (c2.file + c2.rank) % 2) return true; + return false; } - return false; - } - material() { - const captured = { w: [], b: [] }; - for (const h of this.history) if (h.captured) captured[h.piece.color].push(h.captured.type); - - const sort = a => a.sort((x, y) => VALUES[y] - VALUES[x]); - sort(captured.w); sort(captured.b); + material() { - return { - captured, - score: { - w: captured.b.reduce((n, p) => n + VALUES[p], 0), - b: captured.w.reduce((n, p) => n + VALUES[p], 0) - } - }; - } + const captured = { + w: [], + b: [] + }; - getSnapshot() { - return { - ...cloneState(this.state), - moves: this.history.map((h, i) => ({ - number: i + 1, - from: h.from, to: h.to, - san: h.san, color: h.piece.color, - piece: h.piece.type, capture: Boolean(h.captured) - })), - material: this.material(), - status: this.getStatus(), - fen: this.fen, - pgn: this.pgn - }; - } + for (const move of this.history) { + + if (!move.captured) { + continue; + } + + captured[ + move.piece.color + ].push( + move.captured.type + ); + } + + const sort = + list => + list.sort( + (a, b) => + VALUES[b] - + VALUES[a] + ); + + sort(captured.w); + sort(captured.b); + + return { + captured, + + score: { + w: captured.b.reduce( + (sum, piece) => + sum + VALUES[piece], + 0 + ), + + b: captured.w.reduce( + (sum, piece) => + sum + VALUES[piece], + 0 + ) + } + }; + } + + getSnapshot() { + + return { + ...cloneState( + this.state + ), + + moves: + this.history.map( + (move, index) => ({ + number: index + 1, + from: move.from, + to: move.to, + san: move.san, + color: move.piece.color, + piece: move.piece.type, + capture: + Boolean( + move.captured + ) + }) + ), + + material: + this.material(), + + status: + this.getStatus(), + + fen: + this.fen, + + pgn: + this.pgn + }; + } + + get pgn() { + + let result = ""; + + for ( + let i = 0; + i < this.history.length; + i++ + ) { + + const move = + this.history[i]; + + if ( + move.piece.color === "w" + ) { + + result += + `${Math.floor(i / 2) + 1}. ${move.san} `; + + } else { + + result += + `${move.san} `; + } + } + + const status = + this.getStatus(); + + if ( + status.phase === "checkmate" + ) { + + result += + status.winner === "w" + ? "1-0" + : "0-1"; + + } else if ( + status.phase === "draw" + ) { + + result += "1/2-1/2"; + } + + return result.trim(); + } + + undo() { + + const last = + this.history.pop(); + + if (!last) { + return false; + } + + const current = + this.positionKey( + this.state + ); + + const count = + this.positions.get( + current + ); + + if (count <= 1) { + this.positions.delete( + current + ); + } else { + this.positions.set( + current, + count - 1 + ); + } + + this.state = + last.before; + + return true; + } } -export { FILES, VALUES }; - +export { + FILES, + VALUES +};