941 lines
26 KiB
JavaScript
941 lines
26 KiB
JavaScript
'use strict';
|
|
|
|
import {Iterator} from './iterator.js';
|
|
|
|
/* Game states */
|
|
const PLAYING = 'playing';
|
|
const ENDED = 'ended';
|
|
|
|
/* 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';
|
|
|
|
function otherSide(side) {
|
|
if (side === LIGHT) {
|
|
return DARK;
|
|
} else if (side === DARK) {
|
|
return LIGHT;
|
|
} else {
|
|
throw { message: 'invalid side', side: side };
|
|
}
|
|
}
|
|
|
|
function squareIndex(square) {
|
|
if (typeof square !== 'string' || square.length !== 2) {
|
|
throw { message: 'invalid square', square: square };
|
|
}
|
|
|
|
const column = COLUMNS.indexOf(square[0].toLowerCase());
|
|
const row = ROWS.indexOf(square[1]);
|
|
|
|
if (column < 0 || row < 0) {
|
|
throw { message: 'invalid square', 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 { message: 'invalid piece', piece: type };
|
|
}
|
|
|
|
if (side === LIGHT) {
|
|
return (result << 4);
|
|
} else if (side === DARK) {
|
|
return result;
|
|
} else {
|
|
throw { message: 'invalid side', side: side };
|
|
}
|
|
}
|
|
|
|
function decodePiece(side, value) {
|
|
let sideShift = null;
|
|
|
|
if (side === LIGHT) {
|
|
sideShift = 4;
|
|
} else if (side === DARK) {
|
|
sideShift = 0;
|
|
} else {
|
|
throw { message: 'invalid side', side: side };
|
|
}
|
|
|
|
let pieceValue = (value >>> sideShift) & 0b1111;
|
|
|
|
if (pieceValue < ALL_PIECES.length) {
|
|
return ALL_PIECES[pieceValue];
|
|
} else {
|
|
throw { message: 'invalid encoded piece', value: pieceValue };
|
|
}
|
|
}
|
|
|
|
class Board {
|
|
constructor(original) {
|
|
if (original !== null && original !== undefined) {
|
|
if (!(original instanceof this.constructor)) {
|
|
throw { message: 'can only clone from another Board instance' };
|
|
}
|
|
|
|
this._board = new Uint8Array(original._board);
|
|
this._phantom = JSON.parse(JSON.stringify(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() {
|
|
let phantom = this._phantom;
|
|
if (phantom) {
|
|
return { side: phantom.side, type: phantom.type, from: phantom.from };
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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 { message: '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 { message: '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 { message: 'cannot move from empty square' };
|
|
} else if (lightPiece !== EMPTY && darkPiece !== EMPTY) {
|
|
if (!this.isEmpty(to)) {
|
|
throw { message: '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 { message: '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() + ')';
|
|
}
|
|
|
|
if (move.took === KING) {
|
|
result += '#';
|
|
} else if (game.isInCheck()) {
|
|
result += '+';
|
|
}
|
|
|
|
let winner = game.winner;
|
|
|
|
if (winner === LIGHT) {
|
|
result += ' 1-0';
|
|
} else if (winner === DARK) {
|
|
result += ' 0-1';
|
|
} else if (game.status !== PLAYING) {
|
|
result += ' \u00bd-\u00bd'; /* 1/2-1/2 */
|
|
}
|
|
|
|
game._history += result;
|
|
}
|
|
|
|
class Game {
|
|
constructor(original) {
|
|
if (original !== undefined) {
|
|
if (!(original instanceof this.constructor)) {
|
|
throw { message: 'can only clone from another Game instance' };
|
|
}
|
|
|
|
this._board = new Board(original._board);
|
|
this._player = original._player;
|
|
this._status = original._status;
|
|
this._moves = JSON.parse(JSON.stringify(original._moves));
|
|
this._redo = JSON.parse(JSON.stringify(original._redo));
|
|
this._history = original._history;
|
|
this._turn = original._turn;
|
|
this._castling = JSON.parse(JSON.stringify(original._castling));
|
|
this._undo = original._undo;
|
|
} else {
|
|
this._board = new Board();
|
|
this._player = LIGHT;
|
|
this._status = PLAYING;
|
|
this._moves = [];
|
|
this._redo = [];
|
|
this._history = '';
|
|
this._turn = 0;
|
|
this._undo = null;
|
|
|
|
/* set to false when the king or rook moves */
|
|
this._castling = {};
|
|
this._castling[LIGHT] = { king: true, queen: true };
|
|
this._castling[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));
|
|
}
|
|
|
|
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];
|
|
|
|
/* memoize isInCheck(); may not need it and don't want to look twice */
|
|
/* with any other piece this would be recursive, but kings can't capture */
|
|
let inCheck = () => {
|
|
const check = this.isInCheck(side);
|
|
inCheck = () => check;
|
|
return check;
|
|
};
|
|
|
|
if (this._castling[side].king &&
|
|
board.isEmpty('f' + row) &&
|
|
board.isEmpty('g' + row)) {
|
|
if (!inCheck()) {
|
|
yield 'g' + row;
|
|
}
|
|
}
|
|
|
|
if (this._castling[side].queen &&
|
|
board.isEmpty('d' + row) &&
|
|
board.isEmpty('c' + row) &&
|
|
board.isEmpty('b' + row)) {
|
|
if (!inCheck()) {
|
|
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 legals = new Iterator(this.legalMoves(side, from, canCapture));
|
|
return legals.strictlyIncludes(to);
|
|
}
|
|
|
|
isInCheck(_side) {
|
|
if (this._status !== PLAYING || this._board.phantom) {
|
|
/* can't be in check mid-move, or if the game is already over */
|
|
return false;
|
|
}
|
|
|
|
const side = _side || this._player;
|
|
const other = otherSide(side);
|
|
const kings = [...this._board.findPieces(side, KING)];
|
|
|
|
if (kings.length !== 1) {
|
|
throw { message: '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._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 */
|
|
return true;
|
|
}
|
|
|
|
queue.push({ game: sim, from });
|
|
}
|
|
}
|
|
|
|
/* arbitrary limit, but a human player would probably miss a 7-move chain too */
|
|
const moveLimit = this._moves.length + 6;
|
|
const seen = new Set();
|
|
|
|
while (queue.length > 0) {
|
|
const game = queue[0].game;
|
|
const from = queue[0].from;
|
|
queue = queue.slice(1);
|
|
|
|
seen.add(game.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 (!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 */
|
|
return true;
|
|
}
|
|
|
|
if (game2._moves.length < moveLimit) {
|
|
queue.push({ game: game2, from: PHANTOM });
|
|
}
|
|
}
|
|
}
|
|
|
|
/* didn't find anything */
|
|
return false;
|
|
}
|
|
|
|
move(from, to, meta) {
|
|
if (this._status !== PLAYING) {
|
|
throw { message: "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 { message: "attempted to continue a completed move" };
|
|
}
|
|
|
|
const fromSquare = (from === PHANTOM) ? board.phantom.from : from;
|
|
|
|
if (!this.isLegalMove(side, from, to, alongside === EMPTY)) {
|
|
throw { message: "illegal move", side: side, from: fromSquare, to: 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._moves.push(move);
|
|
this._redo = [];
|
|
|
|
if (took === KING) {
|
|
this._status = ENDED;
|
|
}
|
|
|
|
addHistory(this);
|
|
}
|
|
|
|
resign(meta) {
|
|
if (this._status !== PLAYING) {
|
|
throw { message: "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._moves.push(move);
|
|
this._redo = [];
|
|
this._status = ENDED;
|
|
|
|
addHistory(this);
|
|
}
|
|
|
|
get lastMove() {
|
|
if (this._moves.length > 0) {
|
|
return JSON.parse(JSON.stringify(this._moves[this._moves.length - 1]));
|
|
} else {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
get winner() {
|
|
const move = this.lastMove;
|
|
if (move && move.resign) {
|
|
return otherSide(this.lastMove.side);
|
|
} else if (move && move.took === KING) {
|
|
return move.side;
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
replayMove(move) {
|
|
if (!move || move.side !== this._player) {
|
|
throw { message: "other player's move", move: 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 */
|
|
const savedUndo = this._undo;
|
|
for (const prop in savedUndo) {
|
|
this[prop] = savedUndo[prop];
|
|
}
|
|
|
|
/* 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();
|
|
for (const move of this._moves) {
|
|
replay.replayMove(move);
|
|
}
|
|
this._history = replay._history;
|
|
}
|
|
|
|
return this._history;
|
|
}
|
|
}
|
|
|
|
export default {
|
|
/* Classes */
|
|
Board, Game,
|
|
|
|
/* Game States */
|
|
PLAYING, ENDED,
|
|
|
|
/* Sides */
|
|
LIGHT, DARK,
|
|
|
|
/* Pieces */
|
|
KING, QUEEN, ROOK, KNIGHT, BISHOP, PAWN, EMPTY,
|
|
|
|
/* Coordinates */
|
|
ROWS, COLUMNS, PHANTOM,
|
|
|
|
/* Miscellaneous */
|
|
Util: { otherSide, offsetSquare },
|
|
};
|
|
|
|
/* vim:set expandtab sw=3 ts=8: */
|