UNPKG

kokopu

Version:

A JavaScript/TypeScript library implementing the chess game rules and providing tools to read/write the standard chess file formats.

529 lines (450 loc) 21.6 kB
/*! * -------------------------------------------------------------------------- * * * * Kokopu - A JavaScript/TypeScript chess library. * * <https://www.npmjs.com/package/kokopu> * * Copyright (C) 2018-2026 Yoann Le Montagner <yo35 -at- melix.net> * * * * Kokopu is free software: you can redistribute it and/or * * modify it under the terms of the GNU Lesser General Public License * * as published by the Free Software Foundation, either version 3 of * * the License, or (at your option) any later version. * * * * Kokopu is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU Lesser General Public License for more details. * * * * You should have received a copy of the GNU Lesser General * * Public License along with this program. If not, see * * <http://www.gnu.org/licenses/>. * * * * -------------------------------------------------------------------------- */ import { getAttacks } from './attacks'; import { ColorImpl, PieceImpl, SpI, GameVariantImpl, figurineFromString, figurineToString, fileFromString, fileToString, rankFromString, rankToString, pieceFromString, pieceToString, squareFromString, squareToString } from './base_types_impl'; import { getFEN } from './fen'; import { PositionImpl, makeCopy } from './impl'; import { isLegal, isKingSafeAfterMove, refreshEffectiveEnPassant, refreshEffectiveCastling } from './legality'; import { MoveDescriptorImpl } from './move_descriptor_impl'; import { isCheckmate, isCheck, isCaptureMandatory, isCastlingMoveLegal, play } from './move_generation'; import { InvalidNotation } from '../exception'; import { i18n } from '../i18n'; /** * Convert the given move descriptor to standard algebraic notation. */ export function getNotation(position: PositionImpl, descriptor: MoveDescriptorImpl, pieceStyle: 'standard' | 'figurine') { let result = ''; // Castling move if (descriptor.isCastling()) { result = descriptor._to % 16 === 6 ? 'O-O' : 'O-O-O'; } // Pawn move else if (Math.trunc(descriptor._movingColoredPiece / 2) === PieceImpl.PAWN) { if (descriptor.isCapture()) { result += fileToString(descriptor._from % 16) + 'x'; } result += squareToString(descriptor._to); if (descriptor.isPromotion()) { result += '=' + getPieceSymbol(descriptor._finalColoredPiece, pieceStyle); } } // Non-pawn move else { result += getPieceSymbol(descriptor._movingColoredPiece, pieceStyle); result += getDisambiguationSymbol(position, descriptor._from, descriptor._to); if (descriptor.isCapture()) { result += 'x'; } result += squareToString(descriptor._to); } // Check/checkmate detection and final result. result += getCheckCheckmateSymbol(position, descriptor); return result; } /** * Return a string representing the given chess piece according to the given style. */ function getPieceSymbol(coloredPiece: number, pieceStyle: 'standard' | 'figurine') { switch (pieceStyle) { case 'figurine': return figurineToString(coloredPiece); case 'standard': return pieceToString(Math.trunc(coloredPiece / 2)).toUpperCase(); } } /** * Return the check/checkmate symbol to use for a move. */ function getCheckCheckmateSymbol(position: PositionImpl, descriptor: MoveDescriptorImpl) { const nextPosition = makeCopy(position); play(nextPosition, descriptor); return isCheckmate(nextPosition) ? '#' : isCheck(nextPosition) ? '+' : ''; } /** * Return the disambiguation symbol to use for a move from `from` to `to`. */ function getDisambiguationSymbol(position: PositionImpl, from: number, to: number) { const attackers = getAttacks(position, to, position.turn).filter(sq => position.board[sq] === position.board[from]); // Disambiguation is not necessary if there less than 2 attackers. if (attackers.length < 2) { return ''; } let foundNotPined = false; let foundOnSameRank = false; let foundOnSameFile = false; const rankFrom = Math.trunc(from / 16); const fileFrom = from % 16; for (const sq of attackers) { if (sq === from || isPinned(position, sq, to)) { continue; } foundNotPined = true; if (rankFrom === Math.trunc(sq / 16)) { foundOnSameRank = true; } if (fileFrom === sq % 16) { foundOnSameFile = true; } } if (foundOnSameFile) { return foundOnSameRank ? squareToString(from) : rankToString(rankFrom); } else { return foundNotPined ? fileToString(fileFrom) : ''; } } /** * Whether the piece on the given square is pinned or not. */ function isPinned(position: PositionImpl, sq: number, aimingAtSq: number) { const kingSquare = position.king[position.turn]; if (kingSquare < 0) { return false; } const vector = Math.abs(kingSquare - sq); const aimingAtVector = Math.abs(aimingAtSq - sq); const pinnerQueen = PieceImpl.QUEEN * 2 + 1 - position.turn; const pinnerRook = PieceImpl.ROOK * 2 + 1 - position.turn; const pinnerBishop = PieceImpl.BISHOP * 2 + 1 - position.turn; // Potential pinning on file or rank. if (vector < 8) { return aimingAtVector >= 8 && pinningLoockup(position, kingSquare, sq, kingSquare < sq ? 1 : -1, pinnerRook, pinnerQueen); } else if (vector % 16 === 0) { return aimingAtVector % 16 !== 0 && pinningLoockup(position, kingSquare, sq, kingSquare < sq ? 16 : -16, pinnerRook, pinnerQueen); } // Potential pinning on diagonal. else if (vector % 15 === 0) { return aimingAtVector % 15 !== 0 && pinningLoockup(position, kingSquare, sq, kingSquare < sq ? 15 : -15, pinnerBishop, pinnerQueen); } else if (vector % 17 === 0) { return aimingAtVector % 17 !== 0 && pinningLoockup(position, kingSquare, sq, kingSquare < sq ? 17 : -17, pinnerBishop, pinnerQueen); } // No pinning for sure. else { return false; } } function pinningLoockup(position: PositionImpl, kingSquare: number, targetSquare: number, direction: number, pinnerColoredPiece1: number, pinnerColoredPiece2: number) { for (let sq = kingSquare + direction; sq !== targetSquare; sq += direction) { if (position.board[sq] !== SpI.EMPTY) { return false; } } for (let sq = targetSquare + direction; (sq & 0x88) === 0; sq += direction) { if (position.board[sq] !== SpI.EMPTY) { return position.board[sq] === pinnerColoredPiece1 || position.board[sq] === pinnerColoredPiece2; } } return false; } /** * Parse a move notation for the given position. */ export function parseNotation(position: PositionImpl, notation: string, strict: boolean, pieceStyle: 'standard' | 'figurine') { // General syntax const m = /^(?:(O-O-O|0-0-0)|(O-O|0-0)|([A-Z\u2654-\u265f])([a-h])?([1-8])?(x)?([a-h][1-8])|(?:([a-h])(x)?)?([a-h][1-8])(?:(=)?([A-Z\u2654-\u265f]))?)([+#])?$/.exec(notation); if (m === null) { throw new InvalidNotation(getFEN(position), notation, i18n.INVALID_MOVE_NOTATION_SYNTAX); } // Ensure that the position is legal. if (!isLegal(position)) { throw new InvalidNotation(getFEN(position), notation, i18n.ILLEGAL_POSITION); } // CASTLING // m[1] -> O-O-O // m[2] -> O-O // NON-PAWN MOVE // m[3] -> moving piece // m[4] -> file disambiguation // m[5] -> rank disambiguation // m[6] -> x (capture symbol) // m[7] -> to // PAWN MOVE // m[ 8] -> from column (only for captures) // m[ 9] -> x (capture symbol) // m[10] -> to // m[11] -> = (promotion symbol) // m[12] -> promoted piece // OTHER // m[13] -> +/# (check/checkmate symbol) let descriptor: MoveDescriptorImpl; // Parse castling moves if (m[1] !== undefined || m[2] !== undefined) { descriptor = parseCastlingNotation(position, notation, strict, m[1], m[2]); } // Non-pawn move else if (m[3] !== undefined) { descriptor = parseNonPawnNotation(position, notation, strict, pieceStyle, m[3], m[4], m[5], m[7]); } // Pawn move else { descriptor = parsePawnMoveNotation(position, notation, strict, pieceStyle, m[8], m[10], m[11], m[12]); } // STRICT MODE if (strict) { const observedIsCapture = m[6] !== undefined || m[9] !== undefined; if (descriptor.isCapture() !== observedIsCapture) { const message = descriptor.isCapture() ? i18n.MISSING_CAPTURE_SYMBOL : i18n.INVALID_CAPTURE_SYMBOL; throw new InvalidNotation(getFEN(position), notation, message); } const expectedCCS = getCheckCheckmateSymbol(position, descriptor); const observedCCS = m[13] ?? ''; if (expectedCCS !== observedCCS) { throw new InvalidNotation(getFEN(position), notation, i18n.WRONG_CHECK_CHECKMATE_SYMBOL, expectedCCS, observedCCS); } } // Final result return descriptor; } /** * Delegate function that computes the move descriptor corresponding to a castling move (corresponding notation: "O-O" or "O-O-O"). */ function parseCastlingNotation(position: PositionImpl, notation: string, strict: boolean, queenSideCastlingSymbol: string | undefined, kingSideCastlingSymbol: string | undefined): MoveDescriptorImpl { const from = position.king[position.turn]; if (from < 0) { throw new InvalidNotation(getFEN(position), notation, i18n.ILLEGAL_NO_KING_CASTLING); } refreshEffectiveCastling(position); const isKingSideCastling = kingSideCastlingSymbol !== undefined; const toFile = getCastlingDestinationFile(position, isKingSideCastling); const descriptor = toFile >= 0 ? isCastlingMoveLegal(position, from, toFile + 112 * position.turn) : false; if (!descriptor) { const message = isKingSideCastling ? i18n.ILLEGAL_KING_SIDE_CASTLING : i18n.ILLEGAL_QUEEN_SIDE_CASTLING; throw new InvalidNotation(getFEN(position), notation, message); } // STRICT-MODE -> ensure that upper-case O is used instead of digit 0. if (strict) { const firstChar = (isKingSideCastling ? kingSideCastlingSymbol : queenSideCastlingSymbol!).charAt(0); if (firstChar === '0') { throw new InvalidNotation(getFEN(position), notation, i18n.CASTLING_MOVE_ENCODED_WITH_ZERO); } } return descriptor; } /** * Returns the file of a `to` square to take into account to check whether a castling move is legal or not. */ function getCastlingDestinationFile(position: PositionImpl, isKingSideCastling: boolean) { if (position.variant === GameVariantImpl.CHESS960) { if (position.effectiveCastling![position.turn] !== 0) { const castlingKing = PieceImpl.KING * 2 + position.turn; for (let file = isKingSideCastling ? 7 : 0; position.board[file + 112 * position.turn] !== castlingKing; file += isKingSideCastling ? -1 : 1) { if ((position.effectiveCastling![position.turn] & 1 << file) !== 0) { return file; } } } return -1; } else { return isKingSideCastling ? 6 : 2; } } /** * Delegate function that computes the move descriptor corresponding to a non-pawn move. Corresponding notation, for instance "Ne3xd5": * * - N: piece symbol * - e: file disambiguation * - 3: rank disambiguation * - d5: destination square */ function parseNonPawnNotation(position: PositionImpl, notation: string, strict: boolean, pieceStyle: 'standard' | 'figurine', pieceSymbol: string, fileDisambiguation: string | undefined, rankDisambiguation: string | undefined, destinationSquare: string): MoveDescriptorImpl { const movingColoredPiece = parsePieceSymbol(position, notation, pieceSymbol, strict, pieceStyle) * 2 + position.turn; const to = squareFromString(destinationSquare); const toContent = position.board[to]; // Cannot take your own pieces! if (toContent !== SpI.EMPTY && toContent % 2 === position.turn) { throw new InvalidNotation(getFEN(position), notation, i18n.TRYING_TO_CAPTURE_YOUR_OWN_PIECES); } // Capture may be mandatory in some variants. if (toContent === SpI.EMPTY && isCaptureMandatory(position)) { throw new InvalidNotation(getFEN(position), notation, i18n.CAPTURE_IS_MANDATORY); } // Find the "from"-square candidates let attackers = getAttacks(position, to, position.turn).filter(sq => position.board[sq] === movingColoredPiece); // Apply disambiguation if (fileDisambiguation !== undefined) { const fileFrom = fileFromString(fileDisambiguation); attackers = attackers.filter(sq => sq % 16 === fileFrom); } if (rankDisambiguation !== undefined) { const rankFrom = rankFromString(rankDisambiguation); attackers = attackers.filter(sq => Math.trunc(sq / 16) === rankFrom); } if (attackers.length === 0) { const message = fileDisambiguation === undefined && rankDisambiguation === undefined ? i18n.NO_PIECE_CAN_MOVE_TO : i18n.NO_PIECE_CAN_MOVE_TO_DISAMBIGUATION; throw new InvalidNotation(getFEN(position), notation, message, pieceSymbol, destinationSquare); } // Compute the move descriptor for each remaining "from"-square candidate let descriptor: MoveDescriptorImpl | false = false; for (const sq of attackers) { if (isKingSafeAfterMove(position, sq, to)) { if (descriptor) { throw new InvalidNotation(getFEN(position), notation, i18n.REQUIRE_DISAMBIGUATION, pieceSymbol, destinationSquare); } descriptor = MoveDescriptorImpl.make(sq, to, movingColoredPiece, toContent); } } if (!descriptor) { const message = position.turn === ColorImpl.WHITE ? i18n.NOT_SAFE_FOR_WHITE_KING : i18n.NOT_SAFE_FOR_BLACK_KING; throw new InvalidNotation(getFEN(position), notation, message); } // STRICT-MODE -> check the disambiguation symbol. if (strict) { const expectedDS = getDisambiguationSymbol(position, descriptor._from, to); const observedDS = (fileDisambiguation ?? '') + (rankDisambiguation ?? ''); if (expectedDS !== observedDS) { throw new InvalidNotation(getFEN(position), notation, i18n.WRONG_DISAMBIGUATION_SYMBOL, expectedDS, observedDS); } } return descriptor; } /** * Delegate function that computes the move descriptor corresponding to a pawn move. Corresponding notation, for instance "fxg8=Q": * * - f: origin file * - g8: destination square * - =: promotion symbol * - Q: promoted piece */ function parsePawnMoveNotation(position: PositionImpl, notation: string, strict: boolean, pieceStyle: 'standard' | 'figurine', originFile: string | undefined, destinationSquare: string, promotionSymbol: string | undefined, promotedPiece: string | undefined): MoveDescriptorImpl { const coloredPawn = PieceImpl.PAWN * 2 + position.turn; const to = squareFromString(destinationSquare); const toContent = position.board[to]; const vector = 16 - position.turn * 32; let from = to - vector; let enPassantSquare = -1; if (originFile !== undefined) { // Capturing pawn move // Ensure that `to` is not on the 1st row. if ((from & 0x88) !== 0) { throw new InvalidNotation(getFEN(position), notation, i18n.INVALID_CAPTURING_PAWN_MOVE); } // Compute the "from"-square. const columnFrom = fileFromString(originFile); const columnTo = to % 16; if (columnTo - columnFrom === 1) { from -= 1; } else if (columnTo - columnFrom === -1) { from += 1; } else { throw new InvalidNotation(getFEN(position), notation, i18n.INVALID_CAPTURING_PAWN_MOVE); } // Check the content of the "from"-square if (position.board[from] !== coloredPawn) { throw new InvalidNotation(getFEN(position), notation, i18n.INVALID_CAPTURING_PAWN_MOVE); } // Check the content of the "to"-square if (toContent === SpI.EMPTY) { // Look for en-passant captures refreshEffectiveEnPassant(position); if (to !== (5 - position.turn * 3) * 16 + position.effectiveEnPassant!) { throw new InvalidNotation(getFEN(position), notation, i18n.INVALID_CAPTURING_PAWN_MOVE); } enPassantSquare = (4 - position.turn) * 16 + position.effectiveEnPassant!; } else if (toContent % 2 === position.turn) { // detecting regular captures throw new InvalidNotation(getFEN(position), notation, i18n.INVALID_CAPTURING_PAWN_MOVE); } } else if (isCaptureMandatory(position)) { throw new InvalidNotation(getFEN(position), notation, i18n.CAPTURE_IS_MANDATORY); } else { // Non-capturing pawn move // Ensure that `to` is not on the 1st row. if ((from & 0x88) !== 0) { throw new InvalidNotation(getFEN(position), notation, i18n.INVALID_NON_CAPTURING_PAWN_MOVE); } // Check the content of the "to"-square if (toContent !== SpI.EMPTY) { throw new InvalidNotation(getFEN(position), notation, i18n.INVALID_NON_CAPTURING_PAWN_MOVE); } // Check the content of the "from"-square if (position.board[from] === SpI.EMPTY) { // Look for two-square pawn moves from -= vector; const firstSquareOfArea = position.turn * 96; // a1 for white, a7 for black (2-square pawn move is allowed from 1st row at horde chess) if (from < firstSquareOfArea || from >= firstSquareOfArea + 24 || position.board[from] !== coloredPawn) { throw new InvalidNotation(getFEN(position), notation, i18n.INVALID_NON_CAPTURING_PAWN_MOVE); } } else if (position.board[from] !== coloredPawn) { throw new InvalidNotation(getFEN(position), notation, i18n.INVALID_NON_CAPTURING_PAWN_MOVE); } } // Ensure that the pawn move do not let a king in check. if (!isKingSafeAfterMove(position, from, to, enPassantSquare)) { const message = position.turn === ColorImpl.WHITE ? i18n.NOT_SAFE_FOR_WHITE_KING : i18n.NOT_SAFE_FOR_BLACK_KING; throw new InvalidNotation(getFEN(position), notation, message); } // Promotions if (to < 8 || to >= 112) { if (promotedPiece === undefined) { throw new InvalidNotation(getFEN(position), notation, i18n.MISSING_PROMOTION); } const promotion = parsePieceSymbol(position, notation, promotedPiece, strict, pieceStyle); if (promotion === PieceImpl.PAWN || (promotion === PieceImpl.KING && position.variant !== GameVariantImpl.ANTICHESS)) { throw new InvalidNotation(getFEN(position), notation, i18n.INVALID_PROMOTED_PIECE, promotedPiece); } // STRICT MODE -> do not forget the `=` character! if (strict && promotionSymbol === undefined) { throw new InvalidNotation(getFEN(position), notation, i18n.MISSING_PROMOTION_SYMBOL); } return MoveDescriptorImpl.makePromotion(from, to, position.turn, toContent, promotion); } // Non-promotion moves else { if (promotedPiece !== undefined) { // Detect illegal promotion attempts! throw new InvalidNotation(getFEN(position), notation, i18n.ILLEGAL_PROMOTION); } return enPassantSquare >= 0 ? MoveDescriptorImpl.makeEnPassant(from, to, enPassantSquare, position.turn) : MoveDescriptorImpl.make(from, to, coloredPawn, toContent); } } /** * Delegate function for piece symbol parsing. */ function parsePieceSymbol(position: PositionImpl, notation: string, pieceSymbol: string, strict: boolean, pieceStyle: 'standard' | 'figurine') { switch (pieceStyle) { case 'figurine': { const coloredPieceCode = figurineFromString(pieceSymbol); if (coloredPieceCode < 0) { throw new InvalidNotation(getFEN(position), notation, i18n.INVALID_PIECE_SYMBOL, pieceSymbol); } if (strict && coloredPieceCode % 2 !== position.turn) { throw new InvalidNotation(getFEN(position), notation, i18n.INVALID_PIECE_SYMBOL_COLOR, pieceSymbol); } return Math.trunc(coloredPieceCode / 2); } case 'standard': { const pieceCode = pieceFromString(pieceSymbol.toLowerCase()); if (pieceCode < 0) { throw new InvalidNotation(getFEN(position), notation, i18n.INVALID_PIECE_SYMBOL, pieceSymbol); } return pieceCode; } } }