move (de)serialization into the Game class & add rule versioning

This commit is contained in:
Jesse D. McDonald 2020-05-08 20:40:48 -05:00
parent 503a26eda3
commit b1caa69920
2 changed files with 87 additions and 57 deletions

View File

@ -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 },
};

View File

@ -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) {