From 3b02071fe7799d0b4669ad0f124b29ad201fad9d Mon Sep 17 00:00:00 2001 From: admin01 Date: Sat, 22 Aug 2026 17:39:10 +0000 Subject: [PATCH] Add chess/shared/chess-engine.js --- chess/shared/chess-engine.js | 824 +++++++++++++++++++++++++++++++++++ 1 file changed, 824 insertions(+) create mode 100644 chess/shared/chess-engine.js diff --git a/chess/shared/chess-engine.js b/chess/shared/chess-engine.js new file mode 100644 index 0000000..e22f371 --- /dev/null +++ b/chess/shared/chess-engine.js @@ -0,0 +1,824 @@ +//chess-engine.js + +const FILES = "abcdefgh"; +const STARTING_BACK_RANK = ["R", "N", "B", "Q", "K", "B", "N", "R"]; + +function createPiece(color, type) { + return { color, type }; +} + +function clonePiece(piece) { + return piece ? { color: piece.color, type: piece.type } : null; +} + +function cloneBoard(board) { + return board.map((row) => row.map((piece) => clonePiece(piece))); +} + +function colorLabel(color) { + return color === "w" ? "White" : "Black"; +} + +export function opponentColor(color) { + return color === "w" ? "b" : "w"; +} + +export function toSquare(x, y) { + return `${FILES[x]}${8 - y}`; +} + +export function fromSquare(square) { + if (!square || square.length !== 2) { + return null; + } + + const file = square[0].toLowerCase(); + const rank = Number(square[1]); + const x = FILES.indexOf(file); + const y = 8 - rank; + + if (x < 0 || Number.isNaN(y) || y < 0 || y > 7) { + return null; + } + + return { x, y }; +} + +function inBounds(x, y) { + return x >= 0 && x < 8 && y >= 0 && y < 8; +} + +function createEmptyBoard() { + return Array.from({ length: 8 }, () => Array.from({ length: 8 }, () => null)); +} + +function createStartingBoard() { + const board = createEmptyBoard(); + + for (let index = 0; index < 8; index += 1) { + board[0][index] = createPiece("b", STARTING_BACK_RANK[index]); + board[1][index] = createPiece("b", "P"); + board[6][index] = createPiece("w", "P"); + board[7][index] = createPiece("w", STARTING_BACK_RANK[index]); + } + + return board; +} + +function createCastlingRights() { + return { + w: { k: true, q: true }, + b: { k: true, q: true }, + }; +} + +function createInitialState() { + return { + board: createStartingBoard(), + turn: "w", + castling: createCastlingRights(), + enPassant: null, + halfmoveClock: 0, + fullmoveNumber: 1, + history: [], + positionCounts: {}, + status: null, + lastMove: null, + }; +} + +function cloneState(state) { + return { + board: cloneBoard(state.board), + turn: state.turn, + castling: { + w: { ...state.castling.w }, + b: { ...state.castling.b }, + }, + enPassant: state.enPassant, + halfmoveClock: state.halfmoveClock, + fullmoveNumber: state.fullmoveNumber, + history: state.history.map((entry) => ({ ...entry })), + positionCounts: { ...state.positionCounts }, + status: state.status ? { ...state.status } : null, + lastMove: state.lastMove ? { ...state.lastMove } : null, + }; +} + +function boardToHash(board) { + return board + .map((row) => + row + .map((piece) => { + if (!piece) { + return "."; + } + + return piece.color === "w" ? piece.type : piece.type.toLowerCase(); + }) + .join(""), + ) + .join("/"); +} + +function positionHash(state) { + const castlingText = ["w", "b"] + .map((color) => { + const rights = []; + if (state.castling[color].k) { + rights.push("k"); + } + if (state.castling[color].q) { + rights.push("q"); + } + return rights.length ? rights.join("") : "-"; + }) + .join(""); + + return [ + boardToHash(state.board), + state.turn, + castlingText || "-", + state.enPassant || "-", + ].join("|"); +} + +function addPositionCount(state) { + const hash = positionHash(state); + state.positionCounts[hash] = (state.positionCounts[hash] || 0) + 1; +} + +function pieceAt(board, x, y) { + if (!inBounds(x, y)) { + return null; + } + return board[y][x]; +} + +function setPiece(board, x, y, piece) { + board[y][x] = piece; +} + +function squareColor(x, y) { + return (x + y) % 2 === 0 ? "dark" : "light"; +} + +function getKingPosition(board, color) { + for (let y = 0; y < 8; y += 1) { + for (let x = 0; x < 8; x += 1) { + const piece = board[y][x]; + if (piece && piece.color === color && piece.type === "K") { + return { x, y }; + } + } + } + + return null; +} + +function isSquareAttacked(state, x, y, byColor) { + const board = state.board; + const pawnOffset = byColor === "w" ? 1 : -1; + + for (const dx of [-1, 1]) { + const piece = pieceAt(board, x + dx, y + pawnOffset); + if (piece && piece.color === byColor && piece.type === "P") { + return true; + } + } + + const knightOffsets = [ + [-2, -1], + [-2, 1], + [-1, -2], + [-1, 2], + [1, -2], + [1, 2], + [2, -1], + [2, 1], + ]; + + for (const [dx, dy] of knightOffsets) { + const piece = pieceAt(board, x + dx, y + dy); + if (piece && piece.color === byColor && piece.type === "N") { + return true; + } + } + + const slidingLines = [ + { directions: [[1, 0], [-1, 0], [0, 1], [0, -1]], pieces: ["R", "Q"] }, + { directions: [[1, 1], [1, -1], [-1, 1], [-1, -1]], pieces: ["B", "Q"] }, + ]; + + for (const line of slidingLines) { + for (const [dx, dy] of line.directions) { + let nx = x + dx; + let ny = y + dy; + + while (inBounds(nx, ny)) { + const piece = pieceAt(board, nx, ny); + if (piece) { + if (piece.color === byColor && line.pieces.includes(piece.type)) { + return true; + } + break; + } + nx += dx; + ny += dy; + } + } + } + + for (let dy = -1; dy <= 1; dy += 1) { + for (let dx = -1; dx <= 1; dx += 1) { + if (dx === 0 && dy === 0) { + continue; + } + const piece = pieceAt(board, x + dx, y + dy); + if (piece && piece.color === byColor && piece.type === "K") { + return true; + } + } + } + + return false; +} + +function isInCheck(state, color) { + const king = getKingPosition(state.board, color); + if (!king) { + return false; + } + return isSquareAttacked(state, king.x, king.y, opponentColor(color)); +} + +function addMove(moves, state, x, y, nx, ny, extras = {}) { + if (!inBounds(nx, ny)) { + return; + } + + const piece = state.board[y][x]; + const target = state.board[ny][nx]; + + if (target && target.color === piece.color) { + return; + } + + moves.push({ + from: toSquare(x, y), + to: toSquare(nx, ny), + fromX: x, + fromY: y, + toX: nx, + toY: ny, + color: piece.color, + piece: piece.type, + capture: target ? target.type : extras.capture || null, + captureColor: target ? target.color : extras.captureColor || null, + promotion: extras.promotion || null, + castle: extras.castle || null, + enPassant: Boolean(extras.enPassant), + doublePawnPush: Boolean(extras.doublePawnPush), + }); +} + +function generatePawnMoves(state, x, y, piece, moves) { + const direction = piece.color === "w" ? -1 : 1; + const startRow = piece.color === "w" ? 6 : 1; + const promotionRow = piece.color === "w" ? 0 : 7; + const forwardY = y + direction; + + if (inBounds(x, forwardY) && !pieceAt(state.board, x, forwardY)) { + if (forwardY === promotionRow) { + for (const promotion of ["q", "r", "b", "n"]) { + addMove(moves, state, x, y, x, forwardY, { promotion }); + } + } else { + addMove(moves, state, x, y, x, forwardY); + } + + const doubleForwardY = y + direction * 2; + if (y === startRow && !pieceAt(state.board, x, doubleForwardY)) { + addMove(moves, state, x, y, x, doubleForwardY, { doublePawnPush: true }); + } + } + + for (const dx of [-1, 1]) { + const nx = x + dx; + const ny = y + direction; + if (!inBounds(nx, ny)) { + continue; + } + + const target = pieceAt(state.board, nx, ny); + if (target && target.color !== piece.color) { + if (ny === promotionRow) { + for (const promotion of ["q", "r", "b", "n"]) { + addMove(moves, state, x, y, nx, ny, { promotion }); + } + } else { + addMove(moves, state, x, y, nx, ny); + } + continue; + } + + if (state.enPassant === toSquare(nx, ny)) { + const capturedPawn = pieceAt(state.board, nx, y); + if (capturedPawn && capturedPawn.color !== piece.color && capturedPawn.type === "P") { + addMove(moves, state, x, y, nx, ny, { + enPassant: true, + capture: "P", + captureColor: capturedPawn.color, + }); + } + } + } +} + +function generateSlidingMoves(state, x, y, moves, directions) { + for (const [dx, dy] of directions) { + let nx = x + dx; + let ny = y + dy; + + while (inBounds(nx, ny)) { + const target = pieceAt(state.board, nx, ny); + if (!target) { + addMove(moves, state, x, y, nx, ny); + } else { + if (target.color !== state.board[y][x].color) { + addMove(moves, state, x, y, nx, ny); + } + break; + } + + nx += dx; + ny += dy; + } + } +} + +function canCastle(state, color, side) { + const homeRank = color === "w" ? 7 : 0; + const rookFile = side === "k" ? 7 : 0; + const king = pieceAt(state.board, 4, homeRank); + const rook = pieceAt(state.board, rookFile, homeRank); + + if (!king || king.type !== "K" || king.color !== color) { + return false; + } + if (!rook || rook.type !== "R" || rook.color !== color) { + return false; + } + if (!state.castling[color][side] || isInCheck(state, color)) { + return false; + } + + const betweenSquares = side === "k" ? [5, 6] : [1, 2, 3]; + for (const file of betweenSquares) { + if (pieceAt(state.board, file, homeRank)) { + return false; + } + } + + const traverseSquares = side === "k" ? [5, 6] : [3, 2]; + for (const file of traverseSquares) { + if (isSquareAttacked(state, file, homeRank, opponentColor(color))) { + return false; + } + } + + return true; +} + +function generateKingMoves(state, x, y, piece, moves) { + for (let dy = -1; dy <= 1; dy += 1) { + for (let dx = -1; dx <= 1; dx += 1) { + if (dx === 0 && dy === 0) { + continue; + } + addMove(moves, state, x, y, x + dx, y + dy); + } + } + + if (canCastle(state, piece.color, "k")) { + addMove(moves, state, x, y, x + 2, y, { castle: "k" }); + } + + if (canCastle(state, piece.color, "q")) { + addMove(moves, state, x, y, x - 2, y, { castle: "q" }); + } +} + +function generatePseudoMoves(state, x, y) { + const piece = pieceAt(state.board, x, y); + if (!piece) { + return []; + } + + const moves = []; + + if (piece.type === "P") { + generatePawnMoves(state, x, y, piece, moves); + } else if (piece.type === "N") { + const offsets = [ + [-2, -1], + [-2, 1], + [-1, -2], + [-1, 2], + [1, -2], + [1, 2], + [2, -1], + [2, 1], + ]; + for (const [dx, dy] of offsets) { + addMove(moves, state, x, y, x + dx, y + dy); + } + } else if (piece.type === "B") { + generateSlidingMoves(state, x, y, moves, [ + [1, 1], + [1, -1], + [-1, 1], + [-1, -1], + ]); + } else if (piece.type === "R") { + generateSlidingMoves(state, x, y, moves, [ + [1, 0], + [-1, 0], + [0, 1], + [0, -1], + ]); + } else if (piece.type === "Q") { + generateSlidingMoves(state, x, y, moves, [ + [1, 0], + [-1, 0], + [0, 1], + [0, -1], + [1, 1], + [1, -1], + [-1, 1], + [-1, -1], + ]); + } else if (piece.type === "K") { + generateKingMoves(state, x, y, piece, moves); + } + + return moves; +} + +function updateCastlingRights(nextState, move) { + const { color, piece, from, to, capture, captureColor } = move; + + if (piece === "K") { + nextState.castling[color].k = false; + nextState.castling[color].q = false; + } + + if (piece === "R") { + if (from === "a1") { + nextState.castling.w.q = false; + } + if (from === "h1") { + nextState.castling.w.k = false; + } + if (from === "a8") { + nextState.castling.b.q = false; + } + if (from === "h8") { + nextState.castling.b.k = false; + } + } + + if (capture === "R" && captureColor) { + if (to === "a1") { + nextState.castling.w.q = false; + } + if (to === "h1") { + nextState.castling.w.k = false; + } + if (to === "a8") { + nextState.castling.b.q = false; + } + if (to === "h8") { + nextState.castling.b.k = false; + } + } +} + +function buildMoveNotation(move, status) { + if (move.castle === "k") { + return status.reason === "checkmate" ? "O-O#" : status.check ? "O-O+" : "O-O"; + } + + if (move.castle === "q") { + return status.reason === "checkmate" ? "O-O-O#" : status.check ? "O-O-O+" : "O-O-O"; + } + + const piecePrefix = move.piece === "P" ? "" : move.piece; + const pawnCapturePrefix = move.piece === "P" && move.capture ? move.from[0] : ""; + const captureMarker = move.capture ? "x" : ""; + const promotionText = move.promotion ? `=${move.promotion.toUpperCase()}` : ""; + const suffix = status.reason === "checkmate" ? "#" : status.check ? "+" : ""; + + return `${piecePrefix}${pawnCapturePrefix}${captureMarker}${move.to}${promotionText}${suffix}`; +} + +function getMoveRecord(move, status, nextState) { + return { + from: move.from, + to: move.to, + color: move.color, + piece: move.piece, + capture: move.capture, + promotion: move.promotion, + castle: move.castle, + enPassant: move.enPassant, + notation: buildMoveNotation(move, status), + turnNumber: nextState.fullmoveNumber - (move.color === "w" ? 0 : 1), + fenKey: positionHash(nextState), + }; +} + +function hasInsufficientMaterial(board) { + const pieces = []; + + for (const row of board) { + for (const piece of row) { + if (piece) { + pieces.push(piece); + } + } + } + + const nonKings = pieces.filter((piece) => piece.type !== "K"); + if (nonKings.some((piece) => ["P", "R", "Q"].includes(piece.type))) { + return false; + } + + if (nonKings.length === 0 || nonKings.length === 1) { + return true; + } + + if (nonKings.length === 2) { + const [first, second] = nonKings; + if (first.color !== second.color) { + return true; + } + if (first.type === "N" && second.type === "N") { + return true; + } + } + + const bishops = []; + for (let y = 0; y < 8; y += 1) { + for (let x = 0; x < 8; x += 1) { + const piece = board[y][x]; + if (piece && piece.type === "B") { + bishops.push({ ...piece, x, y }); + } + } + } + + if (nonKings.length === 2 && bishops.length === 2 && bishops[0].color !== bishops[1].color) { + return squareColor(bishops[0].x, bishops[0].y) === squareColor(bishops[1].x, bishops[1].y); + } + + return false; +} + +function evaluateStatus(state) { + const legalMoves = ChessEngine.generateLegalMovesForState(state, state.turn); + const check = isInCheck(state, state.turn); + const repetitionCount = state.positionCounts[positionHash(state)] || 0; + + const status = { + phase: "playing", + winner: null, + reason: null, + check, + legalMoves: legalMoves.length, + message: `${colorLabel(state.turn)} to move.`, + }; + + if (legalMoves.length === 0) { + if (check) { + status.phase = "checkmate"; + status.reason = "checkmate"; + status.winner = opponentColor(state.turn); + status.message = `${colorLabel(status.winner)} wins by checkmate.`; + } else { + status.phase = "draw"; + status.reason = "stalemate"; + status.message = "Draw by stalemate."; + } + return status; + } + + if (hasInsufficientMaterial(state.board)) { + status.phase = "draw"; + status.reason = "insufficient-material"; + status.message = "Draw by insufficient material."; + return status; + } + + if (state.halfmoveClock >= 100) { + status.phase = "draw"; + status.reason = "fifty-move"; + status.message = "Draw by fifty-move rule."; + return status; + } + + if (repetitionCount >= 3) { + status.phase = "draw"; + status.reason = "threefold"; + status.message = "Draw by threefold repetition."; + return status; + } + + if (check) { + status.message = `${colorLabel(state.turn)} to move. Check.`; + } + + return status; +} + +function applyMoveToState(state, move, options = {}) { + const { + recordHistory = false, + computeStatus = true, + updatePositionCounts = computeStatus || recordHistory, + } = options; + const nextState = cloneState(state); + const movingPiece = pieceAt(nextState.board, move.fromX, move.fromY); + + if (!movingPiece) { + throw new Error("No piece found on source square."); + } + + setPiece(nextState.board, move.fromX, move.fromY, null); + + if (move.enPassant) { + setPiece(nextState.board, move.toX, move.fromY, null); + } + + const placedPiece = createPiece( + movingPiece.color, + move.promotion ? move.promotion.toUpperCase() : movingPiece.type, + ); + setPiece(nextState.board, move.toX, move.toY, placedPiece); + + if (move.castle === "k") { + const rook = pieceAt(nextState.board, 7, move.fromY); + setPiece(nextState.board, 7, move.fromY, null); + setPiece(nextState.board, 5, move.fromY, rook); + } else if (move.castle === "q") { + const rook = pieceAt(nextState.board, 0, move.fromY); + setPiece(nextState.board, 0, move.fromY, null); + setPiece(nextState.board, 3, move.fromY, rook); + } + + updateCastlingRights(nextState, move); + + nextState.enPassant = move.doublePawnPush ? toSquare(move.fromX, (move.fromY + move.toY) / 2) : null; + nextState.halfmoveClock = move.piece === "P" || move.capture ? 0 : nextState.halfmoveClock + 1; + nextState.turn = opponentColor(state.turn); + + if (move.color === "b") { + nextState.fullmoveNumber += 1; + } + + nextState.lastMove = { + from: move.from, + to: move.to, + color: move.color, + }; + + if (updatePositionCounts) { + addPositionCount(nextState); + } + nextState.status = computeStatus ? evaluateStatus(nextState) : null; + + if (recordHistory) { + nextState.history.push(getMoveRecord(move, nextState.status, nextState)); + } + + return nextState; +} + +export class ChessEngine { + constructor(snapshot = null) { + this.state = snapshot ? cloneState(snapshot) : createInitialState(); + if (Object.keys(this.state.positionCounts).length === 0) { + addPositionCount(this.state); + } + this.state.status = evaluateStatus(this.state); + } + + reset() { + this.state = createInitialState(); + addPositionCount(this.state); + this.state.status = evaluateStatus(this.state); + return this.getSnapshot(); + } + + getSnapshot() { + return cloneState(this.state); + } + + getPiece(square) { + const coords = fromSquare(square); + if (!coords) { + return null; + } + return clonePiece(pieceAt(this.state.board, coords.x, coords.y)); + } + + getMovesFrom(square) { + return this.getLegalMoves(this.state.turn).filter((move) => move.from === square); + } + + getLegalMoves(color = this.state.turn) { + return ChessEngine.generateLegalMovesForState(this.state, color); + } + + makeMove(input) { + const legalMove = ChessEngine.findLegalMove(this.state, input); + if (!legalMove) { + return { + ok: false, + error: "Illegal move.", + state: this.getSnapshot(), + }; + } + + this.state = applyMoveToState(this.state, legalMove, { recordHistory: true }); + return { + ok: true, + move: legalMove, + state: this.getSnapshot(), + status: { ...this.state.status }, + }; + } + + static positionHash(state) { + return positionHash(state); + } + + static isInCheck(state, color) { + return isInCheck(state, color); + } + + static evaluateStatus(state) { + return evaluateStatus(state); + } + + static playMove(state, move, options = {}) { + return applyMoveToState(state, move, options); + } + + static findLegalMove(state, input) { + const from = typeof input.from === "string" ? input.from.toLowerCase() : ""; + const to = typeof input.to === "string" ? input.to.toLowerCase() : ""; + const promotion = input.promotion ? input.promotion.toLowerCase() : null; + + return ChessEngine.generateLegalMovesForState(state, state.turn).find( + (move) => + move.from === from && + move.to === to && + (move.promotion || null) === (promotion || null), + ); + } + + static generateLegalMovesForState(state, color = state.turn) { + const legalMoves = []; + + for (let y = 0; y < 8; y += 1) { + for (let x = 0; x < 8; x += 1) { + const piece = pieceAt(state.board, x, y); + if (!piece || piece.color !== color) { + continue; + } + + const pseudoMoves = generatePseudoMoves(state, x, y); + for (const move of pseudoMoves) { + const nextState = applyMoveToState(state, move, { + recordHistory: false, + computeStatus: false, + updatePositionCounts: false, + }); + if (!isInCheck(nextState, color)) { + legalMoves.push(move); + } + } + } + } + + return legalMoves; + } +} + +export const ChessRules = { + FILES, + colorLabel, + fromSquare, + toSquare, +};