'use strict'; import {Iterator} from './iterator.js'; /* 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'; 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 = 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 { 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() + ')'; } game._history += result; } class Game { constructor(original) { if (original !== undefined) { if (!(original instanceof this.constructor)) { throw { message: '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; /* 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; } 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)) { yield 'g' + row; } } if (this._castling[side].queen && board.isEmpty('d' + row) && board.isEmpty('c' + row) && board.isEmpty('b' + row)) { if (!this.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 ortho = (type === ROOK || type === QUEEN); const diag = (type === BISHOP || 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 { 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.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; try { if (this._history !== undefined) { const hist = new Game(this); hist.ignoreCheck = true; if (hist._player !== other) { if (hist._player === LIGHT) { hist._turn += 1; hist._history += ` ${hist._turn}.${NBSP}…`; } else { hist._history += ` …`; } hist._player = other; } const turn_before = hist._turn; const history_before = hist._history; hist.move(from, king); check = hist.history.slice(history_before.length).trimStart(); if (!check.match(/^\d+\./)) { check = `${turn_before}.${NBSP}… ${check}`; } } } catch(err) {} 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; try { if (this._history !== undefined) { const hist = new Game(this); hist.ignoreCheck = true; if (hist._player !== other) { if (hist._player === LIGHT) { hist._turn += 1; hist._history += ` ${hist._turn}.${NBSP}…`; } else { hist._history += ` …`; } hist._player = other; } const turn_before = hist._turn; const history_before = hist._history; for (const move of game2._moves.slice(hist._moves.length)) { hist.replayMove(move); } hist.move(PHANTOM, king); check = hist.history.slice(history_before.length).trimStart(); if (!check.match(/^\d+\./)) { check = `${turn_before}.${NBSP}… ${check}`; } } } catch(err) {} 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 { 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._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 { 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._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 (!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 */ Object.assign(this, 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(); 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, /* Miscellaneous */ Util: { otherSide, offsetSquare }, }; /* vim:set expandtab sw=3 ts=8: */