1301 lines
37 KiB
JavaScript
1301 lines
37 KiB
JavaScript
'use strict';
|
|
|
|
import {Iterator} from './iterator.js';
|
|
import {Buffer} from 'buffer';
|
|
|
|
/* Game states */
|
|
const PLAYING = 'playing';
|
|
const RESIGNED = 'resigned';
|
|
const CHECKMATE = 'checkmate';
|
|
|
|
/* Sides */
|
|
const LIGHT = 'light';
|
|
const DARK = 'dark';
|
|
|
|
/* Pieces */
|
|
const KING = 'k';
|
|
const QUEEN = 'q';
|
|
const ROOK = 'r';
|
|
const KNIGHT = 'n';
|
|
const BISHOP = 'b';
|
|
const PAWN = 'p';
|
|
const EMPTY = ' ';
|
|
|
|
const ALL_PIECES = EMPTY + PAWN + BISHOP + KNIGHT + ROOK + QUEEN + KING;
|
|
|
|
/* Special squares */
|
|
const PHANTOM = 'phantom';
|
|
|
|
/* a1...h1 and a8...h8 */
|
|
const INITIAL_EDGE_ROW = ROOK + KNIGHT + BISHOP + QUEEN + KING + BISHOP + KNIGHT + ROOK;
|
|
|
|
const ROWS = '12345678';
|
|
const COLUMNS = 'abcdefgh';
|
|
|
|
/*
|
|
* Rule Version
|
|
*
|
|
* Increment this whenever a change in the rules would make some previously
|
|
* illegal moves illegal. The implementation in the Game class needs to check
|
|
* this._version and apply the appropriate rules based on which rules were in
|
|
* effect when the game was started.
|
|
*
|
|
* Version 1: Initial version. (Default if version field is missing.)
|
|
* Version 2: Prohibit moving through check.
|
|
*/
|
|
const CURRENT_VERSION = 2;
|
|
|
|
function otherSide(side) {
|
|
if (side === LIGHT) {
|
|
return DARK;
|
|
} else if (side === DARK) {
|
|
return LIGHT;
|
|
} else {
|
|
throw new Error(`invalid side: ${side}`);
|
|
}
|
|
}
|
|
|
|
function squareIndex(square) {
|
|
if (typeof square !== 'string' || square.length !== 2) {
|
|
throw new Error(`invalid square: ${square}`);
|
|
}
|
|
|
|
const column = COLUMNS.indexOf(square[0].toLowerCase());
|
|
const row = ROWS.indexOf(square[1]);
|
|
|
|
if (column < 0 || row < 0) {
|
|
throw new Error(`invalid square: ${square}`);
|
|
}
|
|
|
|
return (row * 8) + column;
|
|
}
|
|
|
|
function offsetSquare(from, columnsRight, rowsUp) {
|
|
let index = squareIndex(from);
|
|
let column = (index & 0b111) + columnsRight;
|
|
let row = (index >>> 3) + rowsUp;
|
|
|
|
if (column >= 0 && column < 8 && row >= 0 && row < 8) {
|
|
return COLUMNS[column] + ROWS[row];
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function encodePiece(side, type) {
|
|
let result = ALL_PIECES.indexOf(type);
|
|
|
|
if (result < 0) {
|
|
throw new Error(`invalid piece: ${type}`);
|
|
}
|
|
|
|
if (side === LIGHT) {
|
|
return (result << 4);
|
|
} else if (side === DARK) {
|
|
return result;
|
|
} else {
|
|
throw new Error(`invalid side: ${side}`);
|
|
}
|
|
}
|
|
|
|
function decodePiece(side, value) {
|
|
let sideShift = null;
|
|
|
|
if (side === LIGHT) {
|
|
sideShift = 4;
|
|
} else if (side === DARK) {
|
|
sideShift = 0;
|
|
} else {
|
|
throw new Error(`invalid side: ${side}`);
|
|
}
|
|
|
|
let pieceValue = (value >>> sideShift) & 0b1111;
|
|
|
|
if (pieceValue < ALL_PIECES.length) {
|
|
return ALL_PIECES[pieceValue];
|
|
} else {
|
|
throw new Error(`invalid encoded piece: ${pieceValue}`);
|
|
}
|
|
}
|
|
|
|
class Board {
|
|
constructor(original) {
|
|
if (original !== null && original !== undefined) {
|
|
if (!(original instanceof this.constructor)) {
|
|
throw new TypeError(`can only clone from another Board instance`);
|
|
}
|
|
|
|
this._board = new Uint8Array(original._board);
|
|
this._phantom = original._phantom && Object.assign({}, original._phantom);
|
|
} else {
|
|
this._board = new Uint8Array(64);
|
|
this._board.fill(0);
|
|
this._phantom = null;
|
|
|
|
for (let column = 0; column < 8; ++column) {
|
|
const letter = COLUMNS[column];
|
|
this.putPiece(DARK, letter + '8', INITIAL_EDGE_ROW[column]);
|
|
this.putPiece(DARK, letter + '7', PAWN);
|
|
this.putPiece(LIGHT, letter + '2', PAWN);
|
|
this.putPiece(LIGHT, letter + '1', INITIAL_EDGE_ROW[column]);
|
|
}
|
|
}
|
|
}
|
|
|
|
toString() {
|
|
const bufferStr = new Buffer(this._board).toString('hex');
|
|
if (this._phantom) {
|
|
const phantom = this._phantom;
|
|
const phantomStr = ':' + phantom.side[0] + phantom.type + phantom.from;
|
|
return bufferStr + phantomStr;
|
|
} else {
|
|
return bufferStr;
|
|
}
|
|
}
|
|
|
|
get phantom() {
|
|
return this._phantom && Object.assign({}, this._phantom);
|
|
}
|
|
|
|
getPiece(side, square) {
|
|
if (square === PHANTOM) {
|
|
if (this._phantom && this._phantom.side === side) {
|
|
return this._phantom.type;
|
|
} else {
|
|
return EMPTY;
|
|
}
|
|
}
|
|
|
|
return decodePiece(side, this._board[squareIndex(square)]);
|
|
}
|
|
|
|
isEmpty(square) {
|
|
if (square === PHANTOM) {
|
|
return this._phantom === null;
|
|
} else {
|
|
return this._board[squareIndex(square)] === 0;
|
|
}
|
|
}
|
|
|
|
putPiece(side, square, piece) {
|
|
const other = otherSide(side);
|
|
const otherPiece = this.getPiece(other, square);
|
|
const together = encodePiece(side, piece) | encodePiece(other, otherPiece);
|
|
this._board[squareIndex(square)] = together;
|
|
}
|
|
|
|
makePhantom(side, square) {
|
|
const type = this.getPiece(side, square);
|
|
|
|
if (type !== EMPTY) {
|
|
if (this._phantom) {
|
|
throw new Error(`phantom square is already occupied`);
|
|
}
|
|
|
|
this.putPiece(side, square, EMPTY);
|
|
|
|
this._phantom = {
|
|
side: side,
|
|
type: type,
|
|
from: square,
|
|
};
|
|
}
|
|
}
|
|
|
|
move(from, to) {
|
|
if (this._phantom && from !== PHANTOM) {
|
|
throw new Error(`must complete prior move before moving other pieces`);
|
|
}
|
|
|
|
let lightPiece = this.getPiece(LIGHT, from);
|
|
let darkPiece = this.getPiece(DARK, from);
|
|
|
|
if (lightPiece === EMPTY && darkPiece === EMPTY) {
|
|
throw new Error(`cannot move from empty square: ${from} → ${to}`);
|
|
} else if (lightPiece !== EMPTY && darkPiece !== EMPTY) {
|
|
if (!this.isEmpty(to)) {
|
|
throw new Error(`cannot capture with joined pieces`);
|
|
}
|
|
|
|
const fromIndex = squareIndex(from);
|
|
const toIndex = squareIndex(to);
|
|
this._board[toIndex] = this._board[fromIndex];
|
|
this._board[fromIndex] = 0;
|
|
} else {
|
|
const moving = (lightPiece === EMPTY) ? DARK : LIGHT;
|
|
const movingPiece = (lightPiece === EMPTY) ? darkPiece : lightPiece;
|
|
const other = (lightPiece === EMPTY) ? LIGHT : DARK;
|
|
const displaced = this.getPiece(moving, to);
|
|
|
|
if (displaced !== EMPTY && this.getPiece(other, to) === EMPTY) {
|
|
throw new Error(`cannot join with piece of same color`);
|
|
}
|
|
|
|
if (from === PHANTOM) {
|
|
this._phantom = null;
|
|
} else {
|
|
this.putPiece(moving, from, EMPTY);
|
|
}
|
|
|
|
this.makePhantom(moving, to);
|
|
this.putPiece(moving, to, movingPiece);
|
|
}
|
|
}
|
|
|
|
validDestination(side, where, canCapture) {
|
|
let ours = this.getPiece(side, where);
|
|
let theirs = this.getPiece(otherSide(side), where);
|
|
return ((theirs === EMPTY) ? (ours === EMPTY) : (canCapture ? true : false));
|
|
}
|
|
|
|
*scanPath(side, from, canCapture, columnsRight, rowsUp, remainder) {
|
|
while (true) {
|
|
let there = offsetSquare(from, columnsRight, rowsUp);
|
|
|
|
if (!there || !this.validDestination(side, there, canCapture)) {
|
|
return;
|
|
}
|
|
|
|
yield there;
|
|
|
|
if (remainder < 1 || this.getPiece(otherSide(side), there) !== EMPTY) {
|
|
return;
|
|
}
|
|
|
|
from = there;
|
|
remainder -= 1;
|
|
}
|
|
}
|
|
|
|
*findPieces(side, type, alongside) {
|
|
const other = otherSide(side);
|
|
|
|
function testFunction(getPiece, match) {
|
|
if (match === undefined) {
|
|
return function(/*here*/) { return true; };
|
|
} else if (match === false) {
|
|
return function(here) { return getPiece(here) === EMPTY; };
|
|
} else if (match === true) {
|
|
return function(here) { return getPiece(here) !== EMPTY; };
|
|
} else {
|
|
return function(here) { return getPiece(here) === match; };
|
|
}
|
|
}
|
|
|
|
const testSide = testFunction(this.getPiece.bind(this, side), type);
|
|
const testOther = testFunction(this.getPiece.bind(this, other), alongside);
|
|
const test = function(here) { return testSide(here) && testOther(here); };
|
|
|
|
if (this._phantom && this._phantom.side === side) {
|
|
const getPhantom = (/*here*/) => this._phantom.type;
|
|
const testPhantom = testFunction(getPhantom, type);
|
|
if (testPhantom(PHANTOM)) {
|
|
yield this._phantom.from;
|
|
}
|
|
}
|
|
|
|
for (const row of ROWS) {
|
|
for (const column of COLUMNS) {
|
|
const here = column + row;
|
|
if (test(here)) {
|
|
yield here;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const ORTHO = [[-1, 0], [1, 0], [0, -1], [0, 1]];
|
|
const DIAG = [[-1, -1], [-1, 1], [1, -1], [1, 1]];
|
|
const ANY_DIR = ORTHO.concat(DIAG);
|
|
|
|
const KNIGHT_DIR =
|
|
[[-1, 2], [ 1, 2],
|
|
[-2, 1], [ 2, 1],
|
|
[-2, -1], [ 2, -1],
|
|
[-1, -2], [ 1, -2]];
|
|
|
|
const NBSP = '\u00a0'; /* non-breaking space */
|
|
const SHY = '\u00ad'; /* soft hyphen */
|
|
/* const ZWSP = '\u200b'; */ /* zero-width space */
|
|
|
|
function addHistory(game) {
|
|
const prior = game._undo;
|
|
const move = game.lastMove;
|
|
|
|
if (game._history === undefined) {
|
|
if (!move.phantom && !move.resign && move.side === LIGHT) {
|
|
game._turn += 1;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
let result = '';
|
|
if (move.phantom) {
|
|
result += SHY + '*';
|
|
} else if (!move.resign) {
|
|
if (game._turn > 0 || move.side === DARK) {
|
|
result += ' ';
|
|
}
|
|
|
|
if (move.side === LIGHT) {
|
|
game._turn += 1;
|
|
result += String(game._turn) + '.' + NBSP;
|
|
}
|
|
}
|
|
|
|
if (move.castle) {
|
|
result += 'O-O';
|
|
} else if (move.queen_castle) {
|
|
result += 'O-O-O';
|
|
} else if (move.side && move.type && move.from && move.to) {
|
|
let piece = '';
|
|
|
|
if (move.alongside) {
|
|
if (move.side === LIGHT) {
|
|
piece = move.type.toUpperCase() + move.alongside.toUpperCase();
|
|
} else {
|
|
piece = move.alongside.toUpperCase() + move.type.toUpperCase();
|
|
}
|
|
} else {
|
|
piece = move.type === PAWN ? '' : move.type.toUpperCase();
|
|
}
|
|
|
|
/* the second condition below is for en passant of a joined piece */
|
|
if (!move.phantom || move.from !== prior.lastMove.to) {
|
|
const sameKind = prior.board.findPieces(move.side, move.type, move.alongside || EMPTY);
|
|
const legalFrom = [];
|
|
let sameFile = 0; /* column / letter */
|
|
let sameRank = 0; /* row / number */
|
|
|
|
for (const where of sameKind) {
|
|
if (prior.isLegalMove(move.side, where, move.to, true)) {
|
|
legalFrom.push(where);
|
|
|
|
if (where[0] === move.from[0]) {
|
|
sameFile += 1;
|
|
}
|
|
|
|
if (where[1] === move.from[1]) {
|
|
sameRank += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* always disambiguate captures by pawns (standard convention) */
|
|
if (legalFrom.length !== 1 || (move.type === PAWN && move.took)) {
|
|
/* append file, rank, or both to disambiguate */
|
|
if (sameFile === 1) {
|
|
piece += move.from[0];
|
|
} else if (sameRank === 1) {
|
|
piece += move.from[1];
|
|
} else {
|
|
piece += move.from;
|
|
}
|
|
}
|
|
}
|
|
|
|
const took = move.took ? 'x' : '';
|
|
result += piece + took + move.to;
|
|
}
|
|
|
|
if (move.en_passant) {
|
|
result += 'e.p.';
|
|
}
|
|
|
|
if (move.promotion) {
|
|
result += '(' + move.promotion.toUpperCase() + ')';
|
|
}
|
|
|
|
game._history += result;
|
|
}
|
|
|
|
function diffHistory(game1, game2) {
|
|
let prefixLength = 0;
|
|
|
|
while (prefixLength < game1._moves.length && prefixLength < game2._moves.length) {
|
|
const move1 = game1._moves[prefixLength];
|
|
const move2 = game2._moves[prefixLength];
|
|
|
|
if (move2.resign !== move1.resign ||
|
|
move2.from !== move1.from ||
|
|
move2.to !== move1.to) {
|
|
break;
|
|
}
|
|
|
|
prefixLength += 1;
|
|
}
|
|
|
|
const hist = new Game(game1._history === undefined ? game2 : game1);
|
|
game1 = undefined;
|
|
|
|
hist.clearRedo();
|
|
while (hist._moves.length > prefixLength) {
|
|
hist.undo();
|
|
hist.clearRedo();
|
|
}
|
|
|
|
const turn_before = hist._turn;
|
|
const history_before = hist.renderHistory();
|
|
hist.ignoreCheck = game2.ignoreCheck;
|
|
|
|
for (const move of game2._moves.slice(prefixLength)) {
|
|
if (hist._player !== move.side) {
|
|
if (hist._player === LIGHT) {
|
|
hist._turn += 1;
|
|
hist._history += `${hist._turn === 1 ? '' : ' '}${hist._turn}.${NBSP}…`;
|
|
} else {
|
|
hist._history += ` …`;
|
|
}
|
|
|
|
hist._player = move.side;
|
|
}
|
|
|
|
try {
|
|
hist._status = PLAYING;
|
|
hist.replayMove(move);
|
|
} catch(err) {
|
|
try {
|
|
hist.ignoreCheck = true;
|
|
hist.replayMove(move);
|
|
hist.ignoreCheck = game2.ignoreCheck;
|
|
} catch(err) {
|
|
return undefined;
|
|
}
|
|
}
|
|
}
|
|
|
|
let suffix = hist.history.slice(history_before.length).match(/^(?:\s|…)*(.*)$/)[1];
|
|
if (!suffix.match(/^\d+\./)) {
|
|
suffix = `${turn_before}.${NBSP}… ${suffix}`;
|
|
}
|
|
|
|
return suffix;
|
|
}
|
|
|
|
class Game {
|
|
constructor(original) {
|
|
if (typeof original === 'string') {
|
|
const moves = JSON.parse(original);
|
|
original = new Game();
|
|
|
|
if ('version' in moves === false) {
|
|
original._version = 1;
|
|
} else if (!Number.isInteger(moves.version) ||
|
|
moves.version < 1 || moves.version > CURRENT_VERSION) {
|
|
throw new Error('invalid version');
|
|
} else {
|
|
original._version = moves.version;
|
|
}
|
|
|
|
for (const move of moves.past) {
|
|
original.replayMove(move);
|
|
}
|
|
|
|
let n = 0;
|
|
|
|
for (const move of moves.future.slice().reverse()) {
|
|
original.replayMove(move);
|
|
n += 1;
|
|
}
|
|
|
|
for (let i = 0; i < n; ++i) {
|
|
original.undo();
|
|
}
|
|
}
|
|
|
|
if (original !== undefined) {
|
|
if (!(original instanceof this.constructor)) {
|
|
throw new TypeError(`can only clone from another Game instance`);
|
|
}
|
|
|
|
Object.assign(this, original, {
|
|
_board: new Board(original._board),
|
|
_moves: original._moves.slice(),
|
|
_redo: original._redo.slice(),
|
|
_checkCache: Object.assign({}, original._checkCache),
|
|
_castling: {
|
|
[LIGHT]: Object.assign({}, original._castling[LIGHT]),
|
|
[DARK]: Object.assign({}, original._castling[DARK]),
|
|
},
|
|
});
|
|
} else {
|
|
this._board = new Board();
|
|
this._player = LIGHT;
|
|
this._status = PLAYING;
|
|
this._moves = [];
|
|
this._redo = [];
|
|
this._history = '';
|
|
this._turn = 0;
|
|
this._undo = null;
|
|
this._checkCache = {};
|
|
this._ignoreCheck = false;
|
|
this._version = CURRENT_VERSION;
|
|
|
|
/* set to false when the king or rook moves */
|
|
this._castling = {
|
|
[LIGHT]: { king: true, queen: true },
|
|
[DARK]: { king: true, queen: true },
|
|
};
|
|
}
|
|
}
|
|
|
|
get board() {
|
|
return new Board(this._board);
|
|
}
|
|
|
|
get player() {
|
|
return this._player;
|
|
}
|
|
|
|
get status() {
|
|
return this._status;
|
|
}
|
|
|
|
get moves() {
|
|
return JSON.parse(JSON.stringify(this._moves));
|
|
}
|
|
|
|
get redoMoves() {
|
|
return JSON.parse(JSON.stringify(this._redo));
|
|
}
|
|
|
|
get ignoreCheck() {
|
|
return this._ignoreCheck;
|
|
}
|
|
|
|
set ignoreCheck(value) {
|
|
this._ignoreCheck = !!value;
|
|
}
|
|
|
|
get version() {
|
|
return this._version;
|
|
}
|
|
|
|
toJSON(style) {
|
|
function shrinkMove(move) {
|
|
if (move.resign) {
|
|
return { side: move.side, resign: true };
|
|
} else if (move.phantom) {
|
|
return { side: move.side, phantom: true, to: move.to };
|
|
} else {
|
|
return { side: move.side, from: move.from, to: move.to };
|
|
}
|
|
}
|
|
|
|
if (style === 'minify') {
|
|
/* Just the fields that are used by the constructor to replay the game */
|
|
/* Omits metadata and extra annotation fields */
|
|
const state = { past: [], future: [], version: this._version };
|
|
for (const move of this._moves) {
|
|
state.past.push(shrinkMove(move));
|
|
}
|
|
for (const move of this._redo) {
|
|
state.future.push(shrinkMove(move));
|
|
}
|
|
return JSON.stringify(state);
|
|
}
|
|
|
|
return JSON.stringify({
|
|
past: this._moves,
|
|
future: this._redo,
|
|
version: this._version,
|
|
});
|
|
}
|
|
|
|
canCapture(side, from) {
|
|
if (from === PHANTOM) {
|
|
return true;
|
|
} else {
|
|
return this._board.getPiece(otherSide(side), from) === EMPTY;
|
|
}
|
|
}
|
|
|
|
*legalMoves(side, from, canCapture) {
|
|
const board = this._board;
|
|
const type = board.getPiece(side, from);
|
|
|
|
if (this._status !== PLAYING || type === EMPTY) {
|
|
return;
|
|
}
|
|
|
|
if (canCapture === undefined) {
|
|
canCapture = this.canCapture(side, from);
|
|
}
|
|
|
|
if (from === PHANTOM) {
|
|
/* this is valid because board.getPiece(side, PHANTOM) !== EMPTY */
|
|
from = board.phantom.from;
|
|
}
|
|
|
|
if (type === KING) {
|
|
for (const dir of ANY_DIR) {
|
|
yield* board.scanPath(side, from, false, dir[0], dir[1], 0);
|
|
}
|
|
|
|
/* check for castling conditions */
|
|
if (from === (side === DARK ? 'e8' : 'e1')) {
|
|
const row = from[1];
|
|
|
|
/*
|
|
* Note: With any other piece the call to isInCheck() would be recursive.
|
|
* Kings can't capture, so their moves aren't considered for check.
|
|
*/
|
|
|
|
if (this._castling[side].king &&
|
|
board.isEmpty('f' + row) &&
|
|
board.isEmpty('g' + row)) {
|
|
if (!this.isInCheck(side)) {
|
|
if (this._version < 2) {
|
|
yield 'g' + row;
|
|
} else {
|
|
/* Prohibit castling through check */
|
|
const testGame = new Game(this);
|
|
testGame._board.putPiece(side, 'e' + row, EMPTY);
|
|
testGame._board.putPiece(side, 'f' + row, KING);
|
|
testGame._checkCache = {};
|
|
if (!testGame.isInCheck(side)) {
|
|
yield 'g' + row;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this._castling[side].queen &&
|
|
board.isEmpty('d' + row) &&
|
|
board.isEmpty('c' + row) &&
|
|
board.isEmpty('b' + row)) {
|
|
if (!this.isInCheck(side)) {
|
|
if (this._version < 2) {
|
|
yield 'c' + row;
|
|
} else {
|
|
/* Prohibit castling through check */
|
|
const testGame = new Game(this);
|
|
testGame._board.putPiece(side, 'e' + row, EMPTY);
|
|
testGame._board.putPiece(side, 'd' + row, KING);
|
|
testGame._checkCache = {};
|
|
if (!testGame.isInCheck(side)) {
|
|
yield 'c' + row;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else if (type === QUEEN) {
|
|
for (const dir of ANY_DIR) {
|
|
yield* board.scanPath(side, from, canCapture, dir[0], dir[1], 8);
|
|
}
|
|
} else if (type === BISHOP) {
|
|
for (const dir of DIAG) {
|
|
yield* board.scanPath(side, from, canCapture, dir[0], dir[1], 8);
|
|
}
|
|
} else if (type === KNIGHT) {
|
|
for (const dir of KNIGHT_DIR) {
|
|
const there = offsetSquare(from, dir[0], dir[1]);
|
|
if (there && board.validDestination(side, there, canCapture)) {
|
|
yield there;
|
|
}
|
|
}
|
|
} else if (type === ROOK) {
|
|
for (const dir of ORTHO) {
|
|
yield* board.scanPath(side, from, canCapture, dir[0], dir[1], 8);
|
|
}
|
|
} else if (type === PAWN) {
|
|
const dark = side === DARK;
|
|
const forward = offsetSquare(from, 0, dark ? -1 : 1);
|
|
const forward2 = offsetSquare(from, 0, dark ? -2 : 2);
|
|
const diagL = offsetSquare(from, -1, dark ? -1 : 1);
|
|
const diagR = offsetSquare(from, 1, dark ? -1 : 1);
|
|
|
|
if (forward && board.validDestination(side, forward, false)) {
|
|
yield forward;
|
|
if (dark ? (from[1] >= '7') : (from[1] <= '2')) {
|
|
if (forward2 && board.validDestination(side, forward2, false)) {
|
|
yield forward2;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (canCapture) {
|
|
for (const there of [diagL, diagR]) {
|
|
if (there) {
|
|
if (board.getPiece(otherSide(side), there) !== EMPTY) {
|
|
yield there;
|
|
} else if (forward2) {
|
|
let lastMove = null;
|
|
for (let i = this._moves.length; i > 0; --i) {
|
|
const move = this._moves[i - 1];
|
|
if (move.side !== this._player) {
|
|
lastMove = move;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (lastMove && lastMove.type === PAWN &&
|
|
lastMove.from === there[0] + forward2[1] &&
|
|
lastMove.to === there[0] + from[1]) {
|
|
/* en passant */
|
|
yield there;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
isLegalMove(side, from, to, canCapture) {
|
|
const board = this._board;
|
|
const type = board.getPiece(side, from);
|
|
|
|
/* this is valid because board.getPiece(side, PHANTOM) !== EMPTY */
|
|
const fromSquare = (from === PHANTOM) ? board.phantom.from : from;
|
|
|
|
if (this._status !== PLAYING || type === EMPTY || to === fromSquare) {
|
|
return false;
|
|
}
|
|
|
|
if (canCapture === undefined) {
|
|
canCapture = this.canCapture(side, from);
|
|
}
|
|
|
|
/* non-capturing pieces can only move to empty squares */
|
|
/* capturing pieces can only move to squares with opponent's pieces */
|
|
if (!board.isEmpty(to)) {
|
|
if (!canCapture || (board.getPiece(otherSide(side), to) === EMPTY)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/* pre-screen for basic movement constraints */
|
|
/* if a piece has few moves (king, pawn, knight) then just enumerate them */
|
|
/* if movement is more extensive (bishop, rook, queen) then we can do better */
|
|
if (type === BISHOP || type === ROOK || type === QUEEN) {
|
|
const fromIndex = squareIndex(fromSquare);
|
|
const toIndex = squareIndex(to);
|
|
const rowsDiff = (toIndex >>> 3) - (fromIndex >>> 3);
|
|
const columnsDiff = (toIndex & 0b111) - (fromIndex & 0b111);
|
|
|
|
if (rowsDiff === columnsDiff || rowsDiff === -columnsDiff) {
|
|
if (type === ROOK) {
|
|
return false;
|
|
}
|
|
} else if (rowsDiff === 0 || columnsDiff === 0) {
|
|
if (type === BISHOP) {
|
|
return false;
|
|
}
|
|
} else {
|
|
return false;
|
|
}
|
|
|
|
const rowsUp = Math.sign(rowsDiff);
|
|
const columnsRight = Math.sign(columnsDiff);
|
|
|
|
/* verify that the squares in between `fromSquare` and `to` are empty */
|
|
let at = offsetSquare(fromSquare, columnsRight, rowsUp);
|
|
while (at !== to) {
|
|
if (!board.isEmpty(at)) {
|
|
return false;
|
|
}
|
|
|
|
at = offsetSquare(at, columnsRight, rowsUp);
|
|
}
|
|
|
|
/* this is a legal move, no need to enumerate */
|
|
return true;
|
|
}
|
|
|
|
const legals = new Iterator(this.legalMoves(side, from, canCapture));
|
|
return legals.strictlyIncludes(to);
|
|
}
|
|
|
|
isInCheck(_side) {
|
|
const side = _side || this._player;
|
|
|
|
if (this._checkCache[side] !== undefined) {
|
|
return this._checkCache[side];
|
|
}
|
|
|
|
function recordCheck(game, x) {
|
|
game._checkCache[side] = x;
|
|
return x;
|
|
}
|
|
|
|
if (this._status !== PLAYING || this._board.phantom) {
|
|
/* can't be in check mid-move, or if the game is already over */
|
|
return recordCheck(this, false);
|
|
}
|
|
|
|
const other = otherSide(side);
|
|
const kings = [...this._board.findPieces(side, KING)];
|
|
|
|
if (kings.length !== 1) {
|
|
throw new Error(`there should be exactly one king per side`);
|
|
}
|
|
|
|
const king = kings[0];
|
|
|
|
/* check is evaluated as if the current player passed without moving */
|
|
const sim = new Game(this);
|
|
sim.dropHistory();
|
|
sim.ignoreCheck = true;
|
|
sim._player = other;
|
|
|
|
/* start with the opponent's unpaired pieces, the ones that can capture */
|
|
let queue = [];
|
|
for (const from of sim._board.findPieces(other, true, false)) {
|
|
/* this next condition is needed to avoid recursion through legalMoves() */
|
|
/* kings can't capture anything anyway, so they can be skipped */
|
|
if (sim._board.getPiece(other, from) !== KING) {
|
|
if (sim.isLegalMove(other, from, king, true)) {
|
|
/* this piece can directly capture the king */
|
|
let check = true;
|
|
if (this._history !== undefined) {
|
|
try {
|
|
sim.move(from, king);
|
|
check = diffHistory(this, sim) || true;
|
|
} catch(err) {/*ignore*/}
|
|
}
|
|
return recordCheck(this, check);
|
|
}
|
|
|
|
queue.push({ game: sim, from });
|
|
}
|
|
}
|
|
|
|
const seen = new Set();
|
|
let counter = 0;
|
|
|
|
while (queue.length > 0) {
|
|
const game = queue[0].game;
|
|
const from = queue[0].from;
|
|
queue = queue.slice(1);
|
|
|
|
seen.add(game._board.toString());
|
|
|
|
/* look for another piece that can reach the king or continue the chain */
|
|
const pairs = [...game.board.findPieces(other, true, true)];
|
|
for (const pair of pairs) {
|
|
if (counter >= 300) {
|
|
/* this is taking too long */
|
|
const newMoves = game._moves.length - this._moves.length;
|
|
console.log(`stopped looking for check after ${counter} moves (max length was ${newMoves})`);
|
|
return recordCheck(this, false);
|
|
}
|
|
|
|
++counter;
|
|
if (!game.isLegalMove(other, from, pair, true)) {
|
|
/* can't reach it */
|
|
continue;
|
|
}
|
|
|
|
const game2 = new Game(game);
|
|
try {
|
|
game2.move(from, pair);
|
|
} catch (err) {
|
|
/* internal error, but keep looking at the rest of the queue */
|
|
console.log('isInCheck:', err);
|
|
continue;
|
|
}
|
|
|
|
if (seen.has(game2._board.toString())) {
|
|
/* we've already been here via another path */
|
|
continue;
|
|
}
|
|
|
|
if (game2.isLegalMove(other, PHANTOM, king, true)) {
|
|
/* we have our answer */
|
|
let check = true;
|
|
if (this._history !== undefined) {
|
|
try {
|
|
game2.move(PHANTOM, king);
|
|
check = diffHistory(this, game2) || true;
|
|
} catch(err) {/*ignore*/}
|
|
}
|
|
return recordCheck(this, check);
|
|
}
|
|
|
|
queue.push({ game: game2, from: PHANTOM });
|
|
}
|
|
}
|
|
|
|
/* didn't find anything */
|
|
return recordCheck(this, false);
|
|
}
|
|
|
|
isDeadEnd(from, to) {
|
|
const side = this._player;
|
|
const sim = new Game(this);
|
|
sim.dropHistory();
|
|
sim.ignoreCheck = true;
|
|
|
|
try {
|
|
sim.move(from, to);
|
|
} catch (err) {
|
|
/* illegal moves are automatic dead ends */
|
|
return true;
|
|
}
|
|
|
|
if (!sim._board.phantom) {
|
|
/* moves that leave the player in check are also dead ends */
|
|
/* otherwise this is a legal non-chain move, so not a dead end */
|
|
return sim.isInCheck(side);
|
|
}
|
|
|
|
const seen = new Set();
|
|
let queue = [sim];
|
|
let counter = 0;
|
|
|
|
while (queue.length > 0) {
|
|
const game = queue[0];
|
|
queue = queue.slice(1);
|
|
|
|
seen.add(game._board.toString());
|
|
|
|
/* look for another piece that can reach the king or continue the chain */
|
|
for (const toNext of game.legalMoves(side, PHANTOM, true)) {
|
|
if (counter >= 300) {
|
|
/* this is taking too long */
|
|
const newMoves = game._moves.length - this._moves.length;
|
|
console.log(`stopped looking for dead end after ${counter} moves (max length was ${newMoves})`);
|
|
return undefined;
|
|
}
|
|
|
|
++counter;
|
|
|
|
const game2 = new Game(game);
|
|
try {
|
|
game2.move(PHANTOM, toNext);
|
|
} catch (err) {
|
|
/* internal error */
|
|
console.log('isDeadEnd:', err);
|
|
continue;
|
|
}
|
|
|
|
if (!game2._board.phantom) {
|
|
/* skip moves that would leave the player in check */
|
|
if (!game2.isInCheck(side)) {
|
|
/* it's a legal non-chain move, so not a dead end */
|
|
return false;
|
|
}
|
|
}
|
|
else if (!seen.has(game2._board.toString())) {
|
|
/* we haven't seen this exact board state before */
|
|
queue.push(game2);
|
|
}
|
|
}
|
|
}
|
|
|
|
/* an exhaustive search didn't uncover any legal ways to end the chain */
|
|
return true;
|
|
}
|
|
|
|
move(from, to, meta) {
|
|
if (this._status !== PLAYING) {
|
|
throw new Error(`can't move, game is already over`);
|
|
}
|
|
|
|
const side = this._player;
|
|
const other = otherSide(side);
|
|
const board = this._board;
|
|
const type = board.getPiece(side, from);
|
|
const took = board.getPiece(other, to);
|
|
const replaced = board.getPiece(side, to);
|
|
const alongside = board.getPiece(other, from);
|
|
|
|
if (from === PHANTOM && !board.phantom) {
|
|
throw new Error(`attempted to continue a completed move`);
|
|
}
|
|
|
|
const fromSquare = (from === PHANTOM) ? board.phantom.from : from;
|
|
|
|
if (!this.isLegalMove(side, from, to, alongside === EMPTY)) {
|
|
throw new Error(`illegal move by ${side} side: ${fromSquare} → ${to}`);
|
|
}
|
|
|
|
const move = {
|
|
side: side,
|
|
type: type,
|
|
from: fromSquare,
|
|
to: to,
|
|
};
|
|
|
|
if (from === PHANTOM) {
|
|
move.phantom = true;
|
|
}
|
|
|
|
if (took !== EMPTY) {
|
|
move.took = took;
|
|
}
|
|
|
|
if (replaced !== EMPTY) {
|
|
move.replaced = replaced;
|
|
}
|
|
|
|
if (alongside !== EMPTY) {
|
|
move.alongside = alongside;
|
|
}
|
|
|
|
if (meta !== undefined) {
|
|
move.meta = JSON.parse(JSON.stringify(meta));
|
|
}
|
|
|
|
this._undo = new Game(this);
|
|
board.move(from, to);
|
|
|
|
if (type === KING) {
|
|
/* we already checked that this is a legal move, so it must be castling */
|
|
if (from[0] === 'e' && to[0] === 'g') {
|
|
/* move the rook & any paired piece */
|
|
board.move('h' + from[1], 'f' + from[1]);
|
|
move.castle = true;
|
|
}
|
|
else if (from[0] === 'e' && to[0] === 'c') {
|
|
/* move the rook & any paired piece */
|
|
board.move('a' + from[1], 'd' + from[1]);
|
|
move.queen_castle = true;
|
|
}
|
|
|
|
/* can't castle after moving the king */
|
|
this._castling[side].king = false;
|
|
this._castling[side].queen = false;
|
|
} else if (type === ROOK) {
|
|
/* can't castle after moving the rook */
|
|
if (from[0] === 'h') {
|
|
this._castling[side].king = false;
|
|
} else if (from[0] === 'a') {
|
|
this._castling[side].queen = false;
|
|
}
|
|
} else if (type === PAWN) {
|
|
if (fromSquare[0] !== to[0] && took === EMPTY) {
|
|
/* legal diagonal move but nothing at destination; must be en passant */
|
|
move.en_passant = true;
|
|
move.took = PAWN;
|
|
|
|
/* the location of the captured pawn */
|
|
const where = to[0] + fromSquare[1];
|
|
|
|
/* move the opponent's pawn back one space to join our pawn */
|
|
board.putPiece(other, where, EMPTY);
|
|
board.putPiece(other, to, PAWN);
|
|
|
|
/* see if we replaced a piece from the other square */
|
|
board.makePhantom(side, where);
|
|
}
|
|
|
|
if (to[1] === (side === DARK ? '1' : '8')) {
|
|
move.promotion = QUEEN; /* TODO: allow other choices */
|
|
board.putPiece(side, to, move.promotion);
|
|
}
|
|
}
|
|
|
|
if (alongside === PAWN && to[1] === (other === DARK ? '1' : '8')) {
|
|
move.promotion = QUEEN; /* TODO: allow other choices */
|
|
board.putPiece(other, to, move.promotion);
|
|
}
|
|
|
|
if (board.phantom === null) {
|
|
this._player = otherSide(this._player);
|
|
}
|
|
|
|
this._checkCache = {};
|
|
this._moves.push(move);
|
|
this._redo = [];
|
|
|
|
addHistory(this);
|
|
|
|
let inCheck = false;
|
|
|
|
if (took === KING) {
|
|
this._status = CHECKMATE;
|
|
} else if (!this.ignoreCheck && this.isInCheck(this._player)) {
|
|
inCheck = true;
|
|
let canEscape = false;
|
|
|
|
for (const tryFrom of this._board.findPieces(this._player, true)) {
|
|
for (const tryTo of this.legalMoves(this._player, tryFrom)) {
|
|
if (!this.isDeadEnd(tryFrom, tryTo)) {
|
|
canEscape = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (canEscape) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!canEscape) {
|
|
this._status = CHECKMATE;
|
|
}
|
|
}
|
|
|
|
if (this._history !== undefined) {
|
|
if (this._status === CHECKMATE) {
|
|
this._history += '#';
|
|
} else if (inCheck) {
|
|
this._history += '+';
|
|
}
|
|
|
|
let winner = this.winner;
|
|
|
|
if (winner === LIGHT) {
|
|
this._history += ' 1-0';
|
|
} else if (winner === DARK) {
|
|
this._history += ' 0-1';
|
|
} else if (this._status !== PLAYING) {
|
|
this._history += ' \u00bd-\u00bd'; /* 1/2-1/2 */
|
|
}
|
|
}
|
|
}
|
|
|
|
resign(meta) {
|
|
if (this._status !== PLAYING) {
|
|
throw new Error(`can't resign, game is already over`);
|
|
}
|
|
|
|
const move = {
|
|
side: this._player,
|
|
resign: true
|
|
};
|
|
|
|
if (meta !== undefined) {
|
|
move.meta = meta;
|
|
}
|
|
|
|
this._undo = new Game(this);
|
|
this._checkCache = {};
|
|
this._moves.push(move);
|
|
this._redo = [];
|
|
this._status = RESIGNED;
|
|
|
|
addHistory(this);
|
|
|
|
if (this._history !== undefined) {
|
|
if (move.side === LIGHT) {
|
|
this._history += ' 0-1';
|
|
} else {
|
|
this._history += ' 1-0';
|
|
}
|
|
}
|
|
}
|
|
|
|
get lastMove() {
|
|
if (this._moves.length > 0) {
|
|
return JSON.parse(JSON.stringify(this._moves[this._moves.length - 1]));
|
|
} else {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
get winner() {
|
|
if (this._status === RESIGNED) {
|
|
return otherSide(this.lastMove.side);
|
|
} else if (this._status === CHECKMATE) {
|
|
return this.lastMove.side;
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
replayMove(move) {
|
|
if (otherSide(move.side) === this._player) {
|
|
throw new Error(`other player's move`);
|
|
}
|
|
|
|
if (move.resign) {
|
|
this.resign(move.meta);
|
|
} else if (move.phantom) {
|
|
this.move(PHANTOM, move.to, move.meta);
|
|
} else {
|
|
this.move(move.from, move.to, move.meta);
|
|
}
|
|
}
|
|
|
|
get canUndo() {
|
|
return this._moves.length > 0;
|
|
}
|
|
|
|
get canRedo() {
|
|
return this._redo.length > 0;
|
|
}
|
|
|
|
undo() {
|
|
if (this.canUndo) {
|
|
/* preserve the last move and redo history */
|
|
const lastMove = this._moves[this._moves.length - 1];
|
|
const savedRedo = this._redo;
|
|
|
|
/* copy all the properties from the saved prior game to this one */
|
|
Object.assign(this, new Game(this._undo));
|
|
|
|
/* restore the original redo history and add the undone move */
|
|
this._redo = savedRedo;
|
|
this._redo.push(lastMove);
|
|
}
|
|
}
|
|
|
|
redo() {
|
|
if (this.canRedo) {
|
|
const savedRedo = this._redo;
|
|
this.replayMove(savedRedo.pop());
|
|
this._redo = savedRedo;
|
|
}
|
|
}
|
|
|
|
clearRedo() {
|
|
this._redo = [];
|
|
}
|
|
|
|
get turns() {
|
|
return this._turn;
|
|
}
|
|
|
|
get history() {
|
|
return this._history;
|
|
}
|
|
|
|
dropHistory() {
|
|
this._history = undefined;
|
|
}
|
|
|
|
renderHistory() {
|
|
if (this._history === undefined) {
|
|
const replay = new Game();
|
|
replay._version = this._version;
|
|
for (const move of this._moves) {
|
|
replay.replayMove(move);
|
|
}
|
|
this._history = replay._history;
|
|
}
|
|
|
|
return this._history;
|
|
}
|
|
}
|
|
|
|
export default {
|
|
/* Classes */
|
|
Board, Game,
|
|
|
|
/* Game States */
|
|
PLAYING, RESIGNED, CHECKMATE,
|
|
|
|
/* Sides */
|
|
LIGHT, DARK,
|
|
|
|
/* Pieces */
|
|
KING, QUEEN, ROOK, KNIGHT, BISHOP, PAWN, EMPTY,
|
|
|
|
/* Coordinates */
|
|
ROWS, COLUMNS, PHANTOM,
|
|
|
|
/* Versioning */
|
|
CURRENT_VERSION,
|
|
|
|
/* Miscellaneous */
|
|
Util: { otherSide, offsetSquare, diffHistory },
|
|
};
|
|
|
|
/* vim:set expandtab sw=3 ts=8: */
|