'use strict'; import PS from './pacosako.js'; import IO from './pacosako_io.js'; import deepEqual from 'deep-equal'; import 'webpack-jquery-ui/draggable'; import 'webpack-jquery-ui/droppable'; import 'webpack-jquery-ui/selectmenu'; import 'webpack-jquery-ui/clip-effect'; import 'webpack-jquery-ui/css'; import 'jquery-ui-touch-punch'; import escape from 'lodash/escape'; import jBox from 'jbox'; import 'jbox/dist/jBox.all.css'; import {Workbox, messageSW} from 'workbox-window'; import {ResizeSensor, ElementQueries} from 'css-element-queries'; ElementQueries.listen(); import {Buffer} from 'buffer'; import pako from 'pako'; import {sprintf} from 'sprintf-js'; /* "Waterdrop" by Porphyr (freesound.org/people/Porphyr) / CC BY 3.0 (creativecommons.org/licenses/by/3.0) */ import Waterdrop from '../mp3/191678__porphyr__waterdrop.mp3'; $(function (){ function debug() { if (window.logDebug) { console.log.apply(console, arguments); } } window.logDebug = false; let currentGame = new PS.Game(); let visibleGame = new PS.Game(currentGame); let cancelGameCallback = function() {}; function gameMessage(game) { let msg = ''; const winner = game.winner; if (winner) { const lastMove = game.lastMove; if (game.status === PS.CHECKMATE) { msg += 'Checkmate! '; } else 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) { if (game.isInCheck()) { msg += 'Check! '; } msg += (game.player === PS.LIGHT ? 'Light' : 'Dark') + ' player\'s turn.'; } else { msg += 'Game ended in a draw.'; } return msg; } function pieceTypeClass(type) { if (type === PS.KING) { return 'cb-king'; } else if (type === PS.QUEEN) { return 'cb-queen'; } else if (type === PS.ROOK) { return 'cb-rook'; } else if (type === PS.KNIGHT) { return 'cb-knight'; } else if (type === PS.BISHOP) { return 'cb-bishop'; } else if (type === PS.PAWN) { return 'cb-pawn'; } else { throw new Error(`unknown piece type: ${type}`); } } function pieceSideClass(side) { if (side === PS.LIGHT) { return 'cb-lt-piece'; } else if (side === PS.DARK) { return 'cb-dk-piece'; } else { throw new Error(`unknown side: ${side}`); } } function cbSquare(where) { if (where === PS.PHANTOM) { let square = $('#cb_phantom'); if (square.length < 1) { square = $(`
`); square.hide().appendTo('body').droppable({ accept: '.cb-piece', disabled: true, deactivate: function(/*ev, ui*/) { $(this).droppable('disable'); }, drop: squareDropDestination, }); } return square; } else if (where.match(/^[a-h][1-8]$/)) { return $('#cb_' + where).first(); } else { return null; } } function cbPiece(where, side) { const square = cbSquare(where); if (!square) { return null; } else if (side === true) { return square.find('.cb-piece'); } else { return square.find('.' + pieceSideClass(side)).first(); } } function cbSquareLocation(square) { const id = $(square).attr('id'); if (id === "cb_phantom") { return PS.PHANTOM; } const found = id.match(/^cb_([a-h][1-8])$/); if (found) { return found[1]; } return null; } function pieceStartMove(piece, event) { const side = piece.data('side'); const from = piece.data('location'); const legals = currentGame.legalMoves(side, from); for (const there of legals) { if (currentGame.isDeadEnd(from, there)) { continue; } const square = cbSquare(there); square.addClass('cb-legal'); if (event === 'drag') { square.droppable('enable'); } else if (event === 'click') { square.on('click.destination', squareClickDestination); } } if (event === 'drag') { cbSquare(from).droppable('enable'); } } function pieceEndMove(piece, to, animate) { let from = piece.data('location'); piece.appendTo('#cb_hidden'); try { const meta = { timestamp: +new Date() }; currentGame.move(from, to, meta); } catch (err) { debug('unable to move', err); animate = false; } notifyLocalMove(currentGame, $('#cb_board').data('gameId')); setCurrentGame(currentGame, animate); putState(); } function squareClickDestination(/*ev, ui*/) { let selected = $('#cb_board .cb-selected'); if (selected.length !== 1) { renderBoard(); return; } pieceEndMove(selected, cbSquareLocation(this), true); } function squareDropDestination(ev, ui) { const droppedAt = cbSquareLocation(this); if ($(ui.draggable).data('location') == droppedAt) { squareClickSelect.call(cbSquare(droppedAt)); } else { pieceEndMove(ui.draggable, droppedAt); } } function squareClickUnselect(/*ev, ui*/) { renderBoard(); } function squareClickSelect(/*ev, ui*/) { renderBoard(); const clicked = $(this).children('.cb-piece.ui-draggable').not('.ui-draggable-disabled'); clicked.addClass('cb-selected'); $('#cb_board .cb-square').off('click.select'); clicked.parent().on('click.unselect', squareClickUnselect); pieceStartMove(clicked, 'click'); if ($('#cb_board .cb-legal').length < 1) { /* cancel the selection since there is nowhere to move the piece to */ renderBoard(); } } function pieceStartDrag(/*ev, ui*/) { const dragged = $(this); $('#cb_board .cb-selected').removeClass('cb-selected'); $('#cb_board .cb-legal').removeClass('cb-legal'); dragged.data('saved-style', dragged.attr('style')); $('#cb_board').data('dragging_from', dragged.data('location')); pieceStartMove(dragged, 'drag'); } function pieceStopDrag(/*ev, ui*/) { const dragged = $(this); dragged.attr('style', dragged.data('saved-style')); dragged.removeData('saved-style'); dragged.css('z-index', ''); if ($('#cb_board').data('dragging_from') === dragged.data('location')) { if ($('#cb_board .cb-selected').length < 1) { renderBoard(); } } } function placePiece(where, side, type) { const piece = $(`
`); piece.addClass(pieceSideClass(side)); piece.addClass(pieceTypeClass(type)); piece.data({ side: side, type: type, location: where }); piece.appendTo(cbSquare(where)); piece.draggable({ disabled: true, containment: '#cb_inner', revert: true, zIndex: 100, start: pieceStartDrag, stop: pieceStopDrag, }); return piece; } function updatePlayerTimes() { let lightTime = 0; let darkTime = 0; let moveStartTime = undefined; for (const move of visibleGame.moves) { if (!move.meta || !Number.isInteger(move.meta.timestamp)) { lightTime = darkTime = undefined; break; } if (move.replaced) { /* not the final move in the chain */ continue; } /* start counting when the dark player finishes their first move */ if (moveStartTime === undefined) { if (move.side === PS.DARK) { moveStartTime = move.meta.timestamp; } else { continue; } } const moveTime = move.meta.timestamp - moveStartTime; if (moveTime > 0) { if (move.side === PS.LIGHT) { lightTime += moveTime; } else { darkTime += moveTime; } } moveStartTime = move.meta.timestamp; } if (moveStartTime === undefined) { $('#cb_light_time, #cb_dark_time').text('0:00:00'); $('#cb_times').addClass('cb-hide-times'); return; } if (visibleGame.status === PS.PLAYING) { if (!visibleGame.canRedo) { const currentMoveTime = +new Date() - moveStartTime; if (currentMoveTime > 0) { if (visibleGame.player === PS.LIGHT) { lightTime += currentMoveTime; } else { darkTime += currentMoveTime; } } } } $('#cb_times').removeClass('cb-hide-times'); function formatTime(milliseconds) { let seconds = milliseconds / 1000; const hours = seconds / 3600; seconds %= 3600; const minutes = seconds / 60; seconds %= 60; return sprintf('%d:%02d:%02d', hours, minutes, seconds); } $('#cb_light_time').text(formatTime(lightTime)); $('#cb_dark_time').text(formatTime(darkTime)); } setInterval(() => { updatePlayerTimes(); }, 250); function renderBoard(animate) { $('#cb_board').removeData('dragging_from'); $('#cb_board .cb-piece').remove(); $('#cb_board .cb-square').off('click.select'); $('#cb_board .cb-square').off('click.unselect'); $('#cb_board .cb-square').off('click.destination'); $('#cb_board .cb-start').removeClass('cb-start'); $('#cb_board .cb-end').removeClass('cb-end'); $('#cb_board .cb-legal').removeClass('cb-legal'); $('#cb_explain_check').text(''); $('#cb_names').removeClass('cb-light-turn').removeClass('cb-dark-turn'); $('#cb_names').removeClass('cb-light-won').removeClass('cb-dark-won'); $('#cb_phantom').remove(); const game = visibleGame; const board = game.board; for (const side of [PS.LIGHT, PS.DARK]) { 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) { placePiece(here, side, type); } } } } const lastMove = game.lastMove; if (lastMove) { if (lastMove.from) { cbSquare(lastMove.from).addClass('cb-start'); } if (lastMove.to) { cbSquare(lastMove.to).addClass('cb-end'); } if (lastMove.from && lastMove.to && animate) { const sideMoved = lastMove.alongside ? true : lastMove.side; const pieces = cbPiece(lastMove.to, sideMoved); if (pieces.length > 0) { const fromRect = cbSquare(lastMove.from)[0].getBoundingClientRect(); const toRect = cbSquare(lastMove.to)[0].getBoundingClientRect(); const movedDown = toRect.top - fromRect.top; const movedRight = toRect.left - fromRect.left; for (const domPiece of pieces) { const piece = $(domPiece); const originalTop = parseFloat(piece.css('top')); const originalLeft = parseFloat(piece.css('left')); const originalStyle = piece.attr('style') || null; piece.css({ 'z-index': 100, 'top': originalTop - movedDown, 'left': originalLeft - movedRight, }).animate({ 'top': originalTop, 'left': originalLeft, }, { always: function() { piece.attr('style', originalStyle); }, }); } } } } if (game.player === PS.LIGHT) { $('#cb_board').removeClass('cb-dk-turn').addClass('cb-lt-turn'); } else { $('#cb_board').removeClass('cb-lt-turn').addClass('cb-dk-turn'); } const liveView = !game.canRedo; const playing = game.status === PS.PLAYING; const clss = pieceSideClass(game.player); const phantom = board.phantom; if (phantom) { const piece = placePiece(PS.PHANTOM, phantom.side, phantom.type); cbSquare(PS.PHANTOM).appendTo(cbSquare(phantom.from)).show(); if (liveView && playing) { piece.draggable('enable'); piece.addClass('cb-selected'); pieceStartMove(piece, 'click'); } } else if (liveView && playing) { const pieces = $('#cb_board .' + clss); pieces.parent().on('click.select', squareClickSelect); pieces.draggable('enable'); } const check = game.isInCheck(); const king = $('#cb_board .cb-king.' + clss); if (check) { king.addClass('cb-in-check'); } if (typeof check === 'string') { $('#cb_explain_check').text(`(Check: ${check})`); } let msg = ''; if (!liveView) { msg += '(Move ' + String(game.moves.length) + ' of ' + String(currentGame.moves.length) + ') '; } msg += gameMessage(game); $('#cb_message').text(msg); const viewHistory = game.history || ''; const fullHistory = currentGame.history || ''; const futureHistory = fullHistory.slice(viewHistory.length); $('#cb_history_past').html(escape(viewHistory).replace(/\d+\./g, '$&')); $('#cb_history_future').html(escape(futureHistory).replace(/\d+\./g, '$&')); $('#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); $('#cb_undo').attr('disabled', !liveView || !currentGame.canUndo); $('#cb_redo').attr('disabled', !liveView || !currentGame.canRedo); $('#cb_resign').attr('disabled', !liveView || !playing); if (liveView) { $('#cb_board').addClass('cb-live').removeClass('cb-archive'); } else { $('#cb_board').removeClass('cb-live').addClass('cb-archive'); } if (game.status === PS.PLAYING) { if (game.player === PS.LIGHT) { $('#cb_names').addClass('cb-light-turn'); } else { $('#cb_names').addClass('cb-dark-turn'); } } else if (game.winner === PS.LIGHT) { $('#cb_names').addClass('cb-light-won'); } else if (game.winner === PS.DARK) { $('#cb_names').addClass('cb-dark-won'); } updatePlayerTimes(); } function applyTheme(theme) { let cbBoard = $('#cb_board').first(); for (const klass of cbBoard.prop('classList')) { if (klass.match(/^cb-theme-/)) { cbBoard.removeClass(klass); } } cbBoard.addClass('cb-theme-' + theme); } function setVisibleGame(game, animate) { /* navigation should not include the redo stack */ visibleGame = new PS.Game(game); visibleGame.clearRedo(); renderBoard(animate); } function setCurrentGame(game, animate) { currentGame = game; setVisibleGame(game, animate); const state = game.toJSON('minify'); const deflated = pako.deflate(Buffer.from(state)); const encoded = Buffer.from(deflated).toString('base64'); if ($('#page').hasClass('cb-private')) { history.replaceState(null, document.title, `#/private/${encoded}`); } else { $('.private-link').attr('href', `#/private/${encoded}`); } } 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; } function shortenName(name) { name = name.replace(/^\s+/, ''); name = name.replace(/\s+$/, ''); name = name.replace(/\s+/, ' '); let found = name.match(/^(.{0,20})(\s|$)/); if (found) { return found[1]; } return name.slice(0, 20) + '…'; } let noticeBox = null; function openNoticeBox(content) { if (noticeBox) { noticeBox.setContent(content); noticeBox.open(); } else { noticeBox = new jBox('Notice', { autoClose: false, closeOnEsc: false, closeOnClick: false, content: content, delayClose: 2000, delayOpen: 100, onClose() { if (noticeBox === this) { noticeBox = null; } }, onCloseComplete() { this.destroy(); }, }); } } const updateQueue = { head: 0, /* next item to be sent */ tail: 0, /* ID to assign to the next update added to the queue */ sending: false, idle: Promise.resolve(true), /* resolves to true if updates succeeded */ signal_idle: function() {}, add(gameId, data, modified) { data = Object.assign({}, data, { modified }); this[this.tail] = { gameId, data }; this.tail += 1; if (!this.sending) { openNoticeBox('Saving...'); this.sending = true; this.idle = new Promise((resolve) => { this.signal_idle = resolve; }); this.sendNext(); } }, isEmpty() { return this.head === this.tail; }, peek() { if (!this.isEmpty()) { return this[this.head]; } else { return undefined; } }, remove() { if (!this.isEmpty()) { const first = this[this.head]; delete this[this.head]; this.head += 1; return first; } else { return undefined; } }, sendNext() { const queue = this; const update = queue.remove(); if (update === undefined) { return; } /* merge updates for the same game with the same baseline into a single request */ let peek; while ((peek = queue.peek()) !== undefined && peek.gameId === update.gameId && peek.data.modified === update.data.modified) { Object.assign(update.data, peek.data); queue.remove(); } IO.sendUpdate(update.gameId, update.data).then((response) => { if (response && response.data && 'modified' in response.data) { /* * If we queued two or more updates with the same .modified (thus * based on the same server data), send the later update(s) with the * new .modified assigned by the server as a result of this update. */ for (let i = queue.head; i !== queue.tail; i += 1) { if (queue[i].gameId === update.gameId && queue[i].data.modified === update.data.modified) { queue[i].data.modified = response.data.modified; } } /* * Send future updates with the new modified time, and prevent loading * older data from the server in case the connection is lagging. */ const cbBoard = $('#cb_board'); if (cbBoard.data('gameId') === update.gameId && cbBoard.data('modified') === update.data.modified) { cbBoard.data('modified', response.data.modified); } } if (queue.isEmpty()) { queue.sending = false; queue.signal_idle(true); /* close the Saving... notice*/ noticeBox.close({ ignoreDelay: true }); } else { queue.sendNext(); } }).catch((err) => { openNoticeBox('Failed to send update to server.'); noticeBox.close(); debug('update error', err); /* additional updates are unlikely to succeed, so empty the queue */ while (!queue.isEmpty()) { queue.remove(); } queue.sending = false; queue.signal_idle(false); /* force a reset back to the latest server data */ if (update.gameId === $('#cb_board').data('gameId')) { switchGameId(update.gameId, true); } }); }, }; function putState() { const boardElem = $('#cb_board'); notifyLocalMove(currentGame, boardElem.data('gameId')); putMeta({ board: JSON.parse(currentGame.toJSON()) }); } function putMeta(extra) { function player(side) { return (side === PS.LIGHT ? 'light' : 'dark'); } const gameId = $('#cb_board').data('gameId'); if (gameId === 'private') { return; } const lightName = $('#cb_light_name').val(); const darkName = $('#cb_dark_name').val(); const turns = currentGame.turns; const winner = currentGame.winner; const lastMove = currentGame.lastMove || {}; const lastMeta = lastMove.meta || {}; const status = (currentGame.status === PS.PLAYING) ? `${player(currentGame.player)}'s turn` : (currentGame.status === PS.CHECKMATE) ? `checkmate — ${player(winner)} won` : (currentGame.status === PS.RESIGNED) ? `${player(lastMove.side)} resigned` : 'game ended'; const meta = { lightName, darkName, moves: turns, timestamp: lastMeta.timestamp || +new Date(), status, }; if (extra) { Object.assign(meta, extra); } updateQueue.add(gameId, meta, $('#cb_board').data('modified')); } function setNotifyChecked(doNotify) { const cbNotify = $('#cb_notify'); cbNotify.prop('checked', doNotify); if (doNotify) { cbNotify.removeClass('fa-bell-slash').addClass('fa-bell'); } else { cbNotify.removeClass('fa-bell').addClass('fa-bell-slash'); } } function switchGameId(newId, force) { const boardElem = $('#cb_board'); const gameId = boardElem.data('gameId'); debug('switching from ' + gameId + ' to ' + newId); if (newId === gameId && !force) { return; } /* cancel to reload data, but keep polling if game ID will be the same */ cancelGameCallback(newId === gameId); boardElem.data('gameId', newId); boardElem.data('modified', 0); history.replaceState(null, document.title, '#/' + newId); /* this will be the starting state if no data is received from peers */ setCurrentGame(new PS.Game()); notifyLocalMove(null, newId); boardElem.data('lightName', 'Light'); boardElem.data('darkName', 'Dark'); $('#cb_light_name').val(''); $('#cb_dark_name').val(''); if (newId === 'private') { cancelGameCallback = function() {}; } else { cancelGameCallback = IO.onGameUpdate(newId, function(data/*, gameId*/) { updateQueue.idle.then(() => { if (data.modified > $('#cb_board').data('modified')) { try { 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) { debug('Error parsing board data', err); } const d = data || {}; $('#cb_board').data('lightName', shortenName(String(d.lightName || 'Light'))); $('#cb_board').data('darkName', shortenName(String(d.darkName || 'Dark'))); $('#cb_light_name').val(String(d.lightName || '')); $('#cb_dark_name').val(String(d.darkName || '')); $('#cb_board').data('modified', data.modified); } }); }); const notifyList = $('#cb_notify').data('gameList'); const doNotify = notifyList.includes('*') || notifyList.includes(newId); setNotifyChecked(doNotify); if (doNotify) { requestNotify(); } /* Ensure that the selected game is in the list (for new games). */ if ($('#game_tile_' + newId).length < 1) { updateSelectGameMeta({}, newId); } } const reverseList = $('#cb_reverse').data('gameList'); const doReverse = reverseList.includes('*') || reverseList.includes(newId); $('#cb_reverse').prop('checked', doReverse); arrangeBoard(doReverse); /* This is in case the old tile no longer qualifies to be in the list. */ const oldGameTile = $('#game_tile_' + gameId); if (oldGameTile.length >= 1) { updateSelectGameMeta(oldGameTile.data('gameMeta'), gameId); } $('.game-tile-selected').removeClass('game-tile-selected'); $('#game_tile_' + newId).addClass('game-tile-selected'); } function disableNotify(){ setNotifyChecked(false); $('#cb_notify').attr('disabled', true); } function requestNotify(){ try { Notification.requestPermission(function (permission){ if (permission === 'denied') { disableNotify(); } }); } catch (err) { disableNotify(); } } const notifyAudio = new Audio(Waterdrop); function playNotifySound(){ try { notifyAudio.play(); } catch (err) {/*ignore*/} } function notify(body) { 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(); } } async function closeNotifications() { try { const registration = await navigator.serviceWorker.ready; if ('getNotifications' in registration) { const notifications = await registration.getNotifications({tag: 'notice'}); for (const notification of notifications) { notification.close(); } } } catch (err) {/*ignore*/} } function arrangeBoard(reversed) { let rows = '9876543210'.split(''); let columns = 'LabcdefghR'.split(''); const boardElem = $('#cb_board'); if (reversed) { rows.reverse(); columns.reverse(); boardElem.addClass('cb-reversed'); } else { boardElem.removeClass('cb-reversed'); } for (const row of rows) { const rowElem = $('#cb_row' + row); rowElem.appendTo(boardElem); for (const column of columns) { $('#cb_' + column + row).appendTo(rowElem); } } } if ('serviceWorker' in navigator) { let confirmBox = null; Promise.resolve().then(async () => { const wb = new Workbox('sw.js'); let latest_sw = null; function showSkipWaitingPrompt(event) { latest_sw = event.sw; if (!confirmBox) { confirmBox = new jBox('Confirm', { attach: null, content: "A new version is available. Update the page?", confirmButton: `Update`, cancelButton: 'Not now', closeOnConfirm: false, async confirm() { /* The SW should signal us to reload, but do it after 20s regardless. */ setTimeout(() => { window.location.reload(); }, 20000); messageSW(latest_sw, {type: 'SKIP_WAITING'}); $('.update-confirm-button').text('Updating…'); }, onClose() { if (confirmBox === this) { confirmBox = null; } }, onCloseComplete() { this.destroy(); }, }); confirmBox.open(); } } function reloadForUpdate(/*event*/) { window.location.reload(); } wb.addEventListener('installed', (/*event*/) => { try { if (Notification.permission === 'denied') { disableNotify(); } } catch (err) { disableNotify(); } }); wb.addEventListener('waiting', showSkipWaitingPrompt); wb.addEventListener('externalwaiting', showSkipWaitingPrompt); wb.addEventListener('controlling', reloadForUpdate); wb.addEventListener('externalactivated', reloadForUpdate); const registration = await wb.register(); await registration.ready; /* Check for updates every 4h without reloading the page. */ setInterval(() => { wb.update(); }, 4*3600*1000); window.Admin.workbox = wb; }).catch((err) => { console.error('failed to register the service worker', err); disableNotify(); }); } const LS_KEY_NOTIFY = 'pacosako/notify'; const LS_KEY_SOUND = 'pacosako/sound'; const LS_KEY_REVERSE = 'pacosako/reverse'; const LS_KEY_THEME = 'pacosako/theme'; if ('localStorage' in window) { const fromStorage = function fromStorage(key, value) { function updatePerGameFlag(key, value, selector, onchange) { let gameList = undefined; const gameId = $('#cb_board').data('gameId'); if (value === 'on') { gameList = ['*']; } else if (value === null || value === 'off') { gameList = []; } else { try { gameList = JSON.parse(value); if (!Array.isArray(gameList)) { throw new TypeError(`expected an array for ${key}`); } for (const item of gameList) { if (typeof item !== 'string') { throw new TypeError(`expected an array of strings for ${key}`); } } } catch (err) { debug(`error parsing game list for ${key}`, err); gameList = []; } } const enabled = gameList.includes('*') || gameList.includes(gameId); const checkbox = $(selector).first(); const wasChecked = checkbox.prop('checked'); checkbox.data('gameList', gameList); checkbox.prop('checked', enabled); if (enabled !== wasChecked) { onchange(enabled); } } debug('from localStorage', { key, value }); if (key === LS_KEY_NOTIFY) { updatePerGameFlag(key, value, '#cb_notify', (enabled) => { setNotifyChecked(enabled); if (enabled) { requestNotify(); } }); } else if (key === LS_KEY_SOUND) { const doSound = value === 'on'; const cb_sound = $('#cb_sound')[0]; cb_sound.checked = doSound; } else if (key === LS_KEY_REVERSE) { updatePerGameFlag(key, value, '#cb_reverse', (enabled) => { arrangeBoard(enabled); }); } else if (key === LS_KEY_THEME) { if (value === null) { value = 'pacosako'; } const cb_theme = $('#cb_select_theme'); if (value !== cb_theme.val()) { cb_theme.val(value); if (!cb_theme.val()) { value = 'pacosako'; cb_theme.val(value); } applyTheme(value); } } }; $(window).on('storage', function(event){ fromStorage(event.originalEvent.key, event.originalEvent.newValue); }); for (const key of [LS_KEY_NOTIFY, LS_KEY_SOUND, LS_KEY_REVERSE, LS_KEY_THEME]) { const value = window.localStorage.getItem(key); fromStorage(key, value); } } $('.cb-square').droppable({ accept: '.cb-piece', disabled: true, deactivate: function(/*ev, ui*/){ $(this).droppable('disable'); }, drop: squareDropDestination, }); /* Maximum length of gameList for per-game flags like notify and reverse */ const GAMES_TO_REMEMBER = 50; function perGameFlagChanged(key, selector) { if ('localStorage' in window) { const checkbox = $(selector); const checked = checkbox.prop('checked'); const gameId = $('#cb_board').data('gameId'); let gameList = checkbox.data('gameList') || []; if (gameList.includes('*')) { gameList = Object.keys(IO.getCachedMeta()); } gameList = gameList.filter((x) => x !== gameId); if (checked) { /* Ensure the new gameId is at the front of the list */ gameList.unshift(gameId); } gameList = gameList.slice(0, GAMES_TO_REMEMBER); checkbox.data('gameList', gameList); window.localStorage.setItem(key, JSON.stringify(gameList)); } } $('#cb_notify').on('change', function(){ perGameFlagChanged(LS_KEY_NOTIFY, this); setNotifyChecked(this.checked); if (this.checked) { requestNotify(); } }); $('#cb_sound').on('change', function(){ if ('localStorage' in window) { window.localStorage.setItem(LS_KEY_SOUND, this.checked ? 'on' : 'off'); } }); $('#cb_reverse').on('change', function(){ perGameFlagChanged(LS_KEY_REVERSE, this); arrangeBoard(this.checked); }); $('#cb_undo').on('click', function(){ if (currentGame.canUndo) { currentGame.undo(); notifyLocalMove(currentGame, $('#cb_board').data('gameId')); setCurrentGame(currentGame); putState(); } }); $('#cb_redo').on('click', function(){ if (currentGame.canRedo) { currentGame.redo(); notifyLocalMove(currentGame, $('#cb_board').data('gameId')); setCurrentGame(currentGame, true); putState(); } }); $('#cb_resign').on('click', function(){ try { const meta = { timestamp: +new Date() }; currentGame.resign(meta); } catch (err) { debug('unable to resign', err); } notifyLocalMove(currentGame, $('#cb_board').data('gameId')); setCurrentGame(currentGame); putState(); }); $('#cb_nav_first').on('click', function(){ while (visibleGame.canUndo) { visibleGame.undo(); } renderBoard(); }); $('#cb_nav_prev_turn').on('click', function(){ if (visibleGame.canUndo) { visibleGame.undo(); 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(true); }); $('#cb_nav_next_turn').on('click', function(){ const player = visibleGame.player; while (visibleGame.canRedo) { visibleGame.redo(); if (visibleGame.player !== player) { break; } } renderBoard(true); }); $('#cb_nav_last').on('click', function(){ while (visibleGame.canRedo) { visibleGame.redo(); } renderBoard(true); }); $('#cb_select_theme').on('change', function(){ const theme = $('#cb_select_theme').val(); debug('cb_select_theme changed to ' + theme); if ('localStorage' in window) { window.localStorage.setItem(LS_KEY_THEME, theme); } applyTheme(theme); }); $('#cb_choose_game').on('click', function() { if (selectBox) { selectBox.open(); } }); let helpBox = null; $('#help').on('click', function() { helpBox = helpBox || new jBox('Modal', { title: '

Rule Reference

', content: $('#rules_content'), blockScroll: false, blockScrollAdjust: false, isolateScroll: false, footer: `
`, closeButton: 'title', onCreated() { $('.badges').appendTo('#help_badges'); }, }); helpBox.open(); }); let settingBox = null; $('#settings').on('click', function() { settingBox = settingBox || new jBox('Modal', { title: '

Settings

', content: $('#settings_content'), blockScroll: false, blockScrollAdjust: false, isolateScroll: false, closeButton: 'title', }); settingBox.open(); }); $('#cb_light_name, #cb_dark_name').on('input', function() { putMeta(); }); let gameSelectContent = $(`
`).appendTo('body'); let gameTiles = $(`
New
Game
`).appendTo(gameSelectContent); var selectBoxIntervalID = undefined; var selectBox = new jBox('Modal', { content: gameSelectContent, blockScroll: false, blockScrollAdjust: false, isolateScroll: false, delayClose: 750, onOpen() { if (selectBoxIntervalID !== undefined) { clearInterval(selectBoxIntervalID); } selectBoxIntervalID = setInterval(() => { updateTileAges(); }, 15000); updateTileAges(); }, onCloseComplete() { if (selectBoxIntervalID !== undefined) { clearInterval(selectBoxIntervalID); selectBoxIntervalID = undefined; } }, position: { x: 'center', y: 'center' }, reposition: true, }); $('.new-game-tile').on('click', function() { switchGameId(randomId()); selectBox.close(); }); function inWords(n, singular, plural) { const words = [ 'zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine' ]; return `${words[n] || n} ${n === 1 ? singular : (plural || (singular + 's'))}`; } function updateTileAges() { const now = +new Date(); for (const tileElem of $('.game-tile.existing-game-tile')) { const tile = $(tileElem); const game = tile.data('gameMeta'); if (!game || !game.timestamp) { continue; } const then = game.timestamp; let age_str = ''; if ((now - then) < 60*1000) { age_str = `just now`; } else if ((now - then) < 60*60*1000) { const minutes = Math.floor((now - then) / (60*1000)); age_str = `${inWords(minutes, 'minute')} ago`; } else if ((now - then) < 24*60*60*1000) { const hours = Math.floor((now - then) / (60*60*1000)); age_str = `${inWords(hours, 'hour')} ago`; } else { const days = Math.floor((now - then) / (24*60*60*1000)); age_str = `${inWords(days, 'day')} ago`; } tile.find('.game-tile-age').text(age_str); } } function updateSelectGameMeta(data, gameId) { data = Object.assign({}, data, { gameId: gameId || data.gameId, timestamp: data.timestamp || +new Date(), }); if (typeof gameId !== 'string' || !gameId.match(/^[0-9a-f]{16}$/)) { debug('invalid game ID', gameId); return; } const currentGameId = $('#cb_board').data('gameId'); const oldTile = $('#game_tile_' + gameId).first(); if (!data.lightName && !data.darkName && !data.moves && gameId !== currentGameId) { oldTile.removeAttr('id'); if (oldTile.length >= 1) { oldTile.hide({ effect: "clip", direction: "horizontal", complete() { oldTile.remove(); }, }); } return; } const tile = oldTile.length ? oldTile : $(`
`).hide(); tile.attr('id', 'game_tile_' + gameId).data('gameMeta', data).empty(); tile.off('click'); if (gameId === $('#cb_board').data('gameId')) { tile.addClass("game-tile-selected"); } const titleBlock = $('
').appendTo(tile); $('').appendTo(titleBlock) .text(shortenName(String(data.lightName || 'Light'))); $(`vs.`).appendTo(titleBlock); $('').appendTo(titleBlock) .text(shortenName(String(data.darkName || 'Dark'))); $('
').appendTo(tile); if (data.status) { $('
').appendTo(tile).text(data.status); } if (data.moves) { $('
').appendTo(tile) .text(inWords(data.moves, 'turn')); } $('
').addClass('game-tile-age').appendTo(tile); tile.on('click', function() { $('.game-tile-selected').removeClass('game-tile-selected'); tile.addClass('game-tile-selected'); setTimeout(() => { switchGameId(gameId); }, 0); selectBox.close(); }); const list = gameTiles.find('.existing-game-tile').get(); list.push(tile[0]); list.sort(function(a,b) { const then_a = $(a).data('gameMeta').timestamp; const then_b = $(b).data('gameMeta').timestamp; return (then_a < then_b) ? 1 : (then_a === then_b) ? 0 : -1; }); $(list).appendTo(gameTiles); updateTileAges(); tile.show({ effect: "clip", direction: "horizontal", }); selectBox.setContent(gameSelectContent); } const lastNotifyState = {}; function notifyForGame(meta, gameId) { const notifyList = $('#cb_notify').data('gameList') || []; if (!notifyList.includes('*') && !notifyList.includes(gameId)) { return; } lastNotifyState[gameId] = lastNotifyState[gameId] || {}; const lastState = lastNotifyState[gameId]; const lastMetaState = lastState.meta || {}; const changed = meta.status !== lastMetaState.status || meta.moves !== lastMetaState.moves || meta.timestamp !== lastMetaState.timestamp; if (lastState.meta && changed) { IO.getGameState(gameId).then((data) => { let game = undefined; try { game = new PS.Game(JSON.stringify(data.board)); } catch (err) { debug('failed to parse game for notification', err); return; } const moves = game.moves; if (!deepEqual(moves, lastState.moves)) { if (!game.board.phantom && moves.length > (lastState.moves || []).length) { const lightName = shortenName(String(data.lightName || 'Light')); const darkName = shortenName(String(data.darkName || 'Dark')); const message = gameMessage(game); const gameString = `${lightName} vs. ${darkName}\n${message}`; closeNotifications().then(() => { notify(gameString); }); if ($('#cb_sound').prop('checked')) { playNotifySound(); } } lastState.moves = moves; } }).catch((err) => { debug('failed to retrieve game data for notification', err); }); } lastState.meta = meta; } function notifyLocalMove(game, gameId) { if (!game) { delete lastNotifyState[gameId]; /* Preload the last-known state if we already have data for this game */ const localMeta = IO.getCachedMeta(); if (gameId in localMeta) { notifyForGame(localMeta[gameId], gameId); } } else { lastNotifyState[gameId] = lastNotifyState[gameId] || {}; lastNotifyState[gameId].moves = game.moves; } } window.onhashchange = function(/*event*/){ const foundId = location.hash.match(/^#\/([0-9a-f]{16}\b)/); if (foundId) { switchGameId(foundId[1]); } }; const foundPrivate = location.hash.match(/^#\/private((\/(.*)?)?)$/); if (foundPrivate) { switchGameId('private'); $('#page').addClass('cb-private'); let state; try { const decoded = Buffer.from(foundPrivate[1].slice(1), 'base64'); const inflated = pako.inflate(decoded); state = JSON.parse(Buffer.from(inflated).toString()); } catch(err) {/*ignore*/} if (state) { setCurrentGame(new PS.Game(JSON.stringify(state))); } } else { const foundId = location.hash.match(/^#\/([0-9a-f]{16}\b)/); if (foundId) { switchGameId(foundId[1]); } else { switchGameId(randomId()); } IO.onMetaUpdate(async (meta, gameId) => { await updateQueue.idle; updateSelectGameMeta(meta, gameId); notifyForGame(meta, gameId); }); } function adjustBoardSize() { let container = $('#cb_container'); let outerWidth = container.width(); let outerHeight = container.height(); let size = ((outerWidth < outerHeight) ? outerWidth : outerHeight) - 24; $('#cb_board').css({ width: size + 'px', height: size + 'px', top: ((outerHeight - size) / 2) + 'px', left: ((outerWidth - size) / 2) + 'px', visibility: '', }); } new ResizeSensor($('#cb_container'), adjustBoardSize); adjustBoardSize(); (function() { const body = $('body').first(); let horizontal = false; function updateLayout() { const oldHorizontal = horizontal; horizontal = body.width() >= (1.5 * body.height()); if (horizontal !== oldHorizontal) { if (horizontal) { $('#cb_container').prependTo('#page'); $('#header').appendTo('#board_ui'); $('#cb_status').appendTo('#board_ui'); $('#cb_names').appendTo('#board_ui'); $('#cb_times').appendTo('#board_ui'); $('#cb_navigate').appendTo('#board_ui'); $('#board_ui').appendTo('#page'); $('#page').removeClass('vertical-layout').addClass('horizontal-layout'); } else { $('#header').appendTo('#board_ui'); $('#cb_container').appendTo('#board_ui'); $('#cb_status').appendTo('#board_ui'); $('#cb_names').appendTo('#board_ui'); $('#cb_times').appendTo('#board_ui'); $('#cb_navigate').appendTo('#board_ui'); $('#board_ui').appendTo('#page'); $('#page').removeClass('horizontal-layout').addClass('vertical-layout'); } } } new ResizeSensor(body, updateLayout); updateLayout(); })(); /* Low-level commands to be run from the JS console */ window.Admin = { getGameId() { return $('#cb_board').data('gameId'); }, getCurrentGame() { return currentGame; }, getVisibleGame() { return visibleGame; }, setCurrentGame, setVisibleGame, refresh() { setCurrentGame(currentGame); }, renderBoard, putState, putMeta, PS, IO, $, }; (function() { const match = location.href.match('^https://jessemcdonald.info/~nybble/paco_sako/(.*)'); if (match) { /* Redirect to the new URL */ location.href = `https://pacosako.jessemcdonald.info/${match[1]}`; } })(); }); /* vim:set expandtab sw=3 ts=8: */