Files
website-collection/chess/shared/chess-engine.js
T

825 lines
20 KiB
JavaScript
Raw Normal View History

2026-08-22 17:39:10 +00:00
//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,
};