paco_sako/js/pacosako.js

817 lines
22 KiB
JavaScript

'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: */