kokopu
Version:
A JavaScript/TypeScript library implementing the chess game rules and providing tools to read/write the standard chess file formats.
391 lines (338 loc) • 15.3 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 { ColorImpl, PieceImpl, SpI, GameVariantImpl, colorFromString, colorToString, fileFromString, fileToString, variantToString } from './base_types_impl';
import { PositionImpl, makeEmpty } from './impl';
import { refreshEffectiveCastling, refreshEffectiveEnPassant } from './legality';
import { InvalidFEN } from '../exception';
import { i18n } from '../i18n';
const FEN_PIECE_SYMBOL = [ ...'KkQqRrBbNnPp' ];
const EN_PASSANT_RANK = [ '6', '3' ];
/**
* Options for the {@link Position.ascii()} method.
*/
export interface PositionAsciiOptions {
/**
* If true, the board is represented from Black's point of view. false by default.
*/
flipped?: boolean,
/**
* If provided, each line of the ASCII-art picture is prefixed with this string.
*/
prefix?: string,
/**
* Whether board coordinates should be present or not. false by default.
*/
coordinateVisible?: boolean,
}
/**
* Return a human-readable string representing the position. This string is multi-line,
* and is intended to be displayed in a fixed-width font (similarly to an ASCII-art picture).
*/
export function ascii(position: PositionImpl, { flipped = false, prefix = '', coordinateVisible = false }: PositionAsciiOptions) {
// Board scanning
const lineSeparator = prefix + (coordinateVisible ? ' ' : '') + '+---+---+---+---+---+---+---+---+\n';
const rows = flipped ? [ 0, 1, 2, 3, 4, 5, 6, 7 ] : [ 7, 6, 5, 4, 3, 2, 1, 0 ];
const columns = flipped ? [ 7, 6, 5, 4, 3, 2, 1, 0 ] : [ 0, 1, 2, 3, 4, 5, 6, 7 ];
let result = lineSeparator;
for (const r of rows) {
result += prefix;
if (coordinateVisible) {
result += (r + 1) + ' ';
}
for (const c of columns) {
const cp = position.board[r * 16 + c];
result += '| ' + (cp === SpI.EMPTY ? ' ' : FEN_PIECE_SYMBOL[cp]) + ' ';
}
result += '|\n';
result += lineSeparator;
}
if (coordinateVisible) {
result += prefix + (flipped ? ' h g f e d c b a\n' : ' a b c d e f g h\n');
}
// Flags
result += prefix + colorToString(position.turn) + ' ' + castlingToString(position) + ' ' + enPassantToString(position);
if (position.variant !== GameVariantImpl.REGULAR_CHESS) {
result += ' (' + variantToString(position.variant) + ')';
}
return result;
}
export function getFEN(position: PositionImpl, fiftyMoveClock = 0, fullMoveNumber = 1, regularFENIfPossible = false) {
let result = '';
// Board scanning
for (let r = 7; r >= 0; --r) {
let emptyCount = 0;
for (let c = 0; c < 8; ++c) {
const cp = position.board[r * 16 + c];
if (cp === SpI.EMPTY) {
++emptyCount;
}
else {
if (emptyCount > 0) {
result += emptyCount;
emptyCount = 0;
}
result += FEN_PIECE_SYMBOL[cp];
}
}
if (emptyCount > 0) {
result += emptyCount;
}
if (r > 0) {
result += '/';
}
}
// Flags + additional move counters
result += ' ' + colorToString(position.turn) + ' ' + castlingToString(position, regularFENIfPossible) + ' ' + enPassantToString(position);
result += ' ' + fiftyMoveClock + ' ' + fullMoveNumber;
return result;
}
/**
* @param regularFENIfPossible - For Chess960, if `true`, format the flags as `KQkq` (regular FEN style) if possible
* (instead of `AB...Hab...h` which is used by default, i.e. X-FEN style).
* For the other variants, this flag has no effect, as regulary FEN style is always used.
*/
function castlingToString(position: PositionImpl, regularFENIfPossible = false) {
refreshEffectiveCastling(position);
if (position.variant === GameVariantImpl.CHESS960) {
if (regularFENIfPossible) {
const whiteRegularFlags = regularFENCaslingFlagIfPossible(position, ColorImpl.WHITE);
const blackRegularFlags = regularFENCaslingFlagIfPossible(position, ColorImpl.BLACK);
if (whiteRegularFlags !== false && blackRegularFlags !== false) {
return whiteRegularFlags === '' && blackRegularFlags === '' ? '-' : whiteRegularFlags.toUpperCase() + blackRegularFlags;
}
}
let whiteFlags = '';
let blackFlags = '';
for (let file = 0; file < 8; ++file) {
if (position.effectiveCastling![ColorImpl.WHITE] & 1 << file) { whiteFlags += fileToString(file); }
if (position.effectiveCastling![ColorImpl.BLACK] & 1 << file) { blackFlags += fileToString(file); }
}
return whiteFlags === '' && blackFlags === '' ? '-' : whiteFlags.toUpperCase() + blackFlags;
}
else {
let result = '';
if (position.effectiveCastling![ColorImpl.WHITE] & 0x80) { result += 'K'; }
if (position.effectiveCastling![ColorImpl.WHITE] & 0x01) { result += 'Q'; }
if (position.effectiveCastling![ColorImpl.BLACK] & 0x80) { result += 'k'; }
if (position.effectiveCastling![ColorImpl.BLACK] & 0x01) { result += 'q'; }
return result === '' ? '-' : result;
}
}
function regularFENCaslingFlagIfPossible(position: PositionImpl, color: number): string | false {
// Decompose the castling flags into:
//
// +---------------+---+--------------+
// | queenSideMask | 0 | kingSideMask |
// +---------------+---+--------------+
// ^ ^ ^
// File a King file File h
//
const kingFileMask = 1 << (position.king[color] % 16);
const kingSideMask = position.effectiveCastling![color] & ~(kingFileMask | (kingFileMask - 1));
const queenSideMask = position.effectiveCastling![color] & (kingFileMask - 1);
let fenFlag = '';
const firstSquare = 112 * color;
const lastSquare = 112 * color + 7;
const targetRook = PieceImpl.ROOK * 2 + color;
// Search for the rooks on king-side.
if (kingSideMask !== 0) {
let rookFound = false;
for (let sq = position.king[color] + 1; sq <= lastSquare; ++sq) {
if (position.board[sq] === targetRook) {
if (rookFound) { // Ensure there is only 1 rook on the king side.
return false;
}
else {
rookFound = true;
}
}
}
fenFlag += 'k';
}
// Search for the rooks on queen-side.
if (queenSideMask !== 0) {
let rookFound = false;
for (let sq = position.king[color] - 1; sq >= firstSquare; --sq) {
if (position.board[sq] === targetRook) {
if (rookFound) { // Ensure there is only 1 rook on the queen side.
return false;
}
else {
rookFound = true;
}
}
}
fenFlag += 'q';
}
return fenFlag;
}
function enPassantToString(position: PositionImpl) {
refreshEffectiveEnPassant(position);
return position.effectiveEnPassant! < 0 ? '-' : fileToString(position.effectiveEnPassant!) + EN_PASSANT_RANK[position.turn];
}
export function parseFEN(variant: number, fen: string, strict: boolean): { position: PositionImpl, fiftyMoveClock: number, fullMoveNumber: number } {
// Trim the input string and split it into 6 fields.
const fields = strict ? fen.split(' ') : fen.replace(/^\s+|\s+$/g, '').split(/\s+/);
if (fields.length !== 6) {
throw new InvalidFEN(fen, i18n.WRONG_NUMBER_OF_FEN_FIELDS);
}
// The first field (that represents the board) is split in 8 sub-fields.
const rankFields = fields[0].split('/');
if (rankFields.length !== 8) {
throw new InvalidFEN(fen, i18n.WRONG_NUMBER_OF_SUBFIELDS_IN_BOARD_FIELD);
}
// Initialize the position
const position = makeEmpty(variant);
position.legal = null;
position.effectiveCastling = null;
position.effectiveEnPassant = null;
// Board parsing
for (let r = 7; r >= 0; --r) {
const rankField = rankFields[7 - r];
let i = 0;
let c = 0;
while (i < rankField.length && c < 8) {
const s = rankField[i];
const cp = FEN_PIECE_SYMBOL.indexOf(s);
// The current character is in the range [1-8] -> skip the corresponding number of squares.
if (/^[1-8]$/.test(s)) {
c += parseInt(s, 10);
}
// The current character corresponds to a colored piece symbol -> set the current square accordingly.
else if (cp >= 0) {
position.board[r * 16 + c] = cp;
++c;
}
// Otherwise -> parsing error.
else {
throw new InvalidFEN(fen, i18n.UNEXPECTED_CHARACTER_IN_BOARD_FIELD, s);
}
// Increment the character counter.
++i;
}
// Ensure that the current sub-field deals with all the squares of the current rank.
if (i !== rankField.length || c !== 8) {
throw new InvalidFEN(fen, i18n.UNEXPECTED_END_OF_SUBFIELD_IN_BOARD_FIELD, 8 - r);
}
}
// Turn parsing
position.turn = colorFromString(fields[1]);
if (position.turn < 0) {
throw new InvalidFEN(fen, i18n.INVALID_TURN_FIELD);
}
// Castling rights parsing
const castling = variant === GameVariantImpl.CHESS960 ? castlingFromStringXFEN(fields[2], strict, position.board) : castlingFromStringFEN(fields[2], strict);
if (castling === null) {
throw new InvalidFEN(fen, i18n.INVALID_CASTLING_FIELD);
}
else {
position.castling = castling;
}
// En-passant rights parsing
const enPassantField = fields[3];
if (enPassantField !== '-') {
if (!/^[a-h][36]$/.test(enPassantField)) {
throw new InvalidFEN(fen, i18n.INVALID_EN_PASSANT_FIELD);
}
position.enPassant = fileFromString(enPassantField[0]);
if (strict) {
if (enPassantField[1] !== EN_PASSANT_RANK[position.turn]) {
throw new InvalidFEN(fen, i18n.WRONG_RANK_IN_EN_PASSANT_FIELD);
}
refreshEffectiveEnPassant(position);
if (position.enPassant !== position.effectiveEnPassant) {
throw new InvalidFEN(fen, i18n.INEFFECTIVE_EN_PASSANT_FIELD, fileToString(position.enPassant));
}
}
}
// Move counting flags parsing
const moveCountingRegex = strict ? /^(?:0|[1-9][0-9]*)$/ : /^[0-9]+$/;
if (!moveCountingRegex.test(fields[4])) {
throw new InvalidFEN(fen, i18n.INVALID_HALF_MOVE_COUNT_FIELD);
}
if (!moveCountingRegex.test(fields[5])) {
throw new InvalidFEN(fen, i18n.INVALID_MOVE_NUMBER_FIELD);
}
return { position: position, fiftyMoveClock: parseInt(fields[4], 10), fullMoveNumber: parseInt(fields[5], 10) };
}
function castlingFromStringFEN(castling: string, strict: boolean): number[] | null {
const result = [ 0, 0 ];
if (castling === '-') {
return result;
}
if (!(strict ? /^K?Q?k?q?$/ : /^[KQkq]*$/).test(castling)) {
return null;
}
if (castling.indexOf('K') >= 0) { result[ColorImpl.WHITE] |= 1 << 7; }
if (castling.indexOf('Q') >= 0) { result[ColorImpl.WHITE] |= 1 << 0; }
if (castling.indexOf('k') >= 0) { result[ColorImpl.BLACK] |= 1 << 7; }
if (castling.indexOf('q') >= 0) { result[ColorImpl.BLACK] |= 1 << 0; }
return result;
}
function castlingFromStringXFEN(castling: string, strict: boolean, board: number[]): number[] | null {
const result = [ 0, 0 ];
if (castling === '-') {
return result;
}
if (!(strict ? /^[A-H]{0,2}[a-h]{0,2}$/ : /^[A-Ha-h]*|[KQkq]*$/).test(castling)) {
return null;
}
function searchQueenSideRook(color: number) {
const targetRook = PieceImpl.ROOK * 2 + color;
const targetKing = PieceImpl.KING * 2 + color;
for (let sq = 112 * color; sq < 112 * color + 8; ++sq) {
if (board[sq] === targetRook) {
return sq % 8;
}
else if (board[sq] === targetKing) {
break;
}
}
return 0;
}
function searchKingSideRook(color: number) {
const targetRook = PieceImpl.ROOK * 2 + color;
const targetKing = PieceImpl.KING * 2 + color;
for (let sq = 112 * color + 7; sq >= 112 * color; --sq) {
if (board[sq] === targetRook) {
return sq % 8;
}
else if (board[sq] === targetKing) {
break;
}
}
return 7;
}
if (!strict) {
if (castling.indexOf('K') >= 0) { result[ColorImpl.WHITE] |= 1 << searchKingSideRook (ColorImpl.WHITE); }
if (castling.indexOf('Q') >= 0) { result[ColorImpl.WHITE] |= 1 << searchQueenSideRook(ColorImpl.WHITE); }
if (castling.indexOf('k') >= 0) { result[ColorImpl.BLACK] |= 1 << searchKingSideRook (ColorImpl.BLACK); }
if (castling.indexOf('q') >= 0) { result[ColorImpl.BLACK] |= 1 << searchQueenSideRook(ColorImpl.BLACK); }
}
for (let file = 0; file < 8; ++file) {
const s = fileToString(file);
if (castling.indexOf(s.toUpperCase()) >= 0) { result[ColorImpl.WHITE] |= 1 << file; }
if (castling.indexOf(s) >= 0) { result[ColorImpl.BLACK] |= 1 << file; }
}
return result;
}