From f46a68b8ed74b54b6a26645b88d2a4ae48c1227a Mon Sep 17 00:00:00 2001 From: Benjamin Auder Date: Wed, 17 Nov 2021 14:03:08 +0100 Subject: [PATCH] Remove (useless?) pingback logic + retry send move on client side --- app.js | 230 ++++++++++++++++++++++++++++------------------------- index.html | 7 +- server.js | 112 ++++++++++++-------------- 3 files changed, 175 insertions(+), 174 deletions(-) diff --git a/app.js b/app.js index a46ceb3..7f8dc27 100644 --- a/app.js +++ b/app.js @@ -4,11 +4,11 @@ let $ = document; //shortcut // Initialisations // https://stackoverflow.com/a/27747377/12660887 -function dec2hex (dec) { return dec.toString(16).padStart(2, "0") } function generateId (len) { - var arr = new Uint8Array((len || 40) / 2) - window.crypto.getRandomValues(arr) - return Array.from(arr, dec2hex).join('') + const dec2hex = (dec) => dec.toString(16).padStart(2, "0"); + let arr = new Uint8Array(len / 2); //len/2 because 2 chars per hex value + window.crypto.getRandomValues(arr); //fill with random integers + return Array.from(arr, dec2hex).join(''); } // Populate variants dropdown list @@ -37,25 +37,26 @@ const setActive = (active) => { if (active) formField.classList.add("form-field--is-active"); else { formField.classList.remove("form-field--is-active"); - inputName.value === "" ? - formField.classList.remove("form-field--is-filled") : - formField.classList.add("form-field--is-filled"); + inputName.value == '' + ? formField.classList.remove("form-field--is-filled") + : formField.classList.add("form-field--is-filled"); } }; +setActive(true); inputName.onblur = () => setActive(false); inputName.onfocus = () => setActive(true); -inputName.focus(); ///////// // Utils function setName() { + // 'onChange' event on name input text field [HTML] localStorage.setItem("name", $.getElementById("myName").value); } // Turn a "tab" on, and "close" all others function toggleVisible(element) { - for (elt of document.querySelectorAll('main > div')) { + for (elt of document.querySelectorAll("main > div")) { if (elt.id != element) elt.style.display = "none"; else elt.style.display = "block"; } @@ -67,32 +68,29 @@ function toggleVisible(element) { else { document.querySelector("html").style.overflow = "visible"; document.body.style.overflow = "visible"; - if (element == "newGame") { - // Workaround "superposed texts" effect - inputName.focus(); - inputName.blur(); - } + // Workaround "superposed texts" effect: + if (element == "newGame") setActive(false); } } let seek_vname; function seekGame() { seek_vname = $.getElementById("selectVariant").value; - send("seekgame", {vname: seek_vname, name: localStorage.getItem("name")}); - toggleVisible("pendingSeek"); + if (send("seekgame", + {vname: seek_vname, name: localStorage.getItem("name")}) + ) { + toggleVisible("pendingSeek"); + } } function cancelSeek() { - send("cancelseek", {vname: seek_vname}); - toggleVisible("newGame"); + if (send("cancelseek", {vname: seek_vname})) toggleVisible("newGame"); } function sendRematch() { - send("rematch", { gid: gid }); - toggleVisible("pendingRematch"); + if (send("rematch", {gid: gid})) toggleVisible("pendingRematch"); } function cancelRematch() { - send("norematch", { gid: gid }); - toggleVisible("newGame"); + if (send("norematch", {gid: gid})) toggleVisible("newGame"); } // Play with a friend (or not ^^) @@ -109,11 +107,14 @@ function showNewGameForm() { }); } } -function backToNormalSeek() { toggleVisible("newGame"); } +function backToNormalSeek() { + toggleVisible("newGame"); +} -function toggleStyle(e, word) { +function toggleStyle(event, obj) { + const word = obj.innerHTML; options[word] = !options[word]; - e.target.classList.toggle("highlight-word"); + event.target.classList.toggle("highlight-word"); } let options; @@ -157,8 +158,7 @@ function prepareOptions() { for (let j=i; j${style}`; + optHtml += `${style}`; } optHtml += ""; i += 4; @@ -181,12 +181,12 @@ function getGameLink() { } send("creategame", { vname: vname, - player: { sid: sid, name: localStorage.getItem("name"), color: color }, + player: {sid: sid, name: localStorage.getItem("name"), color: color}, options: options }); } -const fillGameInfos = (gameInfos, oppIndex) => { +function fillGameInfos(gameInfos, oppIndex) { fetch(`/variants/${gameInfos.vname}/rules.html`) .then(res => res.text()) .then(txt => { @@ -209,7 +209,7 @@ const fillGameInfos = (gameInfos, oppIndex) => { htmlContent += '' + (opt[1] === true ? opt[0] : `${opt[0]}:${opt[1]}`) + " " + - ''; + ""; } htmlContent += ""; i += 4; @@ -223,27 +223,62 @@ const fillGameInfos = (gameInfos, oppIndex) => { `; $.getElementById("gameInfos").innerHTML = htmlContent; }); -}; +} //////////////// // Communication -let socket, gid, attempt = 0; +let socket, gid, recoAttempt = 0; const autoReconnectDelay = () => { - return [100, 200, 500, 1000, 3000, 10000, 30000][Math.min(attempt, 6)]; + return [100, 200, 500, 1000, 3000, 10000, 30000][Math.min(recoAttempt, 6)]; }; -function copyClipboard(msg) { navigator.clipboard.writeText(msg); } +function send(code, data, opts) { + opts = opts || {}; + const trySend = () => { + if (socket.readyState == 1) { + socket.send(JSON.stringify(Object.assign({code: code}, data))); + if (opts.success) opts.success(); + return true; + } + return false; + }; + const firstTry = trySend(); + if (!firstTry) { + if (opts.retry) { + // Retry for a few seconds (sending move) + let sendAttempt = 1; + const retryLoop = setInterval( + () => { + if (trySend() || ++sendAttempt >= 3) clearInterval(retryLoop); + if (sendAttempt >= 3 && opts.error) opts.error(); + }, + 1000 + ); + } + else if (opt.error) opts.error(); + } + return firstTry; +} + +function copyClipboard(msg) { + navigator.clipboard.writeText(msg); +} function getWhatsApp(msg) { return `https://api.whatsapp.com/send?text=${encodeURIComponent(msg)}`; } const tryResumeGame = () => { - attempt = 0; + recoAttempt = 0; // If a game is found, resume it: if (localStorage.getItem("gid")) { gid = localStorage.getItem("gid"); - send("getgame", { gid: gid }); + send("getgame", + {gid: gid}, + { + retry: true, + error: () => alert("Cannot load game: no connection") + }); } else { // If URL indicates "play with a friend", start game: @@ -251,9 +286,14 @@ const tryResumeGame = () => { if (hashIdx >= 0) { const urlParts = $.URL.split('#'); gid = urlParts[1]; - send("joingame", { gid: gid, name: localStorage.getItem("name") }); localStorage.setItem("gid", gid); - history.replaceState(null, '', urlParts[0]); + history.replaceState(null, '', urlParts[0]); //hide game ID + send("joingame", + {gid: gid, name: localStorage.getItem("name")}, + { + retry: true, + error: () => alert("Cannot load game: no connection") + }); } } }; @@ -275,7 +315,7 @@ const messageCenter = (msg) => {

WhatsApp / - ToClipboard + ToClipboard

${link}

`; @@ -295,9 +335,8 @@ const messageCenter = (msg) => { break; // Receive opponent's move: case "newmove": - send("gotmove", {fen: obj.fen, gid: gid}); - if (obj.fen == lastFen) break; //got this move already - lastFen = obj.fen; + // Basic check: was it really opponent's turn? + if (vr.turn == playerColor) break; if (document.hidden) notifyMe("move"); vr.playReceivedMove(obj.moves, () => { if (vr.getCurrentScore(obj.moves[obj.moves.length-1]) != "*") { @@ -307,16 +346,6 @@ const messageCenter = (msg) => { else toggleTurnIndicator(true); }); break; - // The server notifies that it got our move: - case "gotmove": - if (obj.fen == lastFen) { - curMoves = []; - clearTimeout(timeout1); - clearTimeout(timeout2); - clearTimeout(timeout3); - callbackAfterConfirmation(); - } - break; // Opponent stopped game (draw, abort, resign...) case "gameover": toggleVisible("gameStopped"); @@ -330,7 +359,7 @@ const messageCenter = (msg) => { }; const handleError = (err) => { - if (err.code === 'ECONNREFUSED') { + if (err.code === "ECONNREFUSED") { removeAllListeners(); alert("Server refused connection. Please reload page later"); } @@ -344,28 +373,24 @@ const handleClose = () => { }, autoReconnectDelay()); }; -const removeAllListeners = () => { +function removeAllListeners() { socket.removeEventListener("open", tryResumeGame); socket.removeEventListener("message", messageCenter); socket.removeEventListener("error", handleError); socket.removeEventListener("close", handleClose); -}; +} -const connectToWSS = () => { +function connectToWSS() { socket = new WebSocket(`${Params.socket_server}${Params.socket_path}?sid=${sid}`); socket.addEventListener("open", tryResumeGame); socket.addEventListener("message", messageCenter); socket.addEventListener("error", handleError); socket.addEventListener("close", handleClose); - attempt++; -}; + recoAttempt++; +} connectToWSS(); -const send = (code, data) => { - socket.send(JSON.stringify(Object.assign({code: code}, data))); -}; - /////////// // Playing @@ -381,27 +406,27 @@ function notifyMe(code) { new Notification("New " + code, { vibrate: [200, 100, 200] }); new Audio("/assets/new_" + code + ".mp3").play(); } - if (Notification.permission === 'granted') doNotify(); - else if (Notification.permission !== 'denied') { + if (Notification.permission === "granted") doNotify(); + else if (Notification.permission !== "denied") { Notification.requestPermission().then(permission => { - if (permission === 'granted') doNotify(); + if (permission === "granted") doNotify(); }); } } let curMoves = [], - lastFen, lastMove, - timeout1, timeout2, timeout3; -const callbackAfterConfirmation = () => { - const result = vr.getCurrentScore(lastMove); - if (result != "*") { - setTimeout( () => { - toggleVisible("gameStopped"); - send("gameover", { gid: gid }); - }, 2000); - } -}; + lastFen; const afterPlay = (move) => { + const callbackAfterSend = () => { + curMoves = []; + const result = vr.getCurrentScore(move); + if (result != "*") { + setTimeout(() => { + toggleVisible("gameStopped"); + send("gameover", {gid: gid}); + }, 2000); + } + }; // Pack into one moves array, then send curMoves.push({ appear: move.appear, @@ -409,31 +434,15 @@ const afterPlay = (move) => { start: move.start, end: move.end }); - lastMove = move; if (vr.turn != playerColor) { toggleTurnIndicator(false); - lastFen = vr.getFen(); - const sendMove = - () => send("newmove", {gid: gid, moves: curMoves, fen: lastFen}); - // Send move until we obtain confirmation or timeout, then callback - sendMove(); - timeout1 = setTimeout(sendMove, 500); - timeout2 = setTimeout(sendMove, 1500); - timeout3 = setTimeout( - () => alert("The move may be lost :( Please reload"), - 3000); - } -}; - -// Avoid loading twice the same stylesheet: -const conditionalLoadCSS = (vname) => { - const allIds = [].slice.call($.styleSheets).map(s => s.id); - const newId = vname + "_css"; - if (!allIds.includes(newId)) { - $.getElementsByTagName("head")[0].insertAdjacentHTML( - "beforeend", - ``); + send("newmove", + {gid: gid, moves: curMoves, fen: vr.getFen()}, + { + retry: true, + success: callbackAfterSend, + error: () => alert("Move not sent: reload page") + }); } }; @@ -442,7 +451,15 @@ function initializeGame(obj) { const options = obj.options || {}; import(`/variants/${obj.vname}/class.js`).then(module => { window.V = module.default; - conditionalLoadCSS(obj.vname); + // Load CSS. Avoid loading twice the same stylesheet: + const allIds = [].slice.call($.styleSheets).map(s => s.id); + const newId = obj.vname + "_css"; + if (!allIds.includes(newId)) { + $.getElementsByTagName("head")[0].insertAdjacentHTML( + "beforeend", + ``); + } playerColor = (sid == obj.players[0].sid ? "w" : "b"); // Init + remove potential extra DOM elements from a previous game: document.getElementById("boardContainer").innerHTML = ` @@ -474,8 +491,8 @@ function initializeGame(obj) { options: options }); if (!obj.fen) { - // Game creation - if (playerColor == "w") send("setfen", {gid: obj.gid, fen: vr.getFen()}); + // Game creation: both players set FEN, in case of one is offline + send("setfen", {gid: obj.gid, fen: vr.getFen()}); localStorage.setItem("gid", obj.gid); } const select = $.getElementById("selectVariant"); @@ -494,8 +511,7 @@ function initializeGame(obj) { } function confirmStopGame() { - if (confirm("Stop game?")) { - send("gameover", { gid: gid, relay: true }); + if (confirm("Stop game?") && send("gameover", {gid: gid, relay: true})) { localStorage.removeItem("gid"); toggleVisible("gameStopped"); } @@ -504,11 +520,7 @@ function confirmStopGame() { function toggleGameInfos() { if ($.getElementById("gameInfos").style.display == "none") toggleVisible("gameInfos"); - else { - toggleVisible("boardContainer"); - // Quickfix for the "vanished piece" bug (move played while on game infos) - vr.setupPieces(); //TODO: understand better - } + else toggleVisible("boardContainer"); } $.body.addEventListener("keydown", (e) => { diff --git a/index.html b/index.html index 52acd9a..caab4e9 100644 --- a/index.html +++ b/index.html @@ -18,11 +18,11 @@

Game over

@@ -49,7 +49,7 @@
@@ -110,4 +110,5 @@ + diff --git a/server.js b/server.js index d8a2efa..7dbbaac 100644 --- a/server.js +++ b/server.js @@ -1,89 +1,75 @@ const params = require("./parameters.js"); -const WebSocket = require('ws'); +const WebSocket = require("ws"); const wss = new WebSocket.Server( {port: params.socket_port, path: params.socket_path}); let challenges = {}; //variantName --> socketId, name -let games = {}; //gameId --> gameInfo (vname, fen, players, options) +let games = {}; //gameId --> gameInfo (vname, fen, players, options, time) let sockets = {}; //socketId --> socket -let sendmoveTimeout1 = {}, - sendmoveTimeout2 = {}, - sendmoveRetry = {}, - stopRetry = {}; const variants = require("./variants.js"); - -const clearTrySendMove = (gid) => { - clearTimeout(sendmoveTimeout1[gid]); - clearTimeout(sendmoveTimeout2[gid]); - clearInterval(sendmoveRetry[gid]); - clearTimeout(stopRetry[gid]); -}; +const Crypto = require("crypto"); +const randstrSize = 8; const send = (sid, code, data) => { const socket = sockets[sid]; - // If a player delete local infos and then try to resume a game, + // If a player deletes local infos and then tries to resume a game, // sockets[oppSid] will probably not exist anymore: if (socket) socket.send(JSON.stringify(Object.assign({ code: code }, data))); -} - -const Crypto = require('crypto') -function randomString(size = 8) { - return Crypto.randomBytes(size).toString('hex').slice(0, size); -} +}; -wss.on('connection', function connection(socket, req) { +wss.on("connection", function connection(socket, req) { const sid = req.url.split("=")[1]; //...?sid=... sockets[sid] = socket; socket.isAlive = true; - socket.on('pong', () => socket.isAlive = true); + socket.on("pong", () => socket.isAlive = true); function launchGame(vname, players, options) { - const gid = randomString(8); + const gid = + Crypto.randomBytes(randstrSize).toString("hex").slice(0, randstrSize); games[gid] = { vname: vname, players: players.map(p => { return (!p ? null : {sid: p.sid, name: p.name}); }), - options: options + options: options, + time: Date.now() }; if (players.every(p => p)) { const gameInfo = Object.assign( // Provide seed so that both players initialize with same FEN {seed: Math.floor(Math.random() * 1984), gid: gid}, games[gid]); - for (let i of [0, 1]) { - send(players[i].sid, "gamestart", - Object.assign({randvar: players[i].randvar}, gameInfo)); + for (const p of players) { + send(p.sid, + "gamestart", + Object.assign({randvar: p.randvar}, gameInfo)); } } else { // Incomplete players array: do not start game yet send(sid, "gamecreated", {gid: gid}); - // If nobody joins within a minute, delete game + // If nobody joins within 5 minutes, delete game setTimeout( () => { if (games[gid] && games[gid].players.some(p => !p)) delete games[gid]; }, - 60000 + 5 * 60000 ); } } - socket.on('message', (msg) => { + socket.on("message", (msg) => { const obj = JSON.parse(msg); switch (obj.code) { // Send challenge (may trigger game creation) case "seekgame": { - // Only one challenge per player: - if (Object.keys(challenges).some(k => challenges[k].sid == sid)) - return; let opponent = undefined, choice = undefined; const vname = obj.vname, randvar = (obj.vname == "_random"); if (vname == "_random") { - // Pick any current challenge if any + // Pick any current challenge if possible const currentChalls = Object.keys(challenges); if (currentChalls.length >= 1) { choice = @@ -115,7 +101,7 @@ wss.on('connection', function connection(socket, req) { challenges[vname] = {sid: sid, name: obj.name, randvar: randvar}; break; } - // Set FEN after game was created + // Set FEN after game was created (received twice) case "setfen": games[obj.gid].fen = obj.fen; break; @@ -154,7 +140,7 @@ wss.on('connection', function connection(socket, req) { // Create game vs. friend case "creategame": { let players = [ - { sid: obj.player.sid, name: obj.player.name }, + {sid: obj.player.sid, name: obj.player.name}, undefined ]; if ( @@ -178,40 +164,28 @@ wss.on('connection', function connection(socket, req) { // Provide seed so that both players initialize with same FEN {seed: Math.floor(Math.random()*1984), gid: obj.gid}, games[obj.gid]); - for (let i of [0, 1]) - send(games[obj.gid].players[i].sid, "gamestart", gameInfo); + for (const p of games[obj.gid].players) + send(p.sid, "gamestart", gameInfo); } break; // Relay a move + update games object case "newmove": - // If already received this move: skip - if (games[obj.gid].fen == obj.fen) break; - // Notify sender that the move is received: - send(sid, "gotmove", {fen: obj.fen}); games[obj.gid].fen = obj.fen; + games[obj.gid].time = Date.now(); //update timestamp in case of const playingWhite = (games[obj.gid].players[0].sid == sid); const oppSid = games[obj.gid].players[playingWhite ? 1 : 0].sid; - const sendMove = - // NOTE: sending FEN also, to check it in "gotmove" below - () => send(oppSid, "newmove", {moves: obj.moves, fen: obj.fen}); - sendMove(); - sendmoveTimeout1[obj.gid] = setTimeout(sendMove, 500); - sendmoveTimeout2[obj.gid] = setTimeout(sendMove, 1500); - sendmoveRetry[obj.gid] = setInterval(sendMove, 5000); - stopRetry[obj.gid] = setTimeout(clearTrySendMove, 31000); - break; - case "gotmove": - if (games[obj.gid].fen == obj.fen) clearTrySendMove(obj.gid); + send(oppSid, "newmove", {moves: obj.moves}); break; // Relay "game ends" message - case "gameover": { - const playingWhite = (games[obj.gid].players[0].sid == sid); - const oppSid = games[obj.gid].players[playingWhite ? 1 : 0].sid; - if (obj.relay) send(oppSid, "gameover", { gid: obj.gid }); - games[obj.gid].over = true; - setTimeout( () => delete games[obj.gid], 60000 ); + case "gameover": + if (obj.relay) { + const playingWhite = (games[obj.gid].players[0].sid == sid); + const oppSid = games[obj.gid].players[playingWhite ? 1 : 0].sid; + send(oppSid, "gameover", { gid: obj.gid }); + } + // 2 minutes timeout for rematch: + setTimeout(() => delete games[obj.gid], 2 * 60000); break; - } } }); socket.on("close", () => { @@ -225,11 +199,25 @@ wss.on('connection', function connection(socket, req) { }); }); -const interval = setInterval(() => { +const heartbeat = setInterval(() => { wss.clients.forEach((ws) => { if (ws.isAlive === false) return ws.terminate(); ws.isAlive = false; ws.ping(); }); }, 30000); -wss.on('close', () => clearInterval(interval)); + +// Every 24 hours, scan games and remove if last move older than 24h +const dayInMillisecs = 24 * 60 * 60 * 1000; +const killOldGames = setInterval(() => { + const now = Date.now(); + Object.keys(games).forEach(gid => { + if (now - games[gid].time >= dayInMillisecs) delete games[gid]; + }); +}, dayInMillisecs); + +// TODO: useful code here? +wss.on("close", () => { + clearInterval(heartbeat); + clearInterval(killOldGames); +}); -- 2.44.0