major rewrite; breaking change; game logic in separate file

This commit is contained in:
Jesse D. McDonald 2020-03-22 01:38:34 -05:00
parent 0d91a2baca
commit a96e8f1e4f
5 changed files with 1563 additions and 1249 deletions

View File

@ -245,26 +245,6 @@
<div id="cb_piece_pl"><img src="svg/Chess_plt45.svg" alt="pl" draggable="true" class="cb-piece cb-lt-piece cb-pawn"></div> <div id="cb_piece_pl"><img src="svg/Chess_plt45.svg" alt="pl" draggable="true" class="cb-piece cb-lt-piece cb-pawn"></div>
</div> </div>
</div> </div>
<div style="margin-top: 1em; display: none">
<form id="sign" onsubmit="return false;">
<input id="alias" placeholder="username">
<input id="pass" type="password" placeholder="passphrase">
<input id="in" type="submit" value="sign in">
<input id="up" type="button" value="sign up">
<p id="message" style="display: none"></p>
</form>
<div id="todo" style="display: none">
<ul id="todo_list"></ul>
<form id="said" onsubmit="return false;">
<input id="say">
<input id="speak" type="submit" value="speak">
<input id="sign_out" type="button" value="sign out">
</form>
</div>
</div>
</div> </div>
<script src="jquery-ui/external/jquery/jquery.js"></script> <script src="jquery-ui/external/jquery/jquery.js"></script>
@ -283,8 +263,8 @@
console.log = console.real_log; console.log = console.real_log;
</script> </script>
<script src="node-deep-equal/index.js"></script> <script src="node-deep-equal/index.js"></script>
<script src="js/chess.js"></script> <script src="js/pacosako.js"></script>
<script src="js/todo.js"></script> <script src="js/pacosako_ui.js"></script>
</body> </body>
</html> </html>
<!-- vim:set expandtab sw=3 ts=8: --> <!-- vim:set expandtab sw=3 ts=8: -->

File diff suppressed because it is too large Load Diff

804
js/pacosako.js Normal file
View File

@ -0,0 +1,804 @@
'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));
}
legalMoves(side, from, canCapture) {
const board = this._board;
const type = board.getPiece(side, from);
if (this._status !== PLAYING || type === EMPTY) {
return [];
}
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();
} 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 (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: */

757
js/pacosako_ui.js Normal file
View File

@ -0,0 +1,757 @@
'use strict';
$(function (){
let gun = Gun({
peers: ['https://jessemcdonald.info/gun'],
/* workaround for persistent errors accumulating which break synchronization */
//localStorage: false,
});
const PS = window.PacoSako;
let currentGame = new PS.Game();
let visibleGame = new PS.Game(currentGame);
let cancelGameCallback = function() {};
let cancelMetaCallback = function() {};
/* for debug */
window.getCurrentGame = function() { return currentGame; }
window.getVisibleGame = function() { return visibleGame; }
function debug() { console.log.apply(console, arguments); }
function pieceTypeCode(type) {
if (type === PS.KING) {
return 'k';
} else if (type === PS.QUEEN) {
return 'q';
} else if (type === PS.ROOK) {
return 'r';
} else if (type === PS.KNIGHT) {
return 'n';
} else if (type === PS.BISHOP) {
return 'b';
} else if (type === PS.PAWN) {
return 'p';
} else {
throw { message: 'unknown piece type', type: type };
}
}
function pieceSideCode(side) {
if (side === PS.LIGHT) {
return 'l';
} else if (side === PS.DARK) {
return 'd';
} else {
throw { message: 'unknown side', side: side };
}
}
function pieceCode(side, type) {
return pieceTypeCode(type) + pieceSideCode(side);
}
function cbSquare(where) {
if (where === PS.PHANTOM) {
return $('#cb_phantom').first()
} else {
return $('#cb_' + where).first()
}
}
function pieceStartDrag(ev, ui) {
const dragged = $(this);
const side = dragged.data('side');
const type = dragged.data('type');
const from = dragged.data('location');
const board = currentGame.board;
const where = (from === PS.PHANTOM) ? board.phantom.from : from;
const canCapture = (from === PS.PHANTOM) ||
board.getPiece(PS.Util.otherSide(side), where) === PS.EMPTY;
const legals = currentGame.legalMoves(side, from, canCapture);
for (const there of legals) {
cbSquare(there).addClass('cb-legal').droppable('enable');
}
}
function pieceStopDrag(ev, ui) {
$('#cb_board .cb-legal').removeClass('cb-legal').droppable('disable');
}
function placePiece(where, side, type, suffix) {
const code = pieceCode(side, type);
const piece_id = 'cb_piece_' + code + '_' + suffix;
const piece = $($('#' + piece_id)[0] || $('#cb_piece_' + code + ' img').clone());
piece.attr('style', '');
piece.attr('id', piece_id);
piece.data({ side: side, type: type, location: where });
piece.appendTo(cbSquare(where));
piece.draggable({
disabled: true,
containment: '#cb_inner',
revert: 'invalid',
zIndex: 100,
start: pieceStartDrag,
stop: pieceStopDrag,
});
return piece;
}
function renderBoard() {
$('#cb_board .cb-piece .ui-draggable').draggable('destroy');
$('#cb_board .cb-piece').attr('style', '').appendTo('#cb_hidden');
$('#cb_board .cb-start').removeClass('cb-start');
$('#cb_board .cb-end').removeClass('cb-end');
$('#cb_board .cb-legal').removeClass('cb-legal');
$('#cb_phantom').appendTo('#cb_hidden');
const game = visibleGame;
const board = game.board;
for (const side of [PS.LIGHT, PS.DARK]) {
const counters = {};
for (const row of PS.ROWS) {
for (const column of PS.COLUMNS) {
const here = column + row;
const type = board.getPiece(side, here);
if (type !== PS.EMPTY) {
if (!counters[type]) {
counters[type] = 0;
}
placePiece(here, side, type, String(++counters[type]));
}
}
}
}
let clss = game.player === PS.LIGHT ? '.cb-lt-piece' : '.cb-dk-piece';
if (board.phantom) {
let where = board.phantom.from;
placePiece(PS.PHANTOM, board.phantom.side, board.phantom.type, 'ph');
cbSquare(PS.PHANTOM).appendTo(cbSquare(where));
$('#cb_phantom .ui-draggable-disabled').filter(clss).draggable('enable');
} else if (game.status === PS.PLAYING) {
$('#cb_board .ui-draggable-disabled').filter(clss).draggable('enable');
}
const lastMove = game.lastMove;
if (lastMove) {
if (lastMove.from) {
cbSquare(lastMove.from).addClass('cb-start');
}
if (lastMove.to) {
cbSquare(lastMove.to).addClass('cb-end');
}
}
let msg = '';
let winner = game.winner;
if (winner) {
if (lastMove && lastMove.resign) {
msg += (lastMove.side === PS.LIGHT ? 'Light' : 'Dark') + ' player resigned. ';
}
msg += (winner === PS.LIGHT ? 'Light' : 'Dark') + ' player won!';
} else if (game.status === PS.PLAYING) {
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_nav_first').attr('disabled', !game.canUndo);
$('#cb_nav_prev_turn').attr('disabled', !game.canUndo);
$('#cb_nav_prev_state').attr('disabled', !game.canUndo);
$('#cb_nav_next_state').attr('disabled', !game.canRedo);
$('#cb_nav_next_turn').attr('disabled', !game.canRedo);
$('#cb_nav_last').attr('disabled', !game.canRedo);
if (!game.canRedo) {
$('#cb_undo').attr('disabled', !currentGame.canUndo);
$('#cb_redo').attr('disabled', !currentGame.canRedo);
if (currentGame.status === PS.PLAYING) {
$('#cb_pass').attr('disabled', currentGame.board.phantom ? true : false);
} else {
$('#cb_pass').attr('disabled', true);
}
$('#cb_resign').attr('disabled', currentGame.status !== PS.PLAYING);
$('#cb_board').addClass('cb-live');
$('#cb_board').removeClass('cb-archive');
} else {
$('#cb_undo').attr('disabled', true);
$('#cb_redo').attr('disabled', true);
$('#cb_pass').attr('disabled', true);
$('#cb_resign').attr('disabled', true);
$('#cb_board .ui-draggable').draggable('disable');
$('#cb_board').removeClass('cb-live');
$('#cb_board').addClass('cb-archive');
}
}
function setCurrentBoard(game) {
currentGame = game;
/* navigation should not include the redo stack */
visibleGame = new PS.Game(game);
visibleGame.clearRedo();
renderBoard();
const moves = game.moves;
const cb_board = $('#cb_board').first();
if ($('#cb_notify')[0].checked) {
if (!deepEqual(moves, cb_board.data('last_state'))) {
/* ignore partial moves */
if (!game.board.phantom) {
const gameString = cb_board.data('lightName') + ' vs. ' + cb_board.data('darkName');
notify(gameString + '\n' + $('#cb_message').text());
}
}
}
cb_board.data('last_state', moves);
}
function randomId(){
let res = '';
for (let i = 0; i < 4; ++i) {
const part = Math.floor(Math.random() * 65536).toString(16);
res = res + ('0000'.substring(part.length, 4) + part);
}
return res;
}
//const PacoSakoUUID = '7c38edd4-c931-49c8-9f1a-84de560815db'; /* V1 release */
const PacoSakoUUID = 'b425b812-6bdb-11ea-9414-6f946662bac3'; /* V2 release */
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);
gun.get(PacoSakoUUID).get('games').get(gameId).put({ board: JSON.stringify(moves) });
putMeta();
}
function putMeta(){
const gameId = $('#cb_board').data('gameId');
const lightName = $('#cb_light_name').val();
const darkName = $('#cb_dark_name').val();
const winner = currentGame.winner;
const lastMove = currentGame.lastMove || {};
const lastMeta = lastMove.meta || {};
const status = !winner ? null : (lastMove.took === PS.KING) ? 'mate' : 'ended';
gun.get(PacoSakoUUID).get('meta').get(gameId).put({
lightName: lightName,
darkName: darkName,
moves: currentGame.countTurns(),
timestamp: lastMeta.timestamp || new Date(Gun.state()).getTime(),
status: status,
});
}
function switchGameId(newId){
let boardElem = $('#cb_board');
let gameId = boardElem.data('gameId');
debug('switching from ' + gameId + ' to ' + newId);
if (newId === gameId) {
return;
}
cancelGameCallback();
cancelMetaCallback();
boardElem.data('gameId', newId);
location.hash = '#/' + newId;
if (gameId) {
//gun.get(PacoSakoUUID).get('games').get(gameId).off();
//gun.get(PacoSakoUUID).get('meta').get(gameId).off();
location.reload();
return;
}
const notifyAfter = new Date().getTime() + 2000;
$(window).data('notifyAfter', new Date().getTime() + 2000);
window.setTimeout(function() {
/* Delete the notification block in case the system time is changed backward */
if ($(window).data('notifyAfter') === notifyAfter) {
$(window).removeData('notifyAfter');
}
}, 2000);
closeNotifications();
/* this will be the starting state if no data is received from peers */
setCurrentBoard(new PS.Game());
boardElem.data('last_state', currentGame.moves);
boardElem.data('lightName', 'Light');
boardElem.data('darkName', 'Dark');
$('#cb_light_name').val('');
$('#cb_dark_name').val('');
(function(){
let callback = function(d) {
if (d && d.board) {
try {
const moves = JSON.parse(d.board);
debug('got board', moves);
const newGame = new PS.Game();
for (const move of moves.past) {
newGame.replayMove(move);
}
let n = 0;
for (const move of moves.future.slice().reverse()) {
newGame.replayMove(move);
n += 1;
}
for (let i = 0; i < n; ++i) {
newGame.undo();
}
setCurrentBoard(newGame);
} catch (err) {
debug('Error replaying board state', err);
}
}
};
cancelGameCallback = function() { callback = function() {}; };
gun.get(PacoSakoUUID).get('games').get(newId).on(function(d){
callback(d);
});
})();
(function(){
let callback = function(d) {
d = d || {};
debug('got meta', d);
$('#cb_board').data('lightName', d.lightName || 'Light');
$('#cb_board').data('darkName', d.darkName || 'Dark');
$('#cb_light_name').val(d.lightName || '');
$('#cb_dark_name').val(d.darkName || '');
};
cancelMetaCallback = function() { callback = function() {}; };
gun.get(PacoSakoUUID).get('meta').get(newId).on(function(d){
callback(d);
});
})();
let selOpt = $('#cb_game_' + newId);
if (selOpt.length === 1) {
$('#cb_select_game')[0].selectedIndex = selOpt.index();
} else {
$('#cb_select_game')[0].selectedIndex = -1;
}
}
function disableNotify(){
$('#cb_notify')[0].checked = false;
$('#cb_notify').attr('disabled', true);
}
function requestNotify(){
try {
Notification.requestPermission(function (permission){
if (permission === 'denied') {
disableNotify();
}
});
} catch (err) {
disableNotify();
}
}
function notify(body) {
const now = new Date().getTime();
const then = $(window).data('notifyAfter');
if (!then || now >= then) {
try {
Notification.requestPermission(function(permission){
if (permission === 'granted') {
navigator.serviceWorker.ready.then(function(registration){
registration.showNotification('Paco Ŝako', {
body: body,
tag: 'notice',
});
});
} else if (permission === 'denied') {
disableNotify();
}
});
} catch (err) {
disableNotify();
}
}
}
function closeNotifications(){
try {
navigator.serviceWorker.ready.then(function(registration){
registration.getNotifications({tag: 'notice'}).then(function(notifications){
for (const notification of notifications) {
notification.close();
}
});
});
} catch (err) {}
}
try {
if (Notification.permission === 'denied') {
disableNotify();
} else {
navigator.serviceWorker.register('sw.js').catch(disableNotify);
}
} catch (err) {
disableNotify();
}
if ('localStorage' in window) {
function updateNotify(newValue){
const doNotify = newValue === 'on';
const cb_notify = $('#cb_notify')[0];
if (doNotify) {
if (!cb_notify.checked) {
cb_notify.checked = true;
requestNotify();
}
} else if (cb_notify.checked) {
cb_notify.checked = false;
}
}
$(window).on('storage', function(event){
if (event.originalEvent.key === '/pacosako/notify') {
updateNotify(event.originalEvent.newValue);
}
});
updateNotify(window.localStorage.getItem('/pacosako/notify'));
}
$('.cb-square').droppable({
accept: '.cb-piece',
disabled: true,
deactivate: function(ev, ui){
$(this).droppable('disable');
},
drop: function(ev, ui) {
let dragged = ui.draggable;
let from = dragged.data('location');
let to = this.id.replace(/^cb_/, '');
dragged.appendTo('#cb_hidden');
try {
const meta = { timestamp: new Date(Gun.state()).getTime() };
currentGame.move(from, to, meta);
} catch (err) {
debug('unable to move', err);
}
$('#cb_board').data('last_state', currentGame.moves);
setCurrentBoard(currentGame);
putState();
},
});
$('#cb_undo').on('click', function(){
if (currentGame.canUndo) {
currentGame.undo();
$('#cb_board').data('last_state', currentGame.moves);
setCurrentBoard(currentGame);
putState();
}
});
$('#cb_redo').on('click', function(){
if (currentGame.canRedo) {
currentGame.redo();
$('#cb_board').data('last_state', currentGame.moves);
setCurrentBoard(currentGame);
putState();
}
});
$('#cb_resign').on('click', function(){
try {
const meta = { timestamp: new Date(Gun.state()).getTime() };
currentGame.resign(meta);
} catch (err) {
debug('unable to resign', err);
}
$('#cb_board').data('last_state', currentGame.moves);
setCurrentBoard(currentGame);
putState();
});
$('#cb_nav_first').on('click', function(){
while (visibleGame.canUndo) {
visibleGame.undo();
}
renderBoard();
});
$('#cb_nav_prev_turn').on('click', function(){
const player = visibleGame.player;
while (visibleGame.canUndo) {
visibleGame.undo();
if (visibleGame.player === player) {
visibleGame.redo();
break;
}
}
renderBoard();
});
$('#cb_nav_prev_state').on('click', function(){
if (visibleGame.canUndo) {
visibleGame.undo();
}
renderBoard();
});
$('#cb_nav_next_state').on('click', function(){
if (visibleGame.canRedo) {
visibleGame.redo();
}
renderBoard();
});
$('#cb_nav_next_turn').on('click', function(){
const player = visibleGame.player;
while (visibleGame.canRedo) {
visibleGame.redo();
if (visibleGame.player !== player) {
break;
}
}
renderBoard();
});
$('#cb_nav_last').on('click', function(){
while (visibleGame.canRedo) {
visibleGame.redo();
}
renderBoard();
});
$('#cb_select_game').on('change', function(){
let optIndex = $('#cb_select_game')[0].selectedIndex;
if (optIndex === 0) {
switchGameId(randomId());
} else if (optIndex >= 1) {
let opt = $('#cb_select_game option')[optIndex];
if (opt) {
switchGameId(opt.id.replace(/^cb_game_/, ''));
}
}
});
$('#cb_notify').on('change', function(){
if ('localStorage' in window) {
window.localStorage.setItem('/pacosako/notify', this.checked ? 'on' : 'off');
}
if (this.checked) {
requestNotify();
}
});
let updateMeta = function() { putMeta(); }
$('#cb_light_name').on('input', updateMeta);
$('#cb_dark_name').on('input', updateMeta);
function updateTitle(opt){
opt = $(opt);
const then = opt.data('then');
const now = new Date(Gun.state()).getTime();
let age_str = '';
if (then > now) {
age_str = ' (future)';
} else if ((now - then) < 60*60*1000) {
age_str = ' (' + Math.floor((now - then) / (60*1000)) + 'm)';
} else if ((now - then) < 24*60*60*1000) {
age_str = ' (' + Math.floor((now - then) / (60*60*1000)) + 'h)';
} else if ((now - then) < 14*24*60*60*1000) {
age_str = ' (' + Math.floor((now - then) / (24*60*60*1000)) + 'd)';
} else if (opt.data('gameId') !== $('#cb_board').data('gameId')) {
opt.remove();
return;
}
opt.text(opt.data('title') + age_str);
}
gun.get(PacoSakoUUID).get('meta').map().on(function(d,key){
if (key.match(/^[0-9a-f]{16}$/) && d) {
debug('got meta for key ' + key, d);
const lightName = d.lightName ? d.lightName : 'Light';
const darkName = d.darkName ? d.darkName : 'Dark';
const moves = !d.moves ? '' :
(', ' + d.moves + (d.moves === 1 ? ' move' : ' moves'));
let opt = $('#cb_game_' + key);
if (!(d.lightName || d.darkName) && !d.moves && key !== $('#cb_board').data('gameId')) {
if (opt.length >= 1) {
opt.remove();
}
} else {
if (opt.length === 0) {
opt = $('<option></option>');
opt.attr('id', 'cb_game_' + key);
}
let stat = '';
if (d.status) {
stat = ', ' + d.status;
}
opt.data('gameId', key);
opt.data('title', lightName + ' vs. ' + darkName + moves + stat);
opt.data('then', d.timestamp || new Date(Gun.state()).getTime());
opt.addClass('cb-game-option');
opt.appendTo('#cb_select_game');
updateTitle(opt);
let select = $('#cb_select_game');
let list = select.children('.cb-game-option').get();
list.sort(function(a,b) {
const then_a = $(a).data('then');
const then_b = $(b).data('then');
return (then_a < then_b) ? 1 : (then_a === then_b) ? 0 : -1;
});
for (const e of list) {
$(e).appendTo(select);
}
}
let selOpt = $('#cb_game_' + $('#cb_board').data('gameId'));
if (selOpt.length === 1) {
$('#cb_select_game')[0].selectedIndex = selOpt.index();
} else {
$('#cb_select_game')[0].selectedIndex = -1;
}
}
});
window.setInterval(function(){
$('#cb_select_game').first().children('.cb-game-option').each(function(idx,opt){
updateTitle(opt);
});
}, 15000);
window.setInterval(function(){
const peers = gun._.opt.peers;
let n_open = 0;
let n_total = 0;
for (const peerId in peers) {
++n_total;
try {
const peer = peers[peerId];
if (peer.constructor === RTCPeerConnection) {
if (peer.connectionState === 'connected') {
++n_open;
}
} else if (peer.wire && peer.wire.constructor === WebSocket) {
if (peer.wire.readyState === peer.wire.OPEN) {
++n_open;
}
}
} catch (err) {}
}
const newText = 'Synchronizing with ' + n_open + (n_open === 1 ? ' peer' : ' peers') + ' (' + n_total + ' total).';
if (newText !== $('#cb_peers').text()) {
$('#cb_peers').text(newText);
}
}, 1000);
window.onpopstate = function(event){
const foundId = location.hash.match(/^#\/([0-9a-f]{16}\b)/);
if (foundId) {
switchGameId(foundId[1]);
}
};
/* force page reload after four hours to keep client code up to date */
window.setTimeout(function(){ location.reload(); }, 4*3600*1000);
const foundId = location.hash.match(/^#\/([0-9a-f]{16}\b)/);
switchGameId(foundId ? foundId[1] : randomId());
/* Low-level commands to be run from the JS console */
window.Admin = {
convertFromV1: function() {
const PacoSakoUUIDv1 = '7c38edd4-c931-49c8-9f1a-84de560815db';
gun.get(PacoSakoUUIDv1).get('games').map().once(function(d,key){
if (d && d.board) {
debug('converting ' + key);
const game = new PS.Game();
let moves = [];
try {
let board = JSON.parse(d.board);
while (board.prior) {
moves.push(board.move);
board = board.prior;
}
moves.reverse();
for (const move of moves) {
if (move.to) {
game.move(move.from, move.to, move.timestamp && { timestamp: move.timestamp });
} else if (move.resign) {
game.resign();
} else {
throw { message: "unknown move", move: move };
}
}
gun.get(PacoSakoUUID).get('games').get(key).put({ board: JSON.stringify({
past: game.moves,
future: [],
})});
} catch (err) {
debug('conversion of ' + key + ' failed', err, game, moves);
}
}
});
gun.get(PacoSakoUUIDv1).get('meta').map().once(function(d,key){
if (d) {
debug('converting metadata for ' + key);
gun.get(PacoSakoUUID).get('meta').get(key).put({
lightName: d.lightName,
darkName: d.darkName,
moves: d.moves,
timestamp: d.timestamp,
status: d.status,
});
}
});
},
cleanupMissingGames: function() {
gun.get(PacoSakoUUID).get('games').once(function(games){
gun.get(PacoSakoUUID).get('meta').map().once(function(d,key){
if (!(key in games)) {
gun.get(PacoSakoUUID).get('meta').get(key).put(null);
}
});
});
},
};
});
/* vim:set expandtab sw=3 ts=8: */

View File

@ -1,56 +0,0 @@
var user = gun.user();
function UI(say, id){
var li = $('#' + id).get(0) || $('<li>').attr('id', id).appendTo('ul');
$(li).text(say);
};
function auth(alias, pass){
$('#message').text('Looking up alias "' + alias + '"...').show();
gun.get('~@' + alias).once(function(){
$('#message').text('Signing in as "' + alias + '"...').show();
user.auth(alias, pass, function (ack){
if (ack.err) {
$('#message').text(ack.err).show();
} else {
$('#message').hide();
$('#pass').val('');
$('#sign').hide();
$('#todo').show();
user.get('said').map().once(UI);
}
});
});
};
$('#up').on('click', function(e){
var alias = $('#alias').val();
var pass = $('#pass').val();
$('#message').text('Creating alias "' + alias + '"...').show();
user.create(alias, pass, function (ack){
if (ack.err) {
$('#message').text(ack.err).show();
} else {
auth(alias, pass);
}
});
});
$('#sign').on('submit', function(e){
e.preventDefault();
auth($('#alias').val(), $('#pass').val());
});
$('#said').on('submit', function(e){
e.preventDefault();
if(!user.is){ return }
user.get('said').set($('#say').val());
$('#say').val('');
});
$('#sign_out').on('click', function (){
$('ul').empty();
user.leave();
$('#sign').show();
$('#todo').hide();
});