diff --git a/js/pacosako.js b/js/pacosako.js index ff32862..474929c 100644 --- a/js/pacosako.js +++ b/js/pacosako.js @@ -31,26 +31,38 @@ const INITIAL_EDGE_ROW = ROOK + KNIGHT + BISHOP + QUEEN + KING + BISHOP + KNIGHT 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.) + */ +const CURRENT_VERSION = 1; + function otherSide(side) { if (side === LIGHT) { return DARK; } else if (side === DARK) { return LIGHT; } else { - throw { message: 'invalid side', side: side }; + throw new Error(`invalid side: ${side}`); } } function squareIndex(square) { if (typeof square !== 'string' || square.length !== 2) { - throw { message: 'invalid square', square: square }; + 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 { message: 'invalid square', square: square }; + throw new Error(`invalid square: ${square}`); } return (row * 8) + column; @@ -72,7 +84,7 @@ function encodePiece(side, type) { let result = ALL_PIECES.indexOf(type); if (result < 0) { - throw { message: 'invalid piece', piece: type }; + throw new Error(`invalid piece: ${type}`); } if (side === LIGHT) { @@ -80,7 +92,7 @@ function encodePiece(side, type) { } else if (side === DARK) { return result; } else { - throw { message: 'invalid side', side: side }; + throw new Error(`invalid side: ${side}`); } } @@ -92,7 +104,7 @@ function decodePiece(side, value) { } else if (side === DARK) { sideShift = 0; } else { - throw { message: 'invalid side', side: side }; + throw new Error(`invalid side: ${side}`); } let pieceValue = (value >>> sideShift) & 0b1111; @@ -100,7 +112,7 @@ function decodePiece(side, value) { if (pieceValue < ALL_PIECES.length) { return ALL_PIECES[pieceValue]; } else { - throw { message: 'invalid encoded piece', value: pieceValue }; + throw new Error(`invalid encoded piece: ${pieceValue}`); } } @@ -108,7 +120,7 @@ class Board { constructor(original) { if (original !== null && original !== undefined) { if (!(original instanceof this.constructor)) { - throw { message: 'can only clone from another Board instance' }; + throw new TypeError(`can only clone from another Board instance`); } this._board = new Uint8Array(original._board); @@ -175,7 +187,7 @@ class Board { if (type !== EMPTY) { if (this._phantom) { - throw { message: 'phantom square is already occupied' }; + throw new Error(`phantom square is already occupied`); } this.putPiece(side, square, EMPTY); @@ -190,17 +202,17 @@ class Board { move(from, to) { if (this._phantom && from !== PHANTOM) { - throw { message: 'must complete prior move before moving other pieces' }; + 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 { message: 'cannot move from empty square' }; + throw new Error(`cannot move from empty square: ${from} → ${to}`); } else if (lightPiece !== EMPTY && darkPiece !== EMPTY) { if (!this.isEmpty(to)) { - throw { message: 'cannot capture with joined pieces' }; + throw new Error(`cannot capture with joined pieces`); } const fromIndex = squareIndex(from); @@ -214,7 +226,7 @@ class Board { const displaced = this.getPiece(moving, to); if (displaced !== EMPTY && this.getPiece(other, to) === EMPTY) { - throw { message: 'cannot join with piece of same color' }; + throw new Error(`cannot join with piece of same color`); } if (from === PHANTOM) { @@ -462,9 +474,38 @@ function diffHistory(game1, game2) { 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 { message: 'can only clone from another Game instance' }; + throw new TypeError(`can only clone from another Game instance`); } Object.assign(this, original, { @@ -488,6 +529,7 @@ class Game { this._undo = null; this._checkCache = {}; this._ignoreCheck = false; + this._version = CURRENT_VERSION; /* set to false when the king or rook moves */ this._castling = { @@ -525,6 +567,18 @@ class Game { 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; @@ -732,7 +786,7 @@ class Game { const kings = [...this._board.findPieces(side, KING)]; if (kings.length !== 1) { - throw { message: 'there should be exactly one king per side' }; + throw new Error(`there should be exactly one king per side`); } const king = kings[0]; @@ -894,7 +948,7 @@ class Game { move(from, to, meta) { if (this._status !== PLAYING) { - throw { message: "can't move, game is already over" }; + throw new Error(`can't move, game is already over`); } const side = this._player; @@ -906,13 +960,13 @@ class Game { const alongside = board.getPiece(other, from); if (from === PHANTOM && !board.phantom) { - throw { message: "attempted to continue a completed move" }; + 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 { message: "illegal move", side: side, from: fromSquare, to: to }; + throw new Error(`illegal move by ${side} side: ${fromSquare} → ${to}`); } const move = { @@ -1053,7 +1107,7 @@ class Game { resign(meta) { if (this._status !== PLAYING) { - throw { message: "can't resign, game is already over" }; + throw new Error(`can't resign, game is already over`); } const move = { @@ -1101,8 +1155,8 @@ class Game { } replayMove(move) { - if (!move || move.side !== this._player) { - throw { message: "other player's move", move: move }; + if (otherSide(move.side) === this._player) { + throw new Error(`other player's move`); } if (move.resign) { @@ -1164,6 +1218,7 @@ class Game { renderHistory() { if (this._history === undefined) { const replay = new Game(); + replay._version = this._version; for (const move of this._moves) { replay.replayMove(move); } @@ -1190,6 +1245,9 @@ export default { /* Coordinates */ ROWS, COLUMNS, PHANTOM, + /* Versioning */ + CURRENT_VERSION, + /* Miscellaneous */ Util: { otherSide, offsetSquare, diffHistory }, }; diff --git a/js/pacosako_ui.js b/js/pacosako_ui.js index fe46546..6ab0f38 100644 --- a/js/pacosako_ui.js +++ b/js/pacosako_ui.js @@ -50,7 +50,7 @@ $(function (){ } else if (type === PS.PAWN) { return 'p'; } else { - throw { message: 'unknown piece type', type: type }; + throw new Error(`unknown piece type: ${type}`); } } @@ -60,7 +60,7 @@ $(function (){ } else if (side === PS.DARK) { return 'd'; } else { - throw { message: 'unknown side', side: side }; + throw new Error(`unknown side: ${side}`); } } @@ -576,9 +576,8 @@ $(function (){ function putState() { const boardElem = $('#cb_board'); const gameId = boardElem.data('gameId'); - const moves = { past: currentGame.moves, future: currentGame.redoMoves }; boardElem.data('last_state', currentGame.moves); - putMeta({ board: moves }); + putMeta({ board: JSON.parse(currentGame.toJSON()) }); } function putMeta(extra) { @@ -649,40 +648,13 @@ $(function (){ cancelGameCallback = IO.onGameUpdate(newId, function(data, gameId) { if (data.modified > $('#cb_board').data('modified')) { - const board = data.board || { past: [], future: [] }; - try { - const moves = { past: [...board.past], future: [...board.future] }; - const oldState = { past: currentGame.moves, future: currentGame.redoMoves }; - - if (!deepEqual(moves, oldState)) { - debug('got board', moves); - - const newGame = new PS.Game(); - - try { - for (const move of moves.past) { - newGame.replayMove(move); - } - - let n = 0; - - try { - for (const move of moves.future.slice().reverse()) { - newGame.replayMove(move); - n += 1; - } - } catch (err) { - debug('Error replaying board redo state', err); - } - - for (let i = 0; i < n; ++i) { - newGame.undo(); - } - } catch (err) { - debug('Error replaying board state', err); - } + const newGame = new PS.Game(JSON.stringify(data.board)); + const newState = JSON.parse(newGame.toJSON()); + const oldState = JSON.parse(currentGame.toJSON()); + if (!deepEqual(newState, oldState)) { + debug('got board', newGame.moves); setCurrentGame(newGame, newGame.moves.length > currentGame.moves.length); } } catch (err) {