From 714680114508183fba2c07231dbe8f90b5631b81 Mon Sep 17 00:00:00 2001 From: Benjamin Auder <benjamin.auder@somewhere> Date: Tue, 11 Feb 2020 17:20:26 +0100 Subject: [PATCH] Experimental multi-tabs support (TODO: prevent multi-connect) --- client/src/components/ChallengeList.vue | 2 +- client/src/components/GameList.vue | 3 +- client/src/data/challengeCheck.js | 2 +- client/src/router.js | 2 +- client/src/translations/en.js | 7 +- client/src/translations/es.js | 7 +- client/src/translations/fr.js | 7 +- client/src/utils/gameStorage.js | 2 +- client/src/utils/timeControl.js | 4 +- client/src/views/Game.vue | 234 +++++------ client/src/views/Hall.vue | 494 +++++++++++------------- client/src/views/MyGames.vue | 2 +- server/db/create.sql | 6 +- server/models/Challenge.js | 10 +- server/models/Game.js | 32 +- server/routes/challenges.js | 2 +- server/routes/games.js | 4 +- server/sockets.js | 289 ++++++-------- 18 files changed, 498 insertions(+), 611 deletions(-) diff --git a/client/src/components/ChallengeList.vue b/client/src/components/ChallengeList.vue index f94cb71e..95a71ac2 100644 --- a/client/src/components/ChallengeList.vue +++ b/client/src/components/ChallengeList.vue @@ -12,7 +12,7 @@ div td(data-label="Variant") {{ c.vname }} td(data-label="From") {{ c.from.name || "@nonymous" }} td(data-label="To") {{ c.to }} - td(data-label="Cadence") {{ c.timeControl }} + td(data-label="Cadence") {{ c.cadence }} </template> <script> diff --git a/client/src/components/GameList.vue b/client/src/components/GameList.vue index 3620d329..24359302 100644 --- a/client/src/components/GameList.vue +++ b/client/src/components/GameList.vue @@ -14,7 +14,7 @@ div td(data-label="Variant") {{ g.vname }} td(data-label="White") {{ g.players[0].name || "@nonymous" }} td(data-label="Black") {{ g.players[1].name || "@nonymous" }} - td(data-label="Time control") {{ g.timeControl }} + td(data-label="Time control") {{ g.cadence }} td(data-label="Result") {{ g.score }} </template> @@ -30,6 +30,7 @@ export default { }; }, computed: { + // TODO: also sort by g.created sortedGames: function() { // Show in order: games where it's my turn, my running games, my games, other games let augmentedGames = this.games.map(g => { diff --git a/client/src/data/challengeCheck.js b/client/src/data/challengeCheck.js index db205ac7..626b7cf6 100644 --- a/client/src/data/challengeCheck.js +++ b/client/src/data/challengeCheck.js @@ -6,7 +6,7 @@ export function checkChallenge(c) if (isNaN(vid) || vid <= 0) return "Please select a variant"; - const tc = extractTime(c.timeControl); + const tc = extractTime(c.cadence); if (!tc) return "Wrong time control"; diff --git a/client/src/router.js b/client/src/router.js index b2efee78..ede2186b 100644 --- a/client/src/router.js +++ b/client/src/router.js @@ -44,7 +44,7 @@ const router = new Router({ component: loadView("MyGames"), }, { - path: "/game/:id", + path: "/game/:id([a-zA-Z0-9]+)", name: "game", component: loadView("Game"), }, diff --git a/client/src/translations/en.js b/client/src/translations/en.js index 37b98a4c..e59723ea 100644 --- a/client/src/translations/en.js +++ b/client/src/translations/en.js @@ -8,7 +8,6 @@ export const translations = "Analyse in Dark mode makes no sense!": "Analyse in Dark mode makes no sense!", "Authentication successful!": "Authentication successful!", "Apply": "Apply", - "Available": "Available", "Black": "Black", "Black to move": "Black to move", "Black win": "Black win", @@ -17,6 +16,7 @@ export const translations = "blue": "blue", "brown": "brown", "Cadence": "Cadence", + "Challenge": "Challenge", "Challenge declined": "Challenge declined", "Connection token sent. Check your emails!": "Connection token sent. Check your emails!", "Contact": "Contact", @@ -52,15 +52,16 @@ export const translations = "My games": "My games", "Name": "Name", "Name or Email": "Name or Email", + "New correspondance game:": "New correspondance game:", "New game": "New game", "No subject. Send anyway?": "No subject. Send anyway?", "None": "None", "Notifications by email": "Notifications by email", + "Observe": "Observe", "Offer draw?": "Offer draw?", "Opponent action": "Opponent action", "Play sounds?": "Play sounds?", "Play with?": "Play with?", - "Playing": "Playing", "Please log in to accept corr challenges": "Please log in to accept corr challenges", "Please log in to play correspondance games": "Please log in to play correspondance games", "Please select a variant": "Please select a variant", @@ -80,9 +81,7 @@ export const translations = "Settings": "Settings", "Stop game": "Stop game", "Subject": "Subject", - "Target is not connected": "Target is not connected", "Terminate game?": "Terminate game?", - "This tab is now offline": "This tab is now offline", "Three repetitions": "Three repetitions", "Time": "Time", "To": "To", diff --git a/client/src/translations/es.js b/client/src/translations/es.js index 34fd2bcc..a2232ebe 100644 --- a/client/src/translations/es.js +++ b/client/src/translations/es.js @@ -8,7 +8,6 @@ export const translations = "Analyse in Dark mode makes no sense!": "¡ Analizar en modo Dark no tiene sentido !", "Apply": "Aplicar", "Authentication successful!": "¡ Autenticación exitosa !", - "Available": "Disponible", "Black": "Negras", "Black to move": "Juegan las negras", "Black win": "Las negras gagnan", @@ -17,6 +16,7 @@ export const translations = "blue": "azul", "brown": "marrón", "Cadence": "Cadencia", + "Challenge": "Desafiar", "Challenge declined": "DesafÃo rechazado", "Connection token sent. Check your emails!": "Token de conexión enviado. ¡ Revisa tus correos !", "Contact": "Contacto", @@ -52,15 +52,16 @@ export const translations = "My games": "Mis partidas", "Name": "Nombre", "Name or Email": "Nombre o Email", + "New correspondance game:": "Nueva partida por correspondencia :", "New game": "Nueva partida", "No subject. Send anyway?": "Sin asunto. ¿ Enviar sin embargo ?", "None": "Ninguno", "Notifications by email": "Notificaciones por email", "Offer draw?": "¿ Ofrecer tablas ?", + "Observe": "Observar", "Opponent action": "Acción del adversario", "Play sounds?": "¿ Permitir sonidos ?", "Play with?": "¿ Jugar con ?", - "Playing": "Jugando", "Please log in to accept corr challenges": "Inicia sesión para aceptar los desafÃos por correspondencia", "Please log in to play correspondance games": "Inicia sesión para jugar partidas por correspondancia", "Please select a variant": "Por favor seleccione una variante", @@ -80,9 +81,7 @@ export const translations = "Settings": "Configuraciones", "Stop game": "Terminar la partida", "Subject": "Asunto", - "Target is not connected": "El destinatario no está conectado", "Terminate game?": "¿ Terminar la partida ?", - "This tab is now offline": "Esta pestaña ahora está desconectada", "Three repetitions": "Tres repeticiones", "Time": "Tiempo", "To": "A", diff --git a/client/src/translations/fr.js b/client/src/translations/fr.js index 26b1c83d..7fedc139 100644 --- a/client/src/translations/fr.js +++ b/client/src/translations/fr.js @@ -8,7 +8,6 @@ export const translations = "Analyse in Dark mode makes no sense!": "Analyser en mode Dark n'a pas de sens !", "Apply": "Appliquer", "Authentication successful!": "Authentification réussie !", - "Available": "Disponible", "Black": "Noirs", "Black to move": "Trait aux noirs", "Black win": "Les noirs gagnent", @@ -17,6 +16,7 @@ export const translations = "blue": "bleu", "brown": "marron", "Cadence": "Cadence", + "Challenge": "Défier", "Challenge declined": "Défi refusé", "Connection token sent. Check your emails!": "Token de connection envoyé. Allez voir vos emails !", "Contact": "Contact", @@ -52,15 +52,16 @@ export const translations = "My games": "Mes parties", "Name": "Nom", "Name or Email": "Nom ou Email", + "New correspondance game:": "Nouvelle partie par corespondance :", "New game": "Nouvelle partie", "No subject. Send anyway?": "Pas de sujet. Envoyer quand-même ??", "None": "Aucun", "Notifications by email": "Notifications par email", "Offer draw?": "Proposer nulle ?", + "Observe": "Observer", "Opponent action": "Action de l'adversaire", "Play sounds?": "Jouer les sons ?", "Play with?": "Jouer avec ?", - "Playing": "Jouant", "Please log in to accept corr challenges": "Identifiez vous pour accepter des défis par correspondance", "Please log in to play correspondance games": "Identifiez vous pour jouer des parties par correspondance", "Please select a variant": "Sélectionnez une variante SVP", @@ -80,9 +81,7 @@ export const translations = "Settings": "Réglages", "Stop game": "Arrêter la partie", "Subject": "Sujet", - "Target is not connected": "La cible n'est pas connectée", "Terminate game?": "Stopper la partie ?", - "This tab is now offline": "Cet onglet est désormais hors ligne", "Three repetitions": "Triple répétition", "Time": "Temps", "To": "Ã", diff --git a/client/src/utils/gameStorage.js b/client/src/utils/gameStorage.js index 05aaef74..f995828d 100644 --- a/client/src/utils/gameStorage.js +++ b/client/src/utils/gameStorage.js @@ -4,7 +4,7 @@ // vname: string, // fenStart: string, // players: array of sid+id+name, -// timeControl: string, +// cadence: string, // increment: integer (seconds), // mode: string ("live" or "corr") // imported: boolean (optional, default false) diff --git a/client/src/utils/timeControl.js b/client/src/utils/timeControl.js index 3f5a6506..6d47aac8 100644 --- a/client/src/utils/timeControl.js +++ b/client/src/utils/timeControl.js @@ -20,9 +20,9 @@ function isLargerUnit(unit1, unit2) || (unit1 == 'm' && unit2 == 's'); } -export function extractTime(timeControl) +export function extractTime(cadence) { - let tcParts = timeControl.replace(/ /g,"").split('+'); + let tcParts = cadence.replace(/ /g,"").split('+'); // Concatenate usual time control suffixes, in case of none is provided tcParts[0] += "m"; tcParts[1] += "s"; diff --git a/client/src/views/Game.vue b/client/src/views/Game.vue index 177f9eb3..a7d26d7e 100644 --- a/client/src/views/Game.vue +++ b/client/src/views/Game.vue @@ -45,7 +45,6 @@ import { ArrayFun } from "@/utils/array"; import { processModalClick } from "@/utils/modalClick"; import { getScoreMessage } from "@/utils/scoring"; import params from "@/parameters"; - export default { name: 'my-game', components: { @@ -72,8 +71,6 @@ export default { repeat: {}, //detect position repetition newChat: "", conn: null, - page: "", - tempId: "", //to distinguish several tabs }; }, watch: { @@ -111,7 +108,7 @@ export default { }, 1000); }, }, - // NOTE: some redundant code with Hall.vue (related to people array) + // NOTE: some redundant code with Hall.vue (mostly related to people array) created: function() { // Always add myself to players' list const my = this.st.user; @@ -119,11 +116,10 @@ export default { this.gameRef.id = this.$route.params["id"]; this.gameRef.rid = this.$route.query["rid"]; //may be undefined // Initialize connection - this.page = this.$route.path; const connexionString = params.socketUrl + "/?sid=" + this.st.user.sid + - "&tmpId=" + this.tempId + - "&page=" + encodeURIComponent(this.page); + "&tmpId=" + getRandString() + + "&page=" + encodeURIComponent(this.$route.path); this.conn = new WebSocket(connexionString); this.conn.onmessage = this.socketMessageListener; const socketCloseListener = () => { @@ -154,15 +150,22 @@ export default { "click", processModalClick); }, beforeDestroy: function() { - this.conn.send(JSON.stringify({code:"disconnect",page:this.page})); + this.send("disconnect"); }, methods: { - // O.1] Ask server for room composition: roomInit: function() { // Notify the room only now that I connected, because // messages might be lost otherwise (if game loading is slow) - this.conn.send(JSON.stringify({code:"connect"})); - this.conn.send(JSON.stringify({code:"pollclients"})); + this.send("connect"); + this.send("pollclients"); + }, + send: function(code, obj) { + this.conn.send(JSON.stringify( + Object.assign( + {code: code}, + obj, + ) + )); }, isConnected: function(index) { const player = this.game.players[index]; @@ -177,46 +180,69 @@ export default { const data = JSON.parse(msg.data); switch (data.code) { - case "duplicate": - this.conn.send(JSON.stringify({code:"duplicate", - page:"/game/" + this.game.id})); - alert(this.st.tr["This tab is now offline"]); - break; - // 0.2] Receive clients list (just socket IDs) case "pollclients": data.sockIds.forEach(sid => { - if (!!this.people[sid]) - return; this.$set(this.people, sid, {id:0, name:""}); - // Ask only identity - this.conn.send(JSON.stringify({code:"askidentity", target:sid})); + if (sid != this.st.user.sid) + this.send("askidentity", {target:sid}); }); break; + case "connect": + this.$set(this.people, data.from, {name:"", id:0}); + this.send("askidentity", {target:data.from}); + break; + case "disconnect": + this.$delete(this.people, data.from); + break; case "askidentity": // Request for identification: reply if I'm not anonymous if (this.st.user.id > 0) { - this.conn.send(JSON.stringify({code:"identity", - user: { - // NOTE: decompose to avoid revealing email - name: this.st.user.name, - sid: this.st.user.sid, - id: this.st.user.id, - }, - target:data.from})); + const me = { + // NOTE: decompose to avoid revealing email + name: this.st.user.name, + sid: this.st.user.sid, + id: this.st.user.id, + }; + this.send("identity", {data:me, target:data.from}); } break; case "identity": - this.$set(this.people, data.user.sid, - {id: data.user.id, name: data.user.name}); + { + const user = data.data; + this.$set(this.people, user.sid, {id: user.id, name: user.name}); // Ask potentially missed last state, if opponent and I play if (!!this.game.mycolor && this.game.type == "live" && this.game.score == "*" - && this.game.players.some(p => p.sid == data.user.sid)) + && this.game.players.some(p => p.sid == user.sid)) { - this.conn.send(JSON.stringify({code:"asklastate", target:data.user.sid})); + this.send("asklastate", {target:user.sid}); } break; + } + case "askgame": + // Send current (live) game if not asked by any of the players + if (this.game.type == "live" + && this.game.players.every(p => p.sid != data.from[0])) + { + const myGame = { + id: this.game.id, + fen: this.game.fen, + players: this.game.players, + vid: this.game.vid, + cadence: this.game.cadence, + score: this.game.score, + }; + this.send("game", {data:myGame, target:data.from}); + } + break; + case "askfullgame": + this.send("fullgame", {data:this.game, target:data.from}); + break; + case "fullgame": + // Callback "roomInit" to poll clients only after game is loaded + this.loadGame(data.data, this.roomInit); + break; case "asklastate": // Sending last state if I played a move or score != "*" if ((this.game.moves.length > 0 && this.vr.turn != this.game.mycolor) @@ -225,64 +251,38 @@ export default { // Send our "last state" informations to opponent const L = this.game.moves.length; const myIdx = ["w","b"].indexOf(this.game.mycolor); - this.conn.send(JSON.stringify({ - code: "lastate", - target: data.from, - state: - { - // NOTE: lastMove (when defined) includes addTime - lastMove: (L>0 ? this.game.moves[L-1] : undefined), - // Since we played a move (or abort or resign), - // only drawOffer=="sent" is possible - drawSent: this.drawOffer == "sent", - score: this.game.score, - movesCount: L, - initime: this.game.initime[1-myIdx], //relevant only if I played - } - })); - } - break; - case "askgame": - // Send current (live) game if I play in (not an observer), - // and not asked by opponent (!) - if (this.game.type == "live" - && this.game.players.some(p => p.sid == this.st.user.sid) - && this.game.players.every(p => p.sid != data.from)) - { - const myGame = - { - // Minimal game informations: - id: this.game.id, - players: this.game.players, - vid: this.game.vid, - timeControl: this.game.timeControl, + const myLastate = { + // NOTE: lastMove (when defined) includes addTime + lastMove: (L>0 ? this.game.moves[L-1] : undefined), + // Since we played a move (or abort or resign), + // only drawOffer=="sent" is possible + drawSent: this.drawOffer == "sent", score: this.game.score, + movesCount: L, + initime: this.game.initime[1-myIdx], //relevant only if I played }; - this.conn.send(JSON.stringify({code:"game", - game:myGame, target:data.from})); + this.send("lastate", {data:myLastate, target:data.from}); } break; + case "lastate": //got opponent infos about last move + this.lastate = data.data; + if (this.game.rendered) //game is rendered (Board component) + this.processLastate(); + //else: will be processed when game is ready + break; case "newmove": - if (!!data.move.cancelDrawOffer) //opponent refuses draw + { + const move = data.data; + if (!!move.cancelDrawOffer) //opponent refuses draw { this.drawOffer = ""; // NOTE for corr games: drawOffer reset by player in turn if (this.game.type == "live" && !!this.game.mycolor) GameStorage.update(this.gameRef.id, {drawOffer: ""}); } - this.$set(this.game, "moveToPlay", data.move); - break; - case "newchat": - this.newChat = data.chat; - if (!document.getElementById("modalChat").checked) - document.getElementById("chatBtn").style.backgroundColor = "#c5fefe"; - break; - case "lastate": //got opponent infos about last move - this.lastate = data.state; - if (this.game.rendered) //game is rendered (Board component) - this.processLastate(); - //else: will be processed when game is ready + this.$set(this.game, "moveToPlay", move); break; + } case "resign": this.gameOver(data.side=="b" ? "1-0" : "0-1", "Resign"); break; @@ -290,27 +290,20 @@ export default { this.gameOver("?", "Abort"); break; case "draw": - this.gameOver("1/2", data.message); + this.gameOver("1/2", data.data); break; case "drawoffer": // NOTE: observers don't know who offered draw this.drawOffer = "received"; break; - case "askfullgame": - this.conn.send(JSON.stringify({code:"fullgame", - game:this.game, target:data.from})); - break; - case "fullgame": - // Callback "roomInit" to poll clients only after game is loaded - this.loadGame(data.game, this.roomInit); - break; - case "connect": - this.$set(this.people, data.from, {name:"", id:0}); - this.conn.send(JSON.stringify({code:"askidentity", target:data.from})); - break; - case "disconnect": - this.$delete(this.people, data.from); + case "newchat": + { + const chat = data.data; + this.newChat = chat; + if (!document.getElementById("modalChat").checked) + document.getElementById("chatBtn").style.backgroundColor = "#c5fefe"; break; + } } }, // lastate was received, but maybe game wasn't ready yet: @@ -321,7 +314,7 @@ export default { if (data.movesCount > L) { // Just got last move from him - this.$set(this.game, "moveToPlay", Object.assign({}, data.lastMove, {initime: data.initime})); + this.$set(this.game, "moveToPlay", Object.assign({initime: data.initime}, data.lastMove)); } if (data.drawSent) this.drawOffer = "received"; @@ -342,13 +335,7 @@ export default { const message = (this.drawOffer == "received" ? "Mutual agreement" : "Three repetitions"); - Object.keys(this.people).forEach(sid => { - if (sid != this.st.user.sid) - { - this.conn.send(JSON.stringify({code:"draw", - message:message, target:sid})); - } - }); + this.send("draw", {data:message}); this.gameOver("1/2", message); } else if (this.drawOffer == "") //no effect if drawOffer == "sent" @@ -358,10 +345,7 @@ export default { if (!confirm(this.st.tr["Offer draw?"])) return; this.drawOffer = "sent"; - Object.keys(this.people).forEach(sid => { - if (sid != this.st.user.sid) - this.conn.send(JSON.stringify({code:"drawoffer", target:sid})); - }); + this.send("drawoffer"); GameStorage.update(this.gameRef.id, {drawOffer: this.game.mycolor}); } }, @@ -369,26 +353,12 @@ export default { if (!this.game.mycolor || !confirm(this.st.tr["Terminate game?"])) return; this.gameOver("?", "Abort"); - Object.keys(this.people).forEach(sid => { - if (sid != this.st.user.sid) - { - this.conn.send(JSON.stringify({ - code: "abort", - target: sid, - })); - } - }); + this.send("abort"); }, resign: function(e) { if (!this.game.mycolor || !confirm(this.st.tr["Resign the game?"])) return; - Object.keys(this.people).forEach(sid => { - if (sid != this.st.user.sid) - { - this.conn.send(JSON.stringify({code:"resign", - side:this.game.mycolor, target:sid})); - } - }); + this.send("resign", {data:this.game.mycolor}); this.gameOver(this.game.mycolor=="w" ? "0-1" : "1-0", "Resign"); }, // 3 cases for loading a game: @@ -400,8 +370,8 @@ export default { const vModule = await import("@/variants/" + game.vname + ".js"); window.V = vModule.VariantRules; this.vr = new V(game.fen); - const gtype = (game.timeControl.indexOf('d') >= 0 ? "corr" : "live"); - const tc = extractTime(game.timeControl); + const gtype = (game.cadence.indexOf('d') >= 0 ? "corr" : "live"); + const tc = extractTime(game.cadence); if (gtype == "corr") { if (game.players[0].color == "b") @@ -527,8 +497,7 @@ export default { if (!!this.gameRef.rid) { // Remote live game: forgetting about callback func... (TODO: design) - this.conn.send(JSON.stringify( - {code:"askfullgame", target:this.gameRef.rid})); + this.send("askfullgame", {target:this.gameRef.rid}); } else { @@ -571,16 +540,7 @@ export default { addTime: addTime, cancelDrawOffer: this.drawOffer=="", }); - Object.keys(this.people).forEach(sid => { - if (sid != this.st.user.sid) - { - this.conn.send(JSON.stringify({ - code: "newmove", - target: sid, - move: sendMove, - })); - } - }); + this.send("newmove", {data: sendMove}); // (Add)Time indication: useful in case of lastate infos requested move.addTime = addTime; } @@ -654,7 +614,7 @@ export default { document.getElementById("chatBtn").style.backgroundColor = "#e2e2e2"; }, processChat: function(chat) { - this.conn.send(JSON.stringify({code:"newchat", chat:chat})); + this.send("newchat", {data:chat}); // NOTE: anonymous chats in corr games are not stored on server (TODO?) if (this.game.type == "corr" && this.st.user.id > 0) GameStorage.update(this.gameRef.id, {chat: chat}); diff --git a/client/src/views/Hall.vue b/client/src/views/Hall.vue index e53dd56a..ab92925f 100644 --- a/client/src/views/Hall.vue +++ b/client/src/views/Hall.vue @@ -18,12 +18,12 @@ main :selected="newchallenge.vid==v.id") | {{ v.name }} fieldset - label(for="timeControl") {{ st.tr["Cadence"] }} * - div#predefinedTimeControls + label(for="cadence") {{ st.tr["Cadence"] }} * + div#predefinedCadences button 3+2 button 5+3 button 15+5 - input#timeControl(type="text" v-model="newchallenge.timeControl" + input#cadence(type="text" v-model="newchallenge.cadence" placeholder="5+0, 1h+30s, 7d+1d ...") fieldset(v-if="st.user.id > 0") label(for="selectPlayers") {{ st.tr["Play with?"] }} @@ -52,11 +52,12 @@ main #players p(v-for="sid in Object.keys(people)" v-if="!!people[sid].name") span {{ people[sid].name }} + // Check: anonymous players cannot send individual challenges or be challenged individually button.player-action( - v-if="people[sid].name != st.user.name" - @click="challOrWatch(sid, $event)" + v-if="sid != st.user.sid && !!st.user.name && people[sid].id > 0" + @click="challOrWatch(sid)" ) - | {{ st.tr[!!people[sid].gamer ? 'Playing' : 'Available'] }} + | {{ getActionLabel(sid) }} p.anonymous @nonymous ({{ anonymousCount }}) #chat Chat(:newChat="newChat" @mychat="processChat") @@ -85,7 +86,6 @@ import GameList from "@/components/GameList.vue"; import ChallengeList from "@/components/ChallengeList.vue"; import { GameStorage } from "@/utils/gameStorage"; import { processModalClick } from "@/utils/modalClick"; - export default { name: "my-hall", components: { @@ -97,35 +97,28 @@ export default { return { st: store.state, cdisplay: "live", //or corr - pdisplay: "players", //or chat gdisplay: "live", games: [], challenges: [], - people: {}, //people in main hall + people: {}, infoMessage: "", newchallenge: { fen: "", vid: localStorage.getItem("vid") || "", to: "", //name of challenged player (if any) - timeControl: localStorage.getItem("timeControl") || "", + cadence: localStorage.getItem("cadence") || "", }, newChat: "", conn: null, - page: "", - tempId: "", //to distinguish several tabs }; }, watch: { // st.variants changes only once, at loading from [] to [...] "st.variants": function(variantArray) { // Set potential challenges and games variant names: - this.challenges.forEach(c => { - if (c.vname == "") - c.vname = this.getVname(c.vid); - }); - this.games.forEach(g => { - if (g.vname == "") - g.vname = this.getVname(g.vid); + this.challenges.concat(this.games).forEach(o => { + if (o.vname == "") + o.vname = this.getVname(o.vid); }); }, }, @@ -137,10 +130,8 @@ export default { }, }, created: function() { - // Always add myself to players' list const my = this.st.user; - this.tempId = getRandString(); - this.$set(this.people, my.sid, {id:my.id, name:my.name, tmpId: [this.tempId]}); + this.$set(this.people, my.sid, {id:my.id, name:my.name, pages:["/"]}); // Ask server for current corr games (all but mines) ajax( "/games", @@ -202,20 +193,17 @@ export default { addChallenges(); } ); - // 0.1] Ask server for room composition: - const funcPollClients = () => { - this.conn.send(JSON.stringify({code:"connect"})); - this.conn.send(JSON.stringify({code:"pollclients"})); - this.conn.send(JSON.stringify({code:"pollgamers"})); + const connectAndPoll = () => { + this.send("connect"); + this.send("pollclientsandgamers"); }; // Initialize connection - this.page = this.$route.path; const connexionString = params.socketUrl + "/?sid=" + this.st.user.sid + - "&tmpId=" + this.tempId + - "&page=" + encodeURIComponent(this.page); + "&tmpId=" + getRandString() + + "&page=" + encodeURIComponent(this.$route.path); this.conn = new WebSocket(connexionString); - this.conn.onopen = funcPollClients; + this.conn.onopen = connectAndPoll; this.conn.onmessage = this.socketMessageListener; const socketCloseListener = () => { this.conn = new WebSocket(connexionString); @@ -227,17 +215,30 @@ export default { mounted: function() { [document.getElementById("infoDiv"),document.getElementById("newgameDiv")] .forEach(elt => elt.addEventListener("click", processModalClick)); - document.querySelectorAll("#predefinedTimeControls > button").forEach( + document.querySelectorAll("#predefinedCadences > button").forEach( (b) => { b.addEventListener("click", - () => { this.newchallenge.timeControl = b.innerHTML; } + () => { this.newchallenge.cadence = b.innerHTML; } )} ); }, beforeDestroy: function() { - this.conn.send(JSON.stringify({code:"disconnect",page:this.page})); + this.send("disconnect"); }, methods: { // Helpers: + send: function(code, obj) { + this.conn.send(JSON.stringify( + Object.assign( + {code: code}, + obj, + ) + )); + }, + getVname: function(vid) { + const variant = this.st.variants.find(v => v.id == vid); + // this.st.variants might be uninitialized (variant == null) + return (!!variant ? variant.name : ""); + }, filterChallenges: function(type) { return this.challenges.filter(c => c.type == type); }, @@ -245,15 +246,7 @@ export default { return this.games.filter(g => g.type == type); }, classifyObject: function(o) { //challenge or game - return (o.timeControl.indexOf('d') === -1 ? "live" : "corr"); - }, - showGame: function(g) { - // NOTE: we are an observer, since only games I don't play are shown here - // ==> Moves sent by connected remote player(s) if live game - let url = "/game/" + g.id; - if (g.type == "live") - url += "?rid=" + g.rid; - this.$router.push(url); + return (o.cadence.indexOf('d') === -1 ? "live" : "corr"); }, setDisplay: function(letter, type, e) { this[letter + "display"] = type; @@ -263,105 +256,150 @@ export default { else e.target.nextElementSibling.classList.remove("active"); }, - getVname: function(vid) { - const variant = this.st.variants.find(v => v.id == vid); - // this.st.variants might be uninitialized (variant == null) - return (!!variant ? variant.name : ""); - }, - processChat: function(chat) { - // When received on server, this will trigger a "notifyRoom" - this.conn.send(JSON.stringify({code:"newchat", chat: chat})); + getActionLabel: function(sid) { + return this.people[sid].pages.some(p => p == "/") + ? "Challenge" + : "Observe"; }, - sendSomethingTo: function(to, code, obj, warnDisconnected) { - const doSend = (code, obj, sid) => { - this.conn.send(JSON.stringify(Object.assign( - {code: code}, - obj, - {target: sid} - ))); - }; - if (!to || (!to.sid && !to.name)) + challOrWatch: function(sid) { + if (this.people[sid].pages.some(p => p == "/")) { - // Open challenge: send to all connected players (me excepted) - Object.keys(this.people).forEach(sid => { - if (sid != this.st.user.sid) - doSend(code, obj, sid); - }); + // Available, in Hall + this.newchallenge.to = this.people[sid].name; + doClick("modalNewgame"); } else { - let targetSid = ""; - if (!!to.sid) - targetSid = to.sid; - else + // In some game, maybe playing maybe not + const gid = this.people[sid].page.match(/[a-zA-Z0-9]+$/)[0]; + this.showGame(this.games.find(g => g.id == gid)), sid; + } + }, + showGame: function(g, obsId) { + // NOTE: we are an observer, since only games I don't play are shown here + // ==> Moves sent by connected remote player(s) if live game + let url = "/game/" + g.id; + if (g.type == "live") + { + let rids = []; + for (let i of [0,1]) { - if (to.name == this.st.user.name) - return alert(this.st.tr["Cannot challenge self"]); - // Challenge with targeted players - targetSid = - Object.keys(this.people).find(sid => this.people[sid].name == to.name); - if (!targetSid) - { - if (!!warnDisconnected) - alert(this.st.tr["Target is not connected"]); - return false; - } + if (this.people[g.players[i].sid].pages.indexOf(url) >= 0) + rids.push(g.players[i].sid); } - doSend(code, obj, targetSid); + if (!!obsId) + rids.push(obsId); //observer can provide game too + const ridIdx = Math.floor(Math.random() * rids.length); + url += "?rid=" + rids[ridIdx]; } - return true; + this.$router.push(url); + }, + processChat: function(chat) { + this.send("newchat", {data:chat}); }, // Messaging center: socketMessageListener: function(msg) { const data = JSON.parse(msg.data); switch (data.code) { - case "duplicate": - this.conn.send(JSON.stringify({code:"duplicate", page:"/"})); - this.conn.send = () => {}; - alert(this.st.tr["This tab is now offline"]); - break; - // 0.2] Receive clients list (just socket IDs) - case "pollclients": - data.sockIds.forEach(sid => { - this.$set(this.people, sid, {id:0, name:""}); - // Ask identity and challenges - this.conn.send(JSON.stringify({code:"askidentity", target:sid})); - this.conn.send(JSON.stringify({code:"askchallenge", target:sid})); + case "pollclientsandgamers": + { + let identityAsked = {}; + data.sockIds.forEach(s => { + if (s.sid != this.st.user.sid && !identityAsked[s.sid]) + { + identityAsked[s.sid] = true; + this.send("askidentity", {target:s.sid}); + } + if (!this.people[s.sid]) + this.$set(this.people, s.sid, {id:0, name:"", pages:[s.page || "/"]}); + else if (!!s.page && this.people[s.sid].pages.indexOf(s.page) < 0) + this.people[s.sid].pages.push(s.page); + if (!s.page) + this.send("askchallenge", {target:s.sid}); + else + this.send("askgame", {target:s.sid}); }); break; - case "pollgamers": - // NOTE: we could make a difference between people in hall - // and gamers, but is it necessary? - data.sockIds.forEach(sid => { - this.$set(this.people, sid, {id:0, name:"", gamer:true}); - this.conn.send(JSON.stringify({code:"askidentity", target:sid})); - }); - // Also ask current games to all playing peers (TODO: some design issue) - this.conn.send(JSON.stringify({code:"askgames"})); + } + case "connect": + case "gconnect": + // NOTE: player could have been polled earlier, but might have logged in then + // So it's a good idea to ask identity if he was anonymous. + // But only ask game / challenge if currently disconnected. + if (!this.people[data.from]) + { + this.$set(this.people, data.from, {name:"", id:0, pages:[data.page]}); + if (data.code == "connect") + this.send("askchallenge", {target:data.from}); + else + this.send("askgame", {target:data.from}); + } + else + { + // append page if not already in list + if (this.people[data.from].pages.indexOf(data.page) < 0) + this.people[data.from].pages.push(data.page); + } + if (this.people[data.from].id == 0) + this.send("askidentity", {target:data.from}); + break; + case "disconnect": + case "gdisconnect": + // Disconnect means no more tmpIds: + if (data.code == "disconnect") + { + this.$delete(this.people, data.from); + // Also remove all challenges sent by this player: + ArrayFun.remove(this.challenges, c => c.from.sid == data.from); + } + else + { + const pidx = this.people[data.from].pages.indexOf(data.page); + this.people[data.from].pages.splice(pageIdx, 1); + if (this.people[data.from].pages.length == 0) + { + this.$delete(this.people, data.from); + // And all live games where he plays and no other opponent is online + ArrayFun.remove(this.games, g => + g.type == "live" && (g.players.every(p => p.sid == data.from + || !this.people[p.sid])), "all"); + } + } break; case "askidentity": // Request for identification: reply if I'm not anonymous if (this.st.user.id > 0) { - this.conn.send(JSON.stringify({code:"identity", - user: { - // NOTE: decompose to avoid revealing email - name: this.st.user.name, - sid: this.st.user.sid, - id: this.st.user.id, - }, - target:data.from})); + const me = { + // NOTE: decompose to avoid revealing email + name: this.st.user.name, + sid: this.st.user.sid, + id: this.st.user.id, + }; + this.send("identity", {data:me, target:data.from}); } break; case "identity": - this.$set(this.people, data.user.sid, + { + const user = data.data; + this.$set(this.people, user.sid, { - id: data.user.id, - name: data.user.name, - gamer: this.people[data.user.sid].gamer, + id: user.id, + name: user.name, + pages: this.people[user.sid].pages, }); + +// // TODO: smarter, if multi-connect, send to all instances... (several sid's) +// // Or better: just prevent multi-connect. +// // Fix anomaly: if registered player multi-connect, should be left only one +// const anomalies = Object.keys(this.people).filter(sid => this.people[sid].id == user.id); +// if (anomalies.length == 2) +// this.$delete(this.people, anomalies[0]); +// // --> this isn't good, some sid's are just forgetted + break; + } case "askchallenge": { // Send my current live challenge (if any) @@ -370,142 +408,95 @@ export default { if (cIdx >= 0) { const c = this.challenges[cIdx]; - // TODO: code below requires "c.to" to have given his identity, - // but it can happen that the identity arrives later, which - // prevent him from receiving the challenge. - // ==> Filter later (when receiving challenge) -// if (!!c.to) -// { -// // Only share targeted challenges to the targets: -// const toSid = Object.keys(this.people).find(k => -// this.people[k].name == c.to); -// if (toSid != data.from) -// return; -// } + // NOTE: in principle, should only send targeted challenge to the target. + // But we may not know yet the identity of the target (just name), + // so cannot decide if data.from is the target or not. const myChallenge = { - // Minimal challenge informations: (from not required) id: c.id, + from: this.st.user.sid, to: c.to, fen: c.fen, vid: c.vid, - timeControl: c.timeControl, + cadence: c.cadence, added: c.added, }; - this.conn.send(JSON.stringify({code:"challenge", - chall:myChallenge, target:data.from})); + this.send("challenge", {data:myChallenge, target:data.from}); } break; } - case "challenge": - // Receive challenge from some player (+sid) + case "challenge": //after "askchallenge" + case "newchallenge": + { // NOTE about next condition: see "askchallenge" case. - if (!data.chall.to || data.chall.to == this.st.user.name) + const chall = data.data; + if (!chall.to || (this.people[chall.from].id > 0 && + (chall.from == this.st.user.sid || chall.to == this.st.user.name))) { - let newChall = data.chall; - newChall.type = this.classifyObject(data.chall); - newChall.from = - Object.assign({sid:data.from}, this.people[data.from]); + let newChall = Object.assign({}, chall); + newChall.type = this.classifyObject(chall); + newChall.added = Date.now(); + let fromValues = Object.assign({}, this.people[chall.from]); + delete fromValues["pages"]; //irrelevant in this context + newChall.from = Object.assign({sid:chall.from}, fromValues); newChall.vname = this.getVname(newChall.vid); this.challenges.push(newChall); } break; - case "game": + } + case "refusechallenge": { - // Receive game from some player (+sid) - // NOTE: it may be correspondance (if newgame while we are connected) - // If duplicate found: select rid (remote ID) at random - let game = this.games.find(g => g.id == data.game.id); - if (!!game) - { - if (Math.random() < 0.5) - game.rid = data.from; - } - else + const cid = data.data; + ArrayFun.remove(this.challenges, c => c.id == cid); + alert(this.st.tr["Challenge declined"]); + break; + } + case "deletechallenge": + { + // NOTE: the challenge may be already removed + const cid = data.data; + ArrayFun.remove(this.challenges, c => c.id == cid); + break; + } + case "game": //individual request + case "newgame": + { + // NOTE: it may be live or correspondance + const game = data.data; + if (this.games.findIndex(g => g.id == game.id) < 0) { - let newGame = data.game; - newGame.type = this.classifyObject(data.game); - newGame.vname = this.getVname(data.game.vid); - newGame.rid = data.from; - if (!data.game.score) + let newGame = game; + newGame.type = this.classifyObject(game); + newGame.vname = this.getVname(game.vid); + if (!game.score) //if new game from Hall newGame.score = "*"; this.games.push(newGame); } break; } - case "newgame": + case "startgame": + { // New game just started: data contain all information - if (this.classifyObject(data.gameInfo) == "live") - this.startNewGame(data.gameInfo); + const gameInfo = data.data; + if (this.classifyObject(gameInfo) == "live") + this.startNewGame(gameInfo); else { - this.infoMessage = "New game started: " + - "<a href='#/game/" + data.gameInfo.id + "'>" + - "#/game/" + data.gameInfo.id + "</a>"; + this.infoMessage = this.st.tr["New correspondance game:"] + + " <a href='#/game/" + gameInfo.id + "'>" + + "#/game/" + gameInfo.id + "</a>"; let modalBox = document.getElementById("modalInfo"); modalBox.checked = true; setTimeout(() => { modalBox.checked = false; }, 3000); } break; + } case "newchat": - this.newChat = data.chat; - break; - case "refusechallenge": - ArrayFun.remove(this.challenges, c => c.id == data.cid); - alert(this.st.tr["Challenge declined"]); - break; - case "deletechallenge": - // NOTE: the challenge may be already removed - ArrayFun.remove(this.challenges, c => c.id == data.cid); - break; - case "connect": - case "gconnect": - this.$set(this.people, data.from, {name:"", id:0, gamer:data.code[0]=='g'}); - this.conn.send(JSON.stringify({code:"askidentity", target:data.from})); - if (data.code == "connect") - this.conn.send(JSON.stringify({code:"askchallenge", target:data.from})); - else - this.conn.send(JSON.stringify({code:"askgame", target:data.from})); - break; - case "disconnect": - case "gdisconnect": - this.$delete(this.people, data.from); - if (data.code == "disconnect") - { - // Also remove all challenges sent by this player: - ArrayFun.remove(this.challenges, c => c.from.sid == data.from); - } - else - { - // And all live games where he plays and no other opponent is online - ArrayFun.remove(this.games, g => - g.type == "live" && (g.players.every(p => p.sid == data.from - || !this.people[p.sid])), "all"); - } + this.newChat = data.data; break; } }, // Challenge lifecycle: - tryChallenge: function(sid) { - if (this.people[sid].id == 0) - return; //anonymous players cannot be challenged - // TODO: SID is available, so we could use it instead of searching from name - this.newchallenge.to = this.people[sid].name; - doClick("modalNewgame"); - }, - challOrWatch: function(sid) { - if (!this.people[sid].gamer) - { - // Available, in Hall - this.tryChallenge(sid); - } - else - { - // Playing, in Game - this.showGame(this.games.find( - g => g.players.some(pl => pl.sid == sid || pl.uid == this.people[sid].id))); - } - }, newChallenge: async function() { if (this.newchallenge.vid == "") return alert(this.st.tr["Please select a variant"]); @@ -514,8 +505,8 @@ export default { const vname = this.getVname(this.newchallenge.vid); const vModule = await import("@/variants/" + vname + ".js"); window.V = vModule.VariantRules; - if (!!this.newchallenge.timeControl.match(/^[0-9]+$/)) - this.newchallenge.timeControl += "+0"; //assume minutes, no increment + if (!!this.newchallenge.cadence.match(/^[0-9]+$/)) + this.newchallenge.cadence += "+0"; //assume minutes, no increment const error = checkChallenge(this.newchallenge); if (!!error) return alert(error); @@ -524,21 +515,15 @@ export default { return alert(this.st.tr["Please log in to play correspondance games"]); // NOTE: "from" information is not required here let chall = Object.assign({}, this.newchallenge); - const finishAddChallenge = (cid,warnDisconnected) => { + const finishAddChallenge = (cid) => { chall.id = cid || "c" + getRandString(); - // Send challenge to peers (if connected) - const isSent = this.sendSomethingTo({name:chall.to}, "challenge", - {chall:chall}, !!warnDisconnected); - if (!isSent) - return; // Remove old challenge if any (only one at a time of a given type): const cIdx = this.challenges.findIndex(c => (c.from.sid == this.st.user.sid || c.from.id == this.st.user.id) && c.type == ctype); if (cIdx >= 0) { // Delete current challenge (will be replaced now) - this.sendSomethingTo({name:this.challenges[cIdx].to}, - "deletechallenge", {cid:this.challenges[cIdx].id}); + this.send("deletechallenge", {data:this.challenges[cIdx].id}); if (ctype == "corr") { ajax( @@ -549,26 +534,27 @@ export default { } this.challenges.splice(cIdx, 1); } + this.send("newchallenge", {data:Object.assign({from:this.st.user.sid}, chall)}); // Add new challenge: - chall.added = Date.now(); - // NOTE: vname and type are redundant (can be deduced from timeControl + vid) - chall.type = ctype; - chall.vname = vname; chall.from = { //decompose to avoid revealing email sid: this.st.user.sid, id: this.st.user.id, name: this.st.user.name, }; + chall.added = Date.now(); + // NOTE: vname and type are redundant (can be deduced from cadence + vid) + chall.type = ctype; + chall.vname = vname; this.challenges.push(chall); - // Remember timeControl + vid for quicker further challenges: - localStorage.setItem("timeControl", chall.timeControl); + // Remember cadence + vid for quicker further challenges: + localStorage.setItem("cadence", chall.cadence); localStorage.setItem("vid", chall.vid); document.getElementById("modalNewgame").checked = false; }; if (ctype == "live") { // Live challenges have a random ID - finishAddChallenge(null, "warnDisconnected"); + finishAddChallenge(null); } else { @@ -605,11 +591,9 @@ export default { } else { - this.conn.send(JSON.stringify({ - code: "refusechallenge", - cid: c.id, target: c.from.sid})); + this.send("refusechallenge", {data:c.id, target:c.from.sid}); } - this.sendSomethingTo(!!c.to ? {sid:c.from.sid} : null, "deletechallenge", {cid:c.id}); + this.send("deletechallenge", {data:c.id}); } else //my challenge { @@ -621,7 +605,7 @@ export default { {id: c.id} ); } - this.sendSomethingTo({name:c.to}, "deletechallenge", {cid:c.id}); + this.send("deletechallenge", {data:c.id}); } // In all cases, the challenge is consumed: ArrayFun.remove(this.challenges, ch => ch.id == c.id); @@ -630,15 +614,14 @@ export default { launchGame: async function(c) { const vModule = await import("@/variants/" + c.vname + ".js"); window.V = vModule.VariantRules; - // These game informations will be sent to other players - const gameInfo = + // These game informations will be shared + let gameInfo = { id: getRandString(), fen: c.fen || V.GenRandInitFen(), players: shuffle([c.from, c.seat]), //white then black vid: c.vid, - vname: c.vname, //theoretically vid is enough, but much easier with vname - timeControl: c.timeControl, + cadence: c.cadence, }; let oppsid = c.from.sid; //may not be defined if corr + offline opp if (!oppsid) @@ -646,17 +629,15 @@ export default { oppsid = Object.keys(this.people).find(sid => this.people[sid].id == c.from.id); } - const tryNotifyOpponent = () => { + const notifyNewgame = () => { if (!!oppsid) //opponent is online - { - this.conn.send(JSON.stringify({code:"newgame", - gameInfo:gameInfo, target:oppsid, cid:c.id})); - } + this.send("startgame", {data:gameInfo, target:oppsid}); + // Send game info (only if live) to everyone except me in this tab + this.send("newgame", {data:gameInfo}); }; if (c.type == "live") { - // NOTE: in this case we are sure opponent is online - tryNotifyOpponent(); + notifyNewgame(); this.startNewGame(gameInfo); } else //corr: game only on server @@ -667,32 +648,19 @@ export default { {gameInfo: gameInfo, cid: c.id}, //cid useful to delete challenge response => { gameInfo.id = response.gameId; - tryNotifyOpponent(); + notifyNewgame(); this.$router.push("/game/" + response.gameId); } ); } - // Send game info to everyone except opponent (and me) - Object.keys(this.people).forEach(sid => { - if (![this.st.user.sid,oppsid].includes(sid)) - { - this.conn.send(JSON.stringify({code:"game", - game: { //minimal game info: - id: gameInfo.id, - players: gameInfo.players, - vid: gameInfo.vid, - timeControl: gameInfo.timeControl, - }, - target: sid})); - } - }); }, // NOTE: for live games only (corr games start on the server) startNewGame: function(gameInfo) { const game = Object.assign({}, gameInfo, { // (other) Game infos: constant fenStart: gameInfo.fen, - added: Date.now(), + vname: this.getVname(gameInfo.vid), + created: Date.now(), // Game state (including FEN): will be updated moves: [], clocks: [-1, -1], //-1 = unstarted diff --git a/client/src/views/MyGames.vue b/client/src/views/MyGames.vue index 1ca94117..6ed2c7f6 100644 --- a/client/src/views/MyGames.vue +++ b/client/src/views/MyGames.vue @@ -47,7 +47,7 @@ export default { methods: { // TODO: classifyObject and filterGames are redundant (see Hall.vue) classifyObject: function(o) { - return (o.timeControl.indexOf('d') === -1 ? "live" : "corr"); + return (o.cadence.indexOf('d') === -1 ? "live" : "corr"); }, filterGames: function(type) { return this.games.filter(g => g.type == type); diff --git a/server/db/create.sql b/server/db/create.sql index 21c7728b..b7b76c9b 100644 --- a/server/db/create.sql +++ b/server/db/create.sql @@ -27,7 +27,7 @@ create table Challenges ( target integer, vid integer, fen varchar, - timeControl varchar, + cadence varchar, foreign key (uid) references Users(id), foreign key (vid) references Variants(id) ); @@ -39,8 +39,8 @@ create table Games ( fen varchar, --current state score varchar, scoreMsg varchar, - timeControl varchar, - created datetime, --used only for DB cleaning + cadence varchar, + created datetime, drawOffer character, foreign key (vid) references Variants(id) ); diff --git a/server/models/Challenge.js b/server/models/Challenge.js index fabeb7ae..75cab3ab 100644 --- a/server/models/Challenge.js +++ b/server/models/Challenge.js @@ -9,7 +9,7 @@ const UserModel = require("./User"); * target: recipient id (optional) * vid: variant id (int) * fen: varchar (optional) - * timeControl: string (3m+2s, 7d+1d ...) + * cadence: string (3m+2s, 7d+1d ...) */ const ChallengeModel = @@ -18,7 +18,7 @@ const ChallengeModel = { if (!c.vid.toString().match(/^[0-9]+$/)) return "Wrong variant ID"; - if (!c.timeControl.match(/^[0-9dhms +]+$/)) + if (!c.cadence.match(/^[0-9dhms +]+$/)) return "Wrong characters in time control"; if (!c.fen.match(/^[a-zA-Z0-9, /-]*$/)) return "Bad FEN string"; @@ -33,10 +33,10 @@ const ChallengeModel = db.serialize(function() { const query = "INSERT INTO Challenges " + - "(added, uid, " + (!!c.to ? "target, " : "") + - "vid, fen, timeControl) VALUES " + + "(added, uid, " + (!!c.to ? "target, " : "") + "vid, fen, cadence) " + + "VALUES " + "(" + Date.now() + "," + c.uid + "," + (!!c.to ? c.to + "," : "") + - c.vid + ",'" + c.fen + "','" + c.timeControl + "')"; + c.vid + ",'" + c.fen + "','" + c.cadence + "')"; db.run(query, function(err) { return cb(err, {cid: this.lastID}); }); diff --git a/server/models/Game.js b/server/models/Game.js index 32b2a662..16c050cd 100644 --- a/server/models/Game.js +++ b/server/models/Game.js @@ -7,7 +7,7 @@ const UserModel = require("./User"); * vid: integer (variant id) * fenStart: varchar (initial position) * fen: varchar (current position) - * timeControl: string + * cadence: string * score: varchar (result) * scoreMsg: varchar ("Time", "Mutual agreement"...) * created: datetime @@ -38,7 +38,7 @@ const GameModel = return "Wrong variant ID"; if (!g.vname.match(/^[a-zA-Z0-9]+$/)) return "Wrong variant name"; - if (!g.timeControl.match(/^[0-9dhms +]+$/)) + if (!g.cadence.match(/^[0-9dhms +]+$/)) return "Wrong characters in time control"; if (!g.fen.match(/^[a-zA-Z0-9, /-]*$/)) return "Bad FEN string"; @@ -49,14 +49,14 @@ const GameModel = return ""; }, - create: function(vid, fen, timeControl, players, cb) + create: function(vid, fen, cadence, players, cb) { db.serialize(function() { let query = - "INSERT INTO Games" - + " (vid, fenStart, fen, score, timeControl, created, drawOffer)" - + " VALUES (" + vid + ",'" + fen + "','" + fen + "','*','" - + timeControl + "'," + Date.now() + ",'')"; + "INSERT INTO Games " + + "(vid, fenStart, fen, score, cadence, created, drawOffer) " + + "VALUES " + + "(" + vid + ",'" + fen + "','" + fen + "','*','" + cadence + "'," + Date.now() + ",'')"; db.run(query, function(err) { if (!!err) return cb(err); @@ -72,15 +72,14 @@ const GameModel = }); }, - // TODO: queries here could be async, and wait for all to complete - getOne: function(id, cb) + // TODO: some queries here could be async + getOne: function(id, light, cb) { db.serialize(function() { - // TODO: optimize queries? let query = // NOTE: g.scoreMsg can be NULL // (in this case score = "*" and no reason to look at it) - "SELECT g.id, g.vid, g.fen, g.fenStart, g.timeControl, g.score, " + + "SELECT g.id, g.vid, g.fen, g.fenStart, g.cadence, g.score, " + "g.scoreMsg, g.drawOffer, v.name AS vname " + "FROM Games g " + "JOIN Variants v " + @@ -98,6 +97,14 @@ const GameModel = db.all(query, (err2,players) => { if (!!err2) return cb(err2); + if (light) + { + const game = Object.assign({}, + gameInfo, + {players: players} + ); + return cb(null, game); + } query = "SELECT squares, played, idx " + "FROM Moves " + @@ -128,6 +135,7 @@ const GameModel = }); }, + // For display on MyGames or Hall: no need for moves or chats getByUser: function(uid, excluded, cb) { db.serialize(function() { @@ -143,7 +151,7 @@ const GameModel = let gameArray = []; for (let i=0; i<gameIds.length; i++) { - GameModel.getOne(gameIds[i]["gid"], (err2,game) => { + GameModel.getOne(gameIds[i]["gid"], true, (err2,game) => { if (!!err2) return cb(err2); gameArray.push(game); diff --git a/server/routes/challenges.js b/server/routes/challenges.js index a7adcf5c..9ba5c536 100644 --- a/server/routes/challenges.js +++ b/server/routes/challenges.js @@ -21,7 +21,7 @@ router.post("/challenges", access.logged, access.ajax, (req,res) => { let challenge = { fen: req.body.chall.fen, - timeControl: req.body.chall.timeControl, + cadence: req.body.chall.cadence, vid: req.body.chall.vid, uid: req.userId, to: req.body.chall.to, //string: user name (may be empty) diff --git a/server/routes/games.js b/server/routes/games.js index 42325856..b5f59b0c 100644 --- a/server/routes/games.js +++ b/server/routes/games.js @@ -26,7 +26,7 @@ router.post("/games", access.logged, access.ajax, (req,res) => { return res.json({errmsg:error}); ChallengeModel.remove(cid); GameModel.create( - gameInfo.vid, gameInfo.fen, gameInfo.timeControl, gameInfo.players, + gameInfo.vid, gameInfo.fen, gameInfo.cadence, gameInfo.players, (err,ret) => { access.checkRequest(res, err, ret, "Cannot create game", () => { const oppIdx = (gameInfo.players[0].id == req.userId ? 1 : 0); @@ -45,7 +45,7 @@ router.get("/games", access.ajax, (req,res) => { { if (!gameId.match(/^[0-9]+$/)) return res.json({errmsg: "Wrong game ID"}); - GameModel.getOne(gameId, (err,game) => { + GameModel.getOne(gameId, false, (err,game) => { access.checkRequest(res, err, game, "Game not found", () => { res.json({game: game}); }); diff --git a/server/sockets.js b/server/sockets.js index 11f7d8ec..fea0f49e 100644 --- a/server/sockets.js +++ b/server/sockets.js @@ -14,7 +14,7 @@ function getJsonFromUrl(url) } module.exports = function(wss) { - // Associative array sid --> tmpId --> {socket, page}, + // Associative array page --> sid --> tmpId --> socket // "page" is either "/" for hall or "/game/some_gid" for Game, // tmpId is required if a same user (browser) has different tabs let clients = {}; @@ -22,21 +22,45 @@ module.exports = function(wss) { const query = getJsonFromUrl(req.url); const sid = query["sid"]; const tmpId = query["tmpId"]; - const notifyRoom = (page,code,obj={},excluded=[]) => { - Object.keys(clients).forEach(k => { - if (k in excluded) - return; - if (k != sid && clients[k].page == page) - { - clients[k].sock.send(JSON.stringify(Object.assign( - {code:code, from:[sid,tmpId]}, obj))); - } + const page = query["page"]; + const notifyRoom = (page,code,obj={}) => { + Object.keys(clients[page]).forEach(k => { + Object.keys(clients[page][k]).forEach(x => { + if (k == sid && x == tmpId) + return; + clients[page][k][x].send(JSON.stringify(Object.assign( + {code:code, from:sid}, obj))); + }); }); }; + const deleteConnexion = () => { + if (!clients[page] || !clients[page][sid] || !clients[page][sid][tmpId]) + return; //job already done + delete clients[page][sid][tmpId]; + if (Object.keys(clients[page][sid]).length == 0) + { + delete clients[page][sid]; + if (Object.keys(clients[page]) == 0) + delete clients[page]; + } + }; const messageListener = (objtxt) => { let obj = JSON.parse(objtxt); - if (!!obj.target && !clients[obj.target]) - return; //receiver not connected, nothing we can do + if (!!obj.target) + { + // Check if receiver is connected, because there may be some lag + // between a client disconnects and another notice. + if (Array.isArray(obj.target)) + { + if (!clients[page][obj.target[0]] || + !clients[page][obj.target[0]][obj.target[1]]) + { + return; + } + } + else if (!clients[page][obj.target]) + return; + } switch (obj.code) { // Wait for "connect" message to notify connection to the room, @@ -44,195 +68,124 @@ module.exports = function(wss) { // not be ready too early. case "connect": { - const curPage = clients[sid][tmpId].page; - notifyRoom(curPage, "connect"); //Hall or Game - if (curPage.indexOf("/game/") >= 0) - notifyRoom("/", "gconnect"); //notify main hall + notifyRoom(page, "connect"); + if (page.indexOf("/game/") >= 0) + notifyRoom("/", "gconnect", {page:page}); break; } case "disconnect": - { - const oldPage = obj.page; - notifyRoom(oldPage, "disconnect"); //Hall or Game - if (oldPage.indexOf("/game/") >= 0) - notifyRoom("/", "gdisconnect"); //notify main hall + // When page changes: + deleteConnexion(); + if (!clients[page][sid]) + { + // I effectively disconnected from this page: + notifyRoom(page, "disconnect"); + if (page.indexOf("/game/") >= 0) + notifyRoom("/", "gdisconnect", {page:page}); + } break; - } - case "pollclients": + case "pollclients": //from Hall or Game { - const curPage = clients[sid][tmpId].page; - let sockIds = {}; //result, object sid ==> [tmpIds] - Object.keys(clients).forEach(k => { - Object.keys(clients[k]).forEach(x => { - if ((k != sid || x != tmpId) - && clients[k][x].page == curPage) - { - if (!sockIds[k]) - sockIds[k] = [x]; - else - sockIds[k].push(x); - } - }); + let sockIds = []; + Object.keys(clients[page]).forEach(k => { + // Poll myself if I'm on at least another tab (same page) + if (k != sid || Object.keys(clients["/"][k]).length >= 2) + sockIds.push(k); }); socket.send(JSON.stringify({code:"pollclients", sockIds:sockIds})); break; } - case "pollgamers": + case "pollclientsandgamers": //from Hall { - let sockIds = {}; - Object.keys(clients).forEach(k => { - Object.keys(clients[k]).forEach(x => { - if ((k != sid || x != tmpId) - && clients[k][x].page.indexOf("/game/") >= 0) - { - if (!sockIds[k]) - sockIds[k] = [x]; - else - sockIds[k].push(x); - } - }); + let sockIds = []; + Object.keys(clients["/"]).forEach(k => { + // Poll myself if I'm on at least another tab (same page) + if (k != sid || Object.keys(clients["/"][k]).length >= 2) + sockIds.push({sid:k}); + }); + // NOTE: a "gamer" could also just be an observer + Object.keys(clients).forEach(p => { + if (p != "/") + { + Object.keys(clients[p]).forEach(k => { + if (k != sid) + sockIds.push({sid:k, page:p}); //page needed for gamers + }); + } }); - socket.send(JSON.stringify({code:"pollgamers", sockIds:sockIds})); + socket.send(JSON.stringify({code:"pollclientsandgamers", sockIds:sockIds})); break; } + + // Asking something: from is fully identified, + // but the requested resource can be from any tmpId (except current!) case "askidentity": + case "asklastate": + case "askchallenge": + case "askgame": + case "askfullgame": { - // Identity only depends on sid, so select a tmpId at random - const tmpIds = Object.keys(clients[obj.target]); + const tmpIds = Object.keys(clients[page][obj.target]); + if (obj.target == sid) //targetting myself + { + const idx_myTmpid = tmpIds.findIndex(x => x == tmpId); + if (idx_myTmpid >= 0) + tmpIds.splice(idx_myTmpid, 1); + } const tmpId_idx = Math.floor(Math.random() * tmpIds.length); - clients[obj.target][tmpIds[tmpId_idx]].sock.send(JSON.stringify( - {code:"askidentity",from:[sid,tmpId]})); + clients[page][obj.target][tmpIds[tmpId_idx]].send( + JSON.stringify({code:obj.code, from:[sid,tmpId]})); break; } - case "asklastate": - clients[obj.target[0]][obj.target[1]].sock.send(JSON.stringify( - {code:"asklastate",from:[sid,tmpId]})); - break; - case "askchallenge": - clients[obj.target[0]][obj.target[1]].sock.send(JSON.stringify( - {code:"askchallenge",from:[sid,tmpId]})); - break; - case "askgames": - { - // Check all clients playing, and send them a "askgame" message - // game ID --> [ sid1 --> array of tmpIds, sid2 --> array of tmpIds] - let gameSids = {}; - const regexpGid = /\/[a-zA-Z0-9]+$/; - Object.keys(clients).forEach(k => { - Object.keys(clients[k]).forEach(x => { - if ((k != sid || x != tmpId) - && clients[k][x].page.indexOf("/game/") >= 0) + + // Some Hall events: target all tmpId's (except mine), + case "refusechallenge": + case "startgame": + Object.keys(clients[page][obj.target]).forEach(x => { + if (obj.target != sid || x != tmpId) { - const gid = clients[k][x].page.match(regexpGid)[0]; - if (!gameSids[gid]) - gameSids[gid] = [{k: [x]}]; - else if (k == Object.keys(gameSids[gid][0])[0]) - gameSids[gid][0][k].push(x); - else if (gameSids[gid].length == 1) - gameSids[gid].push({k: [x]}); - else - Object.values(gameSids[gid][1]).push(x); + clients[page][obj.target][x].send(JSON.stringify( + {code:obj.code, data:obj.data})); } }); - // Request only one client out of 2 (TODO: this is a bit heavy) - // Alt: ask game to all, and filter later? - Object.keys(gameSids).forEach(gid => { - const L = gameSids[gid].length; - const sidIdx = L > 1 - ? Math.floor(Math.random() * Math.floor(L)) - : 0; - const tmpIdx = Object.values(gameSids[gid][sidIdx] - const rid = gameSids[gid][sidIdx][tmpIdx]; - clients[sidIdx][tmpIdx].sock.send(JSON.stringify( - {code:"askgame", from: [sid,tmpId]})); - }); - break; - } - case "askgame": - clients[obj.target].sock.send(JSON.stringify( - {code:"askgame", from:sid})); - break; - case "askfullgame": - clients[obj.target].sock.send(JSON.stringify( - {code:"askfullgame", from:sid})); - break; - case "fullgame": - clients[obj.target].sock.send(JSON.stringify( - {code:"fullgame", game:obj.game})); - break; - case "identity": - clients[obj.target].sock.send(JSON.stringify( - {code:"identity",user:obj.user})); - break; - case "refusechallenge": - clients[obj.target].sock.send(JSON.stringify( - {code:"refusechallenge", cid:obj.cid, from:sid})); - break; - case "deletechallenge": - clients[obj.target].sock.send(JSON.stringify( - {code:"deletechallenge", cid:obj.cid, from:sid})); - break; - case "newgame": - clients[obj.target].sock.send(JSON.stringify( - {code:"newgame", gameInfo:obj.gameInfo, cid:obj.cid})); - break; - case "challenge": - clients[obj.target].sock.send(JSON.stringify( - {code:"challenge", chall:obj.chall, from:sid})); - break; - case "game": - if (!!obj.target) - { - clients[obj.target].sock.send(JSON.stringify( - {code:"game", game:obj.game, from:sid})); - } - else - { - // Notify all room except opponent and me: - notifyRoom("/", "game", {game:obj.game}, [obj.oppsid]); - } break; + + // Notify all room: mostly game events case "newchat": - notifyRoom(clients[sid].page, "newchat", {chat:obj.chat}); - break; - // TODO: WebRTC instead in this case (most demanding?) - // --> Or else: at least do a "notifyRoom" (also for draw, resign...) + case "newchallenge": + case "newgame": + case "deletechallenge": case "newmove": - clients[obj.target].sock.send(JSON.stringify( - {code:"newmove", move:obj.move})); - break; - case "lastate": - clients[obj.target].sock.send(JSON.stringify( - {code:"lastate", state:obj.state})); - break; case "resign": - clients[obj.target].sock.send(JSON.stringify( - {code:"resign", side:obj.side})); - break; case "abort": - clients[obj.target].sock.send(JSON.stringify( - {code:"abort"})); - break; case "drawoffer": - clients[obj.target].sock.send(JSON.stringify( - {code:"drawoffer"})); - break; case "draw": - clients[obj.target].sock.send(JSON.stringify( - {code:"draw", message:obj.message})); + notifyRoom(page, obj.code, {data:obj.data}); + break; + + // Passing, relaying something: from isn't needed, + // but target is fully identified (sid + tmpId) + case "challenge": + case "fullgame": + case "game": + case "identity": + case "lastate": + clients[page][obj.target[0]][obj.target[1]].send(JSON.stringify( + {code:obj.code, data:obj.data})); break; } }; const closeListener = () => { - delete clients[sid]; + // For tab or browser closing: + deleteConnexion(); }; - if (!!clients[sid]) - { - // Turn off old sock through current client: - clients[sid].sock.send(JSON.stringify({code:"duplicate"})); - } - // Potentially replace current connection: - clients[sid] = {sock: socket, page: query["page"]}; + // Update clients object: add new connexion + if (!clients[page]) + clients[page] = {[sid]: {[tmpId]: socket}}; + else if (!clients[page][sid]) + clients[page][sid] = {[tmpId]: socket}; + else + clients[page][sid][tmpId] = socket; socket.on("message", messageListener); socket.on("close", closeListener); }); -- 2.44.0