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
text/typescript
/*!
* -------------------------------------------------------------------------- *
* *
* 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;
}
}
}