paco_sako/js/pacosako.js

1278 lines
36 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() {
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: */