From fcb14d489c4b4b5bfad0de6d5b13b84e1f9cb562 Mon Sep 17 00:00:00 2001 From: Jesse McDonald Date: Sun, 5 Apr 2020 04:42:10 -0500 Subject: [PATCH] add ability to detect obvious check (chains of 6 moves or less) --- js/iterator.js | 215 +++++++++++++++++++---------- js/pacosako.js | 345 +++++++++++++++++++++++++++++----------------- js/pacosako_ui.js | 16 ++- 3 files changed, 374 insertions(+), 202 deletions(-) diff --git a/js/iterator.js b/js/iterator.js index db0c086..b932189 100644 --- a/js/iterator.js +++ b/js/iterator.js @@ -1,63 +1,120 @@ 'use strict'; -export const IteratorMixin = { - map: function* map(f) { - let y; - while (!(y = this.next()).done) { - yield f(y.value); - } - return y.value; - }, +function identity(x) { + return x; +} - filter: function* filter(f) { - let y; - while (!(y = this.next()).done) { - if (f(y.value)) { - yield y.value; - } - } - return y.value; - }, +export class Iterator { + constructor(from) { + let next; - take: function* take(limit) { - let remaining = Number(limit) & -1; - let y; - while (remaining > 0 && !(y = this.next()).done) { - yield y.value; - remaining -= 1; + if (Symbol.iterator in from.__proto__) { + const iter = from.__proto__[Symbol.iterator].call(from); + next = iter.next.bind(iter); + } else if (typeof from === 'function') { + next = from; + } else { + throw new TypeError('Iterator class needs iterable input'); } - }, - drop: function* drop(limit) { - let remaining = Number(limit) & -1; - let y; - while (!(y = this.next()).done) { - if (remaining <= 0) { - yield y.value; + Object.defineProperty(this, 'next', { + value: next, + writable: false, + enumerable: true, + configurable: false, + }); + } + + [Symbol.iterator]() { + return this; + } + + map(f) { + const next = this.next; + return new Iterator(function() { + const y = next(); + if (y.done) { + return y; } else { + return { value: f(y.value), done: false }; + } + }); + } + + filter(f) { + const next = this.next; + return new Iterator(function() { + let y; + while (!(y = next()).done && !f(y.value)) { + continue; + } + return y; + }); + } + + take(limit) { + let remaining = Number(limit) & -1; + const next = this.next; + return new Iterator(function() { + if (remaining > 0) { + remaining -= 1; + return next(); + } else { + return { value: undefined, done: true }; + } + }); + } + + drop(limit) { + let remaining = Number(limit) & -1; + const next = this.next; + return new Iterator(function() { + while (remaining > 0 && !next().done) { remaining -= 1; } - } - }, + return next(); + }); + } - asIndexedPairs: function* asIndexedPairs() { + asIndexedPairs() { + const next = this.next; let index = 0; - let y; - while (!(y = this.next()).done) { - yield [index++, x]; - } - return y.value; - }, + return new Iterator(function() { + const y = next(); + if (y.done) { + return y; + } else { + return { value: [index++, y.value], done: false }; + } + }); + } - flatMap: function* flatMap(f) { - let y; - while (!(y = this.next()).done) { - yield* f(y.value); - } - return y.value; - }, + flatMap(f) { + const next = this.next; + let innerNext; - reduce: function reduce(f, state) { + return new Iterator(function() { + for (;;) { + if (innerNext) { + let y = innerNext(); + if (!y.done) { + return y; + } + } + + let z = next(); + if (z.done) { + innerNext = undefined; + return z; + } + + const iter = y.value.__proto__[Symbol.iterator].call(y.value); + innerNext = iter.next.bind(iter); + } + }); + } + + reduce(f, state) { if (typeof state === 'undefined') { const first = this.next(); if (first.done) { @@ -65,52 +122,60 @@ export const IteratorMixin = { } state = first.value; } + let y; while (!(y = this.next()).done) { state = f(state, y.value); } + return state; - }, + } - toArray: function toArray() { + toArray() { return [...this]; - }, + } - forEach: function* forEach(f) { + forEach(f) { let y; while (!(y = this.next()).done) { f(y.value); } - }, + /* extension: return final value from underlying iterator */ + return y.value; + } - some: function some(f) { + some(f) { /* extension: if f is undefined, assume identity function */ - let iter = (typeof f === 'undefined') ? this : IteratorMixin.map.call(this, f); + if (typeof f === 'undefined') { + f = identity; + } let y; - while (!(y = iter.next()).done) { - if (y.value) { + while (!(y = this.next()).done) { + if (f(y.value)) { return true; } } return false; - }, + } - every: function every(f) { + every(f) { /* extension: if f is undefined, assume identity function */ - let iter = (typeof f === 'undefined') ? this : IteratorMixin.map.call(this, f); + if (typeof f === 'undefined') { + f = identity; + } let y; - while (!(y = iter.next()).done) { - if (!y.value) { + while (!(y = this.next()).done) { + if (!f(y.value)) { return false; } } return true; - }, + } - find: function find(f) { + find(f) { + /* extension: if f is undefined, return the first 'truthy' value */ if (typeof f === undefined) { - /* extension */ - f = function identity(x) { return x; }; + f = identity; } let y; while (!(y = this.next()).done) { @@ -118,19 +183,17 @@ export const IteratorMixin = { return y.value; } } - }, + } /* extension */ - includes: function includes(x) { - return IteratorMixin.some.call(this, function equalsX(y) { return y === x; }); - }, + includes(x) { + return this.some(function matches(y) { return y == x; }); + } + + /* extension */ + strictlyIncludes(x) { + return this.some(function matches(y) { return y === x; }); + } }; -export const Iterator = {}; -for (const fn in IteratorMixin) { - Iterator[fn] = function() { - return IteratorMixin[fn].call(...arguments); - }; -} - export default Iterator; diff --git a/js/pacosako.js b/js/pacosako.js index 633d1f9..1cf091f 100644 --- a/js/pacosako.js +++ b/js/pacosako.js @@ -128,7 +128,14 @@ class Board { } toString() { - return new Buffer(this._board).toString('hex') + 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() { @@ -302,6 +309,114 @@ 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() + ')'; + } + + if (move.took === KING) { + result += '#'; + } else if (game.isInCheck()) { + result += '+'; + } + + let winner = game.winner; + + if (winner === LIGHT) { + result += ' 1-0'; + } else if (winner === DARK) { + result += ' 0-1'; + } else if (game.status !== PLAYING) { + result += ' \u00bd-\u00bd'; /* 1/2-1/2 */ + } + + game._history += result; +} + class Game { constructor(original) { if (original !== undefined) { @@ -314,13 +429,19 @@ class Game { this._status = original._status; this._moves = JSON.parse(JSON.stringify(original._moves)); this._redo = JSON.parse(JSON.stringify(original._redo)); + this._history = original._history; + this._turn = original._turn; this._castling = JSON.parse(JSON.stringify(original._castling)); + this._undo = original._undo; } else { this._board = new Board(); this._player = LIGHT; this._status = PLAYING; this._moves = []; this._redo = []; + this._history = ''; + this._turn = 0; + this._undo = null; /* set to false when the king or rook moves */ this._castling = {}; @@ -460,19 +581,88 @@ class Game { } isLegalMove(side, from, to, canCapture) { - const legals = this.legalMoves(side, from, canCapture); - return Iterator.some(legals, (where) => where === to); + const legals = new Iterator(this.legalMoves(side, from, canCapture)); + return legals.strictlyIncludes(to); } isInCheck(_side) { + if (this._status !== PLAYING || this._board.phantom) { + /* can't be in check mid-move, or if the game is already over */ + return false; + } + const side = _side || this._player; + const other = otherSide(side); const kings = [...this._board.findPieces(side, KING)]; if (kings.length !== 1) { - throw { message: "there should be exactly one king" }; + 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._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)) { + if (sim.isLegalMove(other, from, king, true)) { + /* this piece can directly capture the king */ + return true; + } + + queue.push({ game: sim, from }); + } + + /* arbitrary limit, but a human player would probably miss a 7-move chain too */ + const moveLimit = this._moves.length + 6; + const seen = new Set(); + + while (queue.length > 0) { + const game = queue[0].game; + const from = queue[0].from; + queue = queue.slice(1); + + seen.add(game.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 (!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 */ + return true; + } + + if (game2._moves.length < moveLimit) { + queue.push({ game: game2, from: PHANTOM }); + } + } + } + + /* didn't find anything */ + return false; } move(from, to, meta) { @@ -525,6 +715,7 @@ class Game { move.meta = JSON.parse(JSON.stringify(meta)); } + this._undo = new Game(this); board.move(from, to); if (type === KING) { @@ -582,12 +773,14 @@ class Game { this._player = otherSide(this._player); } + this._moves.push(move); + this._redo = []; + if (took === KING) { this._status = ENDED; } - this._moves.push(move); - this._redo = []; + addHistory(this); } resign(meta) { @@ -604,9 +797,12 @@ class Game { move.meta = meta; } - this._status = ENDED; + this._undo = new Game(this); this._moves.push(move); this._redo = []; + this._status = ENDED; + + addHistory(this); } get lastMove() { @@ -656,15 +852,10 @@ class Game { 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]; + /* copy all the properties from the saved prior game to this one */ + const savedUndo = this._undo; + for (const prop in savedUndo) { + this[prop] = savedUndo[prop]; } /* restore the original redo history and add the undone move */ @@ -685,122 +876,28 @@ class Game { this._redo = []; } - countTurns() { - let n = 0; - let player = null; + get turns() { + return this._turn; + } - for (const move of this._moves) { - /* multiple consecutive moves by the same player are a single turn */ - if (move.side !== player) { - ++n; - } + get history() { + return this._history; + } - player = move.side; - } - - return n; + dropHistory() { + this._history = undefined; } 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 (this._history === undefined) { + const replay = new Game(); + for (const move of this._moves) { + replay.replayMove(move); } - - 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.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() + ')'; - } - - if (move.took === KING) { - result += '#'; - } - - replay.replayMove(move); + this._history = replay._history; } - 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 this._history; } } diff --git a/js/pacosako_ui.js b/js/pacosako_ui.js index b291bfe..02e014e 100644 --- a/js/pacosako_ui.js +++ b/js/pacosako_ui.js @@ -144,6 +144,15 @@ $(function (){ const from = piece.data('location'); const legals = currentGame.legalMoves(side, from); for (const there of legals) { + try { + const preview = new PS.Game(currentGame); + preview.dropHistory(); + preview.move(from, there); + if (preview.isInCheck(side)) { + continue; + } + } catch (err) {} + const square = cbSquare(there); square.addClass('cb-legal') if (event === 'drag') { @@ -336,13 +345,16 @@ $(function (){ } msg += (winner === PS.LIGHT ? 'Light' : 'Dark') + ' player won!'; } else if (playing) { + if (game.isInCheck()) { + msg += 'Check! '; + } msg += (game.player === PS.LIGHT ? 'Light' : 'Dark') + ' player\'s turn.'; } else { msg += 'Game ended in a draw.'; } $('#cb_message').text(msg); - $('#cb_history').text(game.renderHistory()); + $('#cb_history').text(game.history || ''); $('#cb_nav_first').attr('disabled', !game.canUndo); $('#cb_nav_prev_turn').attr('disabled', !game.canUndo); @@ -438,7 +450,7 @@ $(function (){ const gameId = $('#cb_board').data('gameId'); const lightName = $('#cb_light_name').val(); const darkName = $('#cb_dark_name').val(); - const turns = currentGame.countTurns(); + const turns = currentGame.turns; const winner = currentGame.winner; const lastMove = currentGame.lastMove || {}; const lastMeta = lastMove.meta || {};