'use strict'; (function(factory) { if (typeof define !== 'undefined' && define.amd) { define([], factory); } else if (typeof define !== 'undefined' && define.petal) { define(['paco-sako'], [], factory); } else if (typeof module !== 'undefined' && module.exports) { module.exports = factory(); } else { window.PacoSako = factory(); } })(function(){ /* 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]); } } } 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(accum, side, from, canCapture, columnsRight, rowsUp, remainder) { while (true) { let there = offsetSquare(from, columnsRight, rowsUp); if (!there || !this.validDestination(side, there, canCapture)) { return; } accum.push(there); if (remainder < 1 || this.getPiece(otherSide(side), there) !== EMPTY) { return; } from = there; remainder -= 1; } } findPieces(side, type, alongside) { const other = otherSide(side); let pieces = []; if (this._phantom && this._phantom.side === side && this._phantom.type === type) { pieces.push(this._phantom.from); } for (const row of ROWS) { for (const column of COLUMNS) { const here = column + row; if (this.getPiece(side, here) === type) { if (!alongside || this.getPiece(other, here) === alongside) { pieces.push(here); } } } } return pieces; } } 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 */ 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._castling = JSON.parse(JSON.stringify(original._castling)); } else { this._board = new Board(); this._player = LIGHT; this._status = PLAYING; this._moves = []; this._redo = []; /* 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; } let legals = []; if (type === KING) { for (const dir of ANY_DIR) { board.scanPath(legals, side, from, false, dir[0], dir[1], 0); } /* check for castling conditions */ if (from === (side === DARK ? 'e8' : 'e1')) { const row = from[1]; if (this._castling[side].king && board.isEmpty('f' + row) && board.isEmpty('g' + row)) { legals.push('g' + row); } if (this._castling[side].queen && board.isEmpty('d' + row) && board.isEmpty('c' + row) && board.isEmpty('b' + row)) { legals.push('c' + row); } } } else if (type === QUEEN) { for (const dir of ANY_DIR) { board.scanPath(legals, side, from, canCapture, dir[0], dir[1], 8); } } else if (type === BISHOP) { for (const dir of DIAG) { board.scanPath(legals, 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)) { legals.push(there); } } } else if (type === ROOK) { for (const dir of ORTHO) { board.scanPath(legals, 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)) { legals.push(forward); if (dark ? (from[1] >= '7') : (from[1] <= '2')) { if (forward2 && board.validDestination(side, forward2, false)) { legals.push(forward2); } } } if (canCapture) { for (const there of [diagL, diagR]) { if (there) { if (board.getPiece(otherSide(side), there) !== EMPTY) { legals.push(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 */ legals.push(there); } } } } } } return legals; } 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; const legals = this.legalMoves(side, from, alongside === EMPTY); if (!legals.includes(to)) { 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)); } 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); } if (took === KING) { this._status = ENDED; } this._moves.push(move); this._redo = []; } 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._status = ENDED; this._moves.push(move); this._redo = []; } 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; /* replay all moves except the last in a new game object */ const replay = new this.constructor(); for (let i = 0; i < this._moves.length - 1; ++i) { replay.replayMove(this._moves[i]); } /* copy all the properties from the replayed game to this one */ for (const prop in replay) { this[prop] = replay[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 = []; } countTurns() { let n = 0; let player = null; for (const move of this._moves) { /* multiple consecutive moves by the same player are a single turn */ if (move.side !== player) { ++n; } player = move.side; } return n; } renderHistory() { let replay = new Game(); let result = ''; let n = 0; for (const move of this._moves) { if (move.phantom) { result += SHY + '*'; } else if (!move.resign) { if (n > 0 || move.side === DARK) { result += ' '; } if (move.side === LIGHT) { ++n; result += String(n) + '.' + 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 !== replay.lastMove.to) { const sameKind = replay.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 (replay.legalMoves(move.side, where, true).includes(move.to)) { 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 += '#'; } replay.replayMove(move); } let winner = replay.winner; if (winner === LIGHT) { result += ' 1-0'; } else if (winner === DARK) { result += ' 0-1'; } else if (replay.status !== PLAYING) { result += ' \u00bd-\u00bd'; /* 1/2-1/2 */ } return result; } } return { Board: Board, Game: Game, PLAYING: PLAYING, ENDED: ENDED, LIGHT: LIGHT, DARK: DARK, KING: KING, QUEEN: QUEEN, ROOK: ROOK, KNIGHT: KNIGHT, BISHOP: BISHOP, PAWN: PAWN, EMPTY: EMPTY, ROWS: ROWS, COLUMNS: COLUMNS, PHANTOM: PHANTOM, Util: { otherSide: otherSide, offsetSquare: offsetSquare, }, }; }); /* vim:set expandtab sw=3 ts=8: */