X-Git-Url: https://git.auder.net/?p=vchess.git;a=blobdiff_plain;f=client%2Fsrc%2Fviews%2FGame.vue;h=5145e5883461fc61e1c1a4ae949b635ef94dc5a9;hp=9a9710063932256181df338851688c41ba85d53b;hb=1ef65040168ab7d55ce921abc9d63644a937d689;hpb=910d631b73cad5ffef1b4461157b704e7e7057d8 diff --git a/client/src/views/Game.vue b/client/src/views/Game.vue index 9a971006..5145e588 100644 --- a/client/src/views/Game.vue +++ b/client/src/views/Game.vue @@ -1,65 +1,150 @@ @@ -68,11 +153,18 @@ import BaseGame from "@/components/BaseGame.vue"; import Chat from "@/components/Chat.vue"; import { store } from "@/store"; import { GameStorage } from "@/utils/gameStorage"; +import { ImportgameStorage } from "@/utils/importgameStorage"; import { ppt } from "@/utils/datetime"; +import { notify } from "@/utils/notifications"; +import { ajax } from "@/utils/ajax"; import { extractTime } from "@/utils/timeControl"; import { getRandString } from "@/utils/alea"; -import { processModalClick } from "@/utils/modalClick"; import { getScoreMessage } from "@/utils/scoring"; +import { getFullNotation } from "@/utils/notation"; +import { getDiagram } from "@/utils/printDiagram"; +import { processModalClick } from "@/utils/modalClick"; +import { playMove, getFilteredMove } from "@/utils/playUndo"; +import { ArrayFun } from "@/utils/array"; import params from "@/parameters"; export default { name: "my-game", @@ -80,160 +172,430 @@ export default { BaseGame, Chat }, - // gameRef: to find the game in (potentially remote) storage data: function() { return { st: store.state, - gameRef: { - //given in URL (rid = remote ID) - id: "", - rid: "" - }, - game: { - //passed to BaseGame - players: [{ name: "" }, { name: "" }], - chats: [], - rendered: false - }, - virtualClocks: [0, 0], //initialized with true game.clocks + // gameRef can point to a corr game, local game or remote live game + gameRef: "", + nextIds: [], + game: {}, //passed to BaseGame + focus: !document.hidden, //will not always work... TODO + // virtualClocks will be initialized from true game.clocks + virtualClocks: [], vr: null, //"variant rules" object initialized from FEN drawOffer: "", + rematchId: "", + rematchOffer: "", + lastateAsked: false, people: {}, //players + observers lastate: undefined, //used if opponent send lastate before game is ready repeat: {}, //detect position repetition - newChat: "", + curDiag: "", //for corr moves confirmation conn: null, + roomInitialized: false, + // If newmove has wrong index: ask fullgame again: + askGameTime: 0, + gameIsLoading: false, + // If asklastate got no reply, ask again: + gotLastate: false, + gotMoveIdx: -1, //last move index received + // If newmove got no pingback, send again: + opponentGotMove: false, connexionString: "", + socketCloseListener: 0, + // Incomplete info games: show move played + moveNotation: "", + // Intervals from setInterval(): + askLastate: null, + retrySendmove: null, + clockUpdate: null, // Related to (killing of) self multi-connects: - newConnect: {}, - killed: {} + newConnect: {} }; }, watch: { - $route: function(to) { - this.gameRef.id = to.params["id"]; - this.gameRef.rid = to.query["rid"]; - this.loadGame(); + $route: function(to, from) { + if (to.path.length < 6 || to.path.substr(0, 6) != "/game/") + // Page change + this.cleanBeforeDestroy(); + else if (from.params["id"] != to.params["id"]) { + // Change everything: + this.cleanBeforeDestroy(); + let boardDiv = document.querySelector(".game"); + if (!!boardDiv) + // In case of incomplete information variant: + boardDiv.style.visibility = "hidden"; + this.atCreation(); + } else + // Same game ID + this.nextIds = JSON.parse(this.$route.query["next"] || "[]"); } }, // 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; - this.$set(this.people, my.sid, { id: my.id, name: my.name }); - this.gameRef.id = this.$route.params["id"]; - this.gameRef.rid = this.$route.query["rid"]; //may be undefined - // Initialize connection - this.connexionString = - params.socketUrl + - "/?sid=" + - this.st.user.sid + - "&tmpId=" + - getRandString() + - "&page=" + - encodeURIComponent(this.$route.path); - this.conn = new WebSocket(this.connexionString); - this.conn.onmessage = this.socketMessageListener; - this.conn.onclose = this.socketCloseListener; - // Socket init required before loading remote game: - const socketInit = callback => { - if (!!this.conn && this.conn.readyState == 1) - //1 == OPEN state - callback(); - //socket not ready yet (initial loading) - else { - // NOTE: it's important to call callback without arguments, - // otherwise first arg is Websocket object and loadGame fails. - this.conn.onopen = () => { - return callback(); - }; - } - }; - if (!this.gameRef.rid) - //game stored locally or on server - this.loadGame(null, () => socketInit(this.roomInit)); - //game stored remotely: need socket to retrieve it - else { - // NOTE: the callback "roomInit" will be lost, so we don't provide it. - // --> It will be given when receiving "fullgame" socket event. - // A more general approach would be to store it somewhere. - socketInit(this.loadGame); - } + this.atCreation(); }, mounted: function() { - document - .getElementById("chatWrap") + document.getElementById("chatWrap") + .addEventListener("click", (e) => { + processModalClick(e, () => { + this.toggleChat("close") + }); + }); + document.getElementById("infoDiv") .addEventListener("click", processModalClick); + if ("ontouchstart" in window) { + // Disable tooltips on smartphones: + document.querySelectorAll("#aboveBoard .tooltip").forEach(elt => { + elt.classList.remove("tooltip"); + }); + } }, beforeDestroy: function() { - this.send("disconnect"); + this.cleanBeforeDestroy(); }, methods: { + cleanBeforeDestroy: function() { + clearInterval(this.socketCloseListener); + document.removeEventListener('visibilitychange', this.visibilityChange); + window.removeEventListener('focus', this.onFocus); + window.removeEventListener('blur', this.onBlur); + if (!!this.askLastate) clearInterval(this.askLastate); + if (!!this.retrySendmove) clearInterval(this.retrySendmove); + if (!!this.clockUpdate) clearInterval(this.clockUpdate); + this.conn.removeEventListener("message", this.socketMessageListener); + this.send("disconnect"); + this.conn = null; + }, + visibilityChange: function() { + // TODO: Use document.hidden? https://webplatform.news/issues/2019-03-27 + this.focus = (document.visibilityState == "visible"); + if (!this.focus && !!this.rematchOffer) { + this.rematchOffer = ""; + this.send("rematchoffer", { data: false }); + // Do not remove rematch offer from (local) storage + } + this.send(this.focus ? "getfocus" : "losefocus"); + }, + onFocus: function() { + this.focus = true; + this.send("getfocus"); + }, + onBlur: function() { + this.focus = false; + if (!!this.rematchOffer) { + this.rematchOffer = ""; + this.send("rematchoffer", { data: false }); + } + this.send("losefocus"); + }, + isLargeScreen: function() { + return window.innerWidth >= 500; + }, + participateInChat: function(p) { + return Object.keys(p.tmpIds).some(x => p.tmpIds[x].focus) && !!p.name; + }, + someAnonymousPresent: function() { + return ( + Object.values(this.people).some(p => + !p.name && Object.keys(p.tmpIds).some(x => p.tmpIds[x].focus) + ) + ); + }, + atCreation: function() { + document.addEventListener('visibilitychange', this.visibilityChange); + window.addEventListener('focus', this.onFocus); + window.addEventListener('blur', this.onBlur); + // 0] (Re)Set variables + this.gameRef = this.$route.params["id"]; + // next = next corr games IDs to navigate faster (if applicable) + this.nextIds = JSON.parse(this.$route.query["next"] || "[]"); + // Always add myself to players' list + const my = this.st.user; + const tmpId = getRandString(); + this.$set( + this.people, + my.sid, + { + id: my.id, + name: my.name, + tmpIds: { + tmpId: { focus: true } + } + } + ); + this.game = { + players: [{ name: "" }, { name: "" }], + chats: [], + rendered: false + }; + let chatComp = this.$refs["chatcomp"]; + if (!!chatComp) chatComp.chats = []; + this.virtualClocks = [[0,0], [0,0]]; + this.vr = null; + this.drawOffer = ""; + this.lastateAsked = false; + this.rematchOffer = ""; + this.lastate = undefined; + this.roomInitialized = false; + this.askGameTime = 0; + this.gameIsLoading = false; + this.gotLastate = false; + this.gotMoveIdx = -1; + this.opponentGotMove = false; + this.askLastate = null; + this.retrySendmove = null; + this.clockUpdate = null; + this.newConnect = {}; + // 1] Initialize connection + this.connexionString = + params.socketUrl + + "/?sid=" + this.st.user.sid + + "&id=" + this.st.user.id + + "&tmpId=" + tmpId + + "&page=" + + // Discard potential "/?next=[...]" for page indication: + encodeURIComponent(this.$route.path.match(/\/game\/[a-zA-Z0-9]+/)[0]); + this.conn = new WebSocket(this.connexionString); + this.conn.addEventListener("message", this.socketMessageListener); + this.socketCloseListener = setInterval( + () => { + if (this.conn.readyState == 3) { + this.conn.removeEventListener( + "message", this.socketMessageListener); + this.conn = new WebSocket(this.connexionString); + this.conn.addEventListener("message", this.socketMessageListener); + } + }, + 1000 + ); + // Socket init required before loading remote game: + const socketInit = callback => { + if (this.conn.readyState == 1) + // 1 == OPEN state + callback(); + else + // Socket not ready yet (initial loading) + // NOTE: first arg is Websocket object, unused here: + this.conn.onopen = () => callback(); + }; + this.fetchGame((game) => { + if (!!game) + this.loadVariantThenGame(game, () => socketInit(this.roomInit)); + else + // Live game stored remotely: need socket to retrieve it + // NOTE: the callback "roomInit" will be lost, so it's not provided. + // --> It will be given when receiving "fullgame" socket event. + socketInit(() => { this.send("askfullgame"); }); + }); + }, roomInit: function() { - // Notify the room only now that I connected, because - // messages might be lost otherwise (if game loading is slow) - this.send("connect"); - this.send("pollclients"); + if (!this.roomInitialized) { + // Notify the room only now that I connected, because + // messages might be lost otherwise (if game loading is slow) + this.send("connect"); + this.send("pollclients"); + // We may ask fullgame several times if some moves are lost, + // but room should be init only once: + this.roomInitialized = true; + } }, send: function(code, obj) { - if (this.conn) { + if (!!this.conn && this.conn.readyState == 1) this.conn.send(JSON.stringify(Object.assign({ code: code }, obj))); - } }, isConnected: function(index) { const player = this.game.players[index]; - // Is it me ? - if (this.st.user.sid == player.sid || this.st.user.id == player.uid) - return true; + // Is it me ? In this case no need to bother with focus + if (this.st.user.sid == player.sid || this.st.user.id == player.id) + // Still have to check for name (because of potential multi-accounts + // on same browser, although this should be rare...) + return (!this.st.user.name || this.st.user.name == player.name); // Try to find a match in people: return ( - Object.keys(this.people).some(sid => sid == player.sid) || - Object.values(this.people).some(p => p.id == player.uid) + ( + !!player.sid && + Object.keys(this.people).some(sid => { + return ( + sid == player.sid && + Object.values(this.people[sid].tmpIds).some(v => v.focus) + ); + }) + ) + || + ( + !!player.id && + Object.values(this.people).some(p => { + return ( + p.id == player.id && + Object.values(p.tmpIds).some(v => v.focus) + ); + }) + ) ); }, + getOppsid: function() { + let oppsid = this.game.oppsid; + if (!oppsid) { + oppsid = Object.keys(this.people).find( + sid => this.people[sid].id == this.game.oppid + ); + } + // oppsid is useful only if opponent is online: + if (!!oppsid && !!this.people[oppsid]) return oppsid; + return null; + }, + // NOTE: action if provided is always a closing action + toggleChat: function(action) { + if (!action && document.getElementById("modalChat").checked) + // Entering chat + document.getElementById("inputChat").focus(); + else { + document.getElementById("chatBtn").classList.remove("somethingnew"); + if (!!this.game.mycolor) { + // Update "chatRead" variable either on server or locally + if (this.game.type == "corr") + this.updateCorrGame({ chatRead: this.game.mycolor }); + else if (this.game.type == "live") + GameStorage.update(this.gameRef, { chatRead: true }); + } + } + }, + processChat: function(chat) { + this.send("newchat", { data: chat }); + // NOTE: anonymous chats in corr games are not stored on server (TODO?) + if (!!this.game.mycolor) { + if (this.game.type == "corr") + this.updateCorrGame({ chat: chat }); + else { + // Live game + chat.added = Date.now(); + GameStorage.update(this.gameRef, { chat: chat }); + } + } + }, + clearChat: function() { + if (!!this.game.mycolor) { + if (this.game.type == "corr") { + ajax( + "/chats", + "DELETE", + { data: { gid: this.game.id } } + ); + } else { + // Live game + GameStorage.update(this.gameRef, { delchat: true }); + } + this.$set(this.game, "chats", []); + } + }, + getGameType: function(game) { + if (!!game.id.match(/^i/)) return "import"; + return game.cadence.indexOf("d") >= 0 ? "corr" : "live"; + }, + // Notify something after a new move (to opponent and me on MyGames page) + notifyMyGames: function(thing, data) { + this.send( + "notify" + thing, + { + data: data, + targets: this.game.players.map(p => { + return { sid: p.sid, id: p.id }; + }) + } + ); + }, + showNextGame: function() { + // Did I play in current game? If not, add it to nextIds list + if (this.game.score == "*" && this.vr.turn == this.game.mycolor) + this.nextIds.unshift(this.game.id); + const nextGid = this.nextIds.pop(); + this.$router.push( + "/game/" + nextGid + "/?next=" + JSON.stringify(this.nextIds)); + }, + askGameAgain: function() { + this.gameIsLoading = true; + const currentUrl = document.location.href; + const doAskGame = () => { + if (document.location.href != currentUrl) return; //page change + this.fetchGame((game) => { + if (!!game) + // This is my game: just reload. + this.loadGame(game); + else + // Just ask fullgame again (once!), this is much simpler. + // If this fails, the user could just reload page :/ + this.send("askfullgame"); + }); + }; + // Delay of at least 2s between two game requests + const now = Date.now(); + const delay = Math.max(2000 - (now - this.askGameTime), 0); + this.askGameTime = now; + setTimeout(doAskGame, delay); + }, socketMessageListener: function(msg) { if (!this.conn) return; const data = JSON.parse(msg.data); switch (data.code) { case "pollclients": - data.sockIds.forEach(sid => { - this.$set(this.people, sid, { id: 0, name: "" }); + // TODO: shuffling and random filtering on server, + // if the room is really crowded. + Object.keys(data.sockIds).forEach(sid => { if (sid != this.st.user.sid) { this.send("askidentity", { target: sid }); - // 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 == sid) - ) { - this.send("asklastate", { target: sid }); - } + this.people[sid] = { tmpIds: data.sockIds[sid] }; + } else { + // Complete my tmpIds: + Object.assign(this.people[sid].tmpIds, data.sockIds[sid]); } }); break; case "connect": - if (!this.people[data.from]) - this.$set(this.people, data.from, { name: "", id: 0 }); - if (!this.people[data.from].name) { - this.newConnect[data.from] = true; //for self multi-connects tests - this.send("askidentity", { target: data.from }); + if (!this.people[data.from[0]]) { + // focus depends on the tmpId (e.g. tab) + this.$set( + this.people, + data.from[0], + { + tmpIds: { + [data.from[1]]: { focus: true } + } + } + ); + // For self multi-connects tests: + this.newConnect[data.from[0]] = true; + this.send("askidentity", { target: data.from[0] }); + } else { + this.people[data.from[0]].tmpIds[data.from[1]] = { focus: true }; + this.$forceUpdate(); //TODO: shouldn't be required } break; case "disconnect": - this.$delete(this.people, data.from); + if (!this.people[data.from[0]]) return; + delete this.people[data.from[0]].tmpIds[data.from[1]]; + if (Object.keys(this.people[data.from[0]].tmpIds).length == 0) + this.$delete(this.people, data.from[0]); + else this.$forceUpdate(); //TODO: shouldn't be required break; - case "killed": - // I logged in elsewhere: - alert(this.st.tr["New connexion detected: tab now offline"]); - // TODO: this fails. See https://github.com/websockets/ws/issues/489 - //this.conn.removeEventListener("message", this.socketMessageListener); - //this.conn.removeEventListener("close", this.socketCloseListener); - //this.conn.close(); - this.conn = null; + case "getfocus": { + let player = this.people[data.from[0]]; + if (!!player) { + player.tmpIds[data.from[1]].focus = true; + this.$forceUpdate(); //TODO: shouldn't be required + } break; + } + case "losefocus": { + let player = this.people[data.from[0]]; + if (!!player) { + player.tmpIds[data.from[1]].focus = false; + this.$forceUpdate(); //TODO: shouldn't be required + } + break; + } case "askidentity": { - // Request for identification (TODO: anonymous shouldn't need to reply) + // Request for identification const me = { // Decompose to avoid revealing email name: this.st.user.name, @@ -245,102 +607,194 @@ export default { } case "identity": { const user = data.data; - if (user.name) { - // If I multi-connect, kill current connexion if no mark (I'm older) + let player = this.people[user.sid]; + // player.tmpIds is already set + player.name = user.name; + player.id = user.id; + this.$forceUpdate(); //TODO: shouldn't be required + // If I multi-connect, kill current connexion if no mark (I'm older) + if (this.newConnect[user.sid]) { + delete this.newConnect[user.sid]; if ( - this.newConnect[user.sid] && user.id > 0 && user.id == this.st.user.id && user.sid != this.st.user.sid ) { - if (!this.killed[this.st.user.sid]) { - this.send("killme", { sid: this.st.user.sid }); - this.killed[this.st.user.sid] = true; - } - } - if (user.sid != this.st.user.sid) { - //I already know my identity... - this.$set(this.people, user.sid, { - id: user.id, - name: user.name - }); + this.cleanBeforeDestroy(); + alert(this.st.tr["New connexion detected: tab now offline"]); + break; } } - delete this.newConnect[user.sid]; + // Ask potentially missed last state, if opponent and I play + if ( + !this.gotLastate && + !!this.game.mycolor && + this.game.type == "live" && + this.game.score == "*" && + this.game.players.some(p => p.sid == user.sid) + ) { + this.send("asklastate", { target: user.sid }); + let counter = 1; + this.askLastate = setInterval( + () => { + // Ask at most 3 times: + // if no reply after that there should be a network issue. + if ( + counter < 3 && + !this.gotLastate && + !!this.people[user.sid] + ) { + this.send("asklastate", { target: user.sid }); + counter++; + } else { + clearInterval(this.askLastate); + } + }, + 1500 + ); + } break; } case "askgame": - // Send current (live) game if not asked by any of the players + // Send current (live or import) game, + // if not asked by any of the players if ( - this.game.type == "live" && + this.game.type != "corr" && this.game.players.every(p => p.sid != data.from[0]) ) { const myGame = { id: this.game.id, + // FEN is current position, unused for now fen: this.game.fen, players: this.game.players, vid: this.game.vid, cadence: this.game.cadence, - score: this.game.score, - rid: this.st.user.sid //useful in Hall if I'm an observer + score: this.game.score }; this.send("game", { data: myGame, target: data.from }); } break; case "askfullgame": - this.send("fullgame", { data: this.game, target: data.from }); + const gameToSend = Object.keys(this.game) + .filter(k => + [ + "id","fen","players","vid","cadence","fenStart","vname", + "moves","clocks","score","drawOffer","rematchOffer" + ].includes(k)) + .reduce( + (obj, k) => { + obj[k] = this.game[k]; + return obj; + }, + {} + ); + this.send("fullgame", { data: gameToSend, target: data.from }); break; case "fullgame": - // Callback "roomInit" to poll clients only after game is loaded - this.loadGame(data.data, this.roomInit); + if (!!data.data.empty) { + alert(this.st.tr["The game should be in another tab"]); + this.$router.go(-1); + } + else + // Callback "roomInit" to poll clients only after game is loaded + this.loadVariantThenGame(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) || - this.game.score != "*" || - this.drawOffer == "sent" - ) { - // Send our "last state" informations to opponent - const L = this.game.moves.length; - const myIdx = ["w", "b"].indexOf(this.game.mycolor); - 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.send("lastate", { data: myLastate, target: data.from }); - } + // Sending informative last state if I played a move or score != "*" + // If the game or moves aren't loaded yet, delay the sending: + // TODO: socket init after game load, so the game is supposedly ready + if (!this.game || !this.game.moves) this.lastateAsked = true; + else this.sendLastate(data.from); break; - case "lastate": //got opponent infos about last move + case "lastate": { + // Got opponent infos about last move + this.gotLastate = true; this.lastate = data.data; if (this.game.rendered) - //game is rendered (Board component) + // Game is rendered (Board component) this.processLastate(); - //else: will be processed when game is ready + // Else: will be processed when game is ready break; + } case "newmove": { - 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: "" }); + const movePlus = data.data; + const movesCount = this.game.moves.length; + if (movePlus.index > movesCount) { + // This can only happen if I'm an observer and missed a move. + if (this.gotMoveIdx < movePlus.index) + this.gotMoveIdx = movePlus.index; + if (!this.gameIsLoading) this.askGameAgain(); + } + else { + if ( + movePlus.index < movesCount || + this.gotMoveIdx >= movePlus.index + ) { + // Opponent re-send but we already have the move: + // (maybe he didn't receive our pingback...) + this.send("gotmove", {data: movePlus.index, target: data.from}); + } else { + this.gotMoveIdx = movePlus.index; + const receiveMyMove = (movePlus.color == this.game.mycolor); + const moveColIdx = ["w", "b"].indexOf(movePlus.color); + if (!receiveMyMove && !!this.game.mycolor) { + // Notify opponent that I got the move: + this.send( + "gotmove", + { data: movePlus.index, target: data.from } + ); + // And myself if I'm elsewhere: + if (!this.focus) { + notify( + "New move", + { + body: + (this.game.players[moveColIdx].name || "@nonymous") + + " just played." + } + ); + } + } + if (movePlus.cancelDrawOffer) { + // Opponent refuses draw + this.drawOffer = ""; + // NOTE for corr games: drawOffer reset by player in turn + if ( + this.game.type == "live" && + !!this.game.mycolor && + !receiveMyMove + ) { + GameStorage.update(this.gameRef, { drawOffer: "" }); + } + } + this.$refs["basegame"].play( + movePlus.move, "received", null, true); + this.game.clocks[moveColIdx] = movePlus.clock; + this.processMove( + movePlus.move, + { receiveMyMove: receiveMyMove } + ); + } } - this.$set(this.game, "moveToPlay", move); + break; + } + case "gotmove": { + this.opponentGotMove = true; + // Now his clock starts running on my side: + const oppIdx = ['w','b'].indexOf(this.vr.turn); + // NOTE: next line to avoid multi-resetClocks when several tabs + // on same game, resulting in a faster countdown. + if (!!this.clockUpdate) clearInterval(this.clockUpdate); + this.re_setClocks(); break; } case "resign": - this.gameOver(data.side == "b" ? "1-0" : "0-1", "Resign"); + const score = (data.data == "b" ? "1-0" : "0-1"); + const side = (data.data == "w" ? "White" : "Black"); + this.gameOver(score, side + " surrender"); break; case "abort": - this.gameOver("?", "Abort"); + this.gameOver("?", "Stop"); break; case "draw": this.gameOver("1/2", data.data); @@ -348,40 +802,117 @@ export default { case "drawoffer": // NOTE: observers don't know who offered draw this.drawOffer = "received"; + if (!!this.game.mycolor && this.game.type == "live") { + GameStorage.update( + this.gameRef, + { drawOffer: V.GetOppCol(this.game.mycolor) } + ); + } + break; + case "rematchoffer": + // NOTE: observers don't know who offered rematch + this.rematchOffer = data.data ? "received" : ""; + if (!!this.game.mycolor && this.game.type == "live") { + GameStorage.update( + this.gameRef, + { rematchOffer: V.GetOppCol(this.game.mycolor) } + ); + } + break; + case "newgame": { + // A game started, redirect if I'm playing in + const gameInfo = data.data; + const gameType = this.getGameType(gameInfo); + if ( + gameType == "live" && + gameInfo.players.some(p => p.sid == this.st.user.sid) + ) { + this.addAndGotoLiveGame(gameInfo); + } else if ( + gameType == "corr" && + gameInfo.players.some(p => p.id == this.st.user.id) + ) { + this.$router.push("/game/" + gameInfo.id); + } else { + this.rematchId = gameInfo.id; + document.getElementById("modalInfo").checked = true; + } break; - case "newchat": - this.newChat = data.data; + } + case "newchat": { + let chat = data.data; + this.$refs["chatcomp"].newChat(chat); + if (this.game.type == "live") { + chat.added = Date.now(); + if (!!this.game.mycolor) + GameStorage.update(this.gameRef, { chat: chat }); + } if (!document.getElementById("modalChat").checked) document.getElementById("chatBtn").classList.add("somethingnew"); break; + } } }, - socketCloseListener: function() { - this.conn = new WebSocket(this.connexionString); - this.conn.addEventListener("message", this.socketMessageListener); - this.conn.addEventListener("close", this.socketCloseListener); + updateCorrGame: function(obj, callback) { + ajax( + "/games", + "PUT", + { + data: { + gid: this.gameRef, + newObj: obj + }, + success: () => { + if (!!callback) callback(); + } + } + ); + }, + sendLastate: function(target) { + // Send our "last state" informations to opponent + const L = this.game.moves.length; + const myIdx = ["w", "b"].indexOf(this.game.mycolor); + const myLastate = { + lastMove: + (L > 0 && this.vr.turn != this.game.mycolor) + ? this.game.moves[L - 1] + : undefined, + clock: this.game.clocks[myIdx], + // Since we played a move (or abort or resign), + // only drawOffer=="sent" is possible + drawSent: this.drawOffer == "sent", + rematchSent: this.rematchOffer == "sent", + score: this.game.score != "*" ? this.game.score : undefined, + scoreMsg: this.game.score != "*" ? this.game.scoreMsg : undefined, + movesCount: L + }; + this.send("lastate", { data: myLastate, target: target }); }, // lastate was received, but maybe game wasn't ready yet: processLastate: function() { const data = this.lastate; this.lastate = undefined; //security... const L = this.game.moves.length; + const oppIdx = 1 - ["w", "b"].indexOf(this.game.mycolor); + this.game.clocks[oppIdx] = data.clock; if (data.movesCount > L) { // Just got last move from him - this.$set( - this.game, - "moveToPlay", - Object.assign({ initime: data.initime }, data.lastMove) - ); + this.$refs["basegame"].play(data.lastMove, "received", null, true); + this.processMove(data.lastMove); + } else { + if (!!this.clockUpdate) clearInterval(this.clockUpdate); + this.re_setClocks(); } if (data.drawSent) this.drawOffer = "received"; - if (data.score != "*") { + if (data.rematchSent) this.rematchOffer = "received"; + if (!!data.score) { this.drawOffer = ""; - if (this.game.score == "*") this.gameOver(data.score); + if (this.game.score == "*") + this.gameOver(data.score, data.scoreMsg); } }, clickDraw: function() { - if (!this.game.mycolor) return; //I'm just spectator + if (!this.game.mycolor || this.game.type == "import") return; if (["received", "threerep"].includes(this.drawOffer)) { if (!confirm(this.st.tr["Accept draw?"])) return; const message = @@ -391,7 +922,7 @@ export default { this.send("draw", { data: message }); this.gameOver("1/2", message); } else if (this.drawOffer == "") { - //no effect if drawOffer == "sent" + // No effect if drawOffer == "sent" if (this.game.mycolor != this.vr.turn) { alert(this.st.tr["Draw offer only in your turn"]); return; @@ -399,353 +930,614 @@ export default { if (!confirm(this.st.tr["Offer draw?"])) return; this.drawOffer = "sent"; this.send("drawoffer"); - GameStorage.update(this.gameRef.id, { drawOffer: this.game.mycolor }); + if (this.game.type == "live") { + GameStorage.update( + this.gameRef, + { drawOffer: this.game.mycolor } + ); + } else this.updateCorrGame({ drawOffer: this.game.mycolor }); + } + }, + addAndGotoLiveGame: function(gameInfo, callback) { + const game = Object.assign( + {}, + gameInfo, + { + // (other) Game infos: constant + fenStart: gameInfo.fen, + vname: this.game.vname, + created: Date.now(), + // Game state (including FEN): will be updated + moves: [], + clocks: [-1, -1], //-1 = unstarted + score: "*" + } + ); + GameStorage.add(game, (err) => { + // No error expected. + if (!err) { + if (this.st.settings.sound) + new Audio("/sounds/newgame.flac").play().catch(() => {}); + if (!!callback) callback(); + this.$router.push("/game/" + gameInfo.id); + } + }); + }, + clickRematch: function() { + if (!this.game.mycolor || this.game.type == "import") return; + if (this.rematchOffer == "received") { + // Start a new game! + let gameInfo = { + id: getRandString(), //ignored if corr + fen: V.GenRandInitFen(this.game.randomness), + players: this.game.players.reverse(), + vid: this.game.vid, + cadence: this.game.cadence + }; + const notifyNewGame = () => { + const oppsid = this.getOppsid(); //may be null + this.send("rnewgame", { data: gameInfo, oppsid: oppsid }); + // To main Hall if corr game: + if (this.game.type == "corr") + this.send("newgame", { data: gameInfo, page: "/" }); + // Also to MyGames page: + this.notifyMyGames("newgame", gameInfo); + }; + if (this.game.type == "live") + this.addAndGotoLiveGame(gameInfo, notifyNewGame); + else { + // corr game + ajax( + "/games", + "POST", + { + // cid is useful to delete the challenge: + data: { gameInfo: gameInfo }, + success: (response) => { + gameInfo.id = response.gameId; + notifyNewGame(); + this.$router.push("/game/" + response.gameId); + } + } + ); + } + } else if (this.rematchOffer == "") { + this.rematchOffer = "sent"; + this.send("rematchoffer", { data: true }); + if (this.game.type == "live") { + GameStorage.update( + this.gameRef, + { rematchOffer: this.game.mycolor } + ); + } else this.updateCorrGame({ rematchOffer: this.game.mycolor }); + } else if (this.rematchOffer == "sent") { + // Toggle rematch offer (on --> off) + this.rematchOffer = ""; + this.send("rematchoffer", { data: false }); + if (this.game.type == "live") { + GameStorage.update( + this.gameRef, + { rematchOffer: '' } + ); + } else this.updateCorrGame({ rematchOffer: 'n' }); } }, abortGame: function() { - if (!this.game.mycolor || !confirm(this.st.tr["Terminate game?"])) return; - this.gameOver("?", "Abort"); + if (!this.game.mycolor || !confirm(this.st.tr["Terminate game?"])) + return; + this.gameOver("?", "Stop"); this.send("abort"); }, resign: function() { if (!this.game.mycolor || !confirm(this.st.tr["Resign the game?"])) return; this.send("resign", { data: this.game.mycolor }); - this.gameOver(this.game.mycolor == "w" ? "0-1" : "1-0", "Resign"); + const score = (this.game.mycolor == "w" ? "0-1" : "1-0"); + const side = (this.game.mycolor == "w" ? "White" : "Black"); + this.gameOver(score, side + " surrender"); }, - // 3 cases for loading a game: - // - from indexedDB (running or completed live game I play) - // - from server (one correspondance game I play[ed] or not) - // - from remote peer (one live game I don't play, finished or not) loadGame: function(game, callback) { - const afterRetrieval = async game => { - const vModule = await import("@/variants/" + game.vname + ".js"); - window.V = vModule.VariantRules; - this.vr = new V(game.fen); - const gtype = game.cadence.indexOf("d") >= 0 ? "corr" : "live"; - const tc = extractTime(game.cadence); - const myIdx = game.players.findIndex(p => { - return p.sid == this.st.user.sid || p.uid == this.st.user.id; - }); - const mycolor = [undefined, "w", "b"][myIdx + 1]; //undefined for observers - if (!game.chats) game.chats = []; //live games don't have chat history - if (gtype == "corr") { - if (game.players[0].color == "b") { - // Adopt the same convention for live and corr games: [0] = white - [game.players[0], game.players[1]] = [ - game.players[1], - game.players[0] - ]; - } - // corr game: needs to compute the clocks + initime - // NOTE: clocks in seconds, initime in milliseconds - game.clocks = [tc.mainTime, tc.mainTime]; - game.moves.sort((m1, m2) => m1.idx - m2.idx); //in case of - if (game.score == "*") { - //otherwise no need to bother with time - game.initime = [0, 0]; - const L = game.moves.length; - if (L >= 3) { - let addTime = [0, 0]; - for (let i = 2; i < L; i++) { - addTime[i % 2] += - tc.increment - - (game.moves[i].played - game.moves[i - 1].played) / 1000; - } - for (let i = 0; i <= 1; i++) game.clocks[i] += addTime[i]; - } - if (L >= 1) game.initime[L % 2] = game.moves[L - 1].played; - } - const reformattedMoves = game.moves.map(m => { - const s = m.squares; - return { - appear: s.appear, - vanish: s.vanish, - start: s.start, - end: s.end - }; - }); - // Sort chat messages from newest to oldest - game.chats.sort((c1, c2) => { - return c2.added - c1.added; - }); - if (myIdx >= 0 && game.chats.length > 0) { - // TODO: group multi-moves into an array, to deduce color from index - // and not need this (also repeated in BaseGame::re_setVariables()) - let vr_tmp = new V(game.fenStart); //vr is already at end of game - for (let i = 0; i < reformattedMoves.length; i++) { - game.moves[i].color = vr_tmp.turn; - vr_tmp.play(reformattedMoves[i]); - } - // Blue background on chat button if last chat message arrived after my last move. - let dtLastMove = 0; - for (let midx = game.moves.length - 1; midx >= 0; midx--) { - if (game.moves[midx].color == mycolor) { - dtLastMove = game.moves[midx].played; - break; - } - } - if (dtLastMove < game.chats[0].added) - document.getElementById("chatBtn").classList.add("somethingnew"); + const gtype = game.type || this.getGameType(game); + const tc = extractTime(game.cadence); + const myIdx = game.players.findIndex(p => { + return p.sid == this.st.user.sid || p.id == this.st.user.id; + }); + // "mycolor" is undefined for observers + const mycolor = [undefined, "w", "b"][myIdx + 1]; + if (gtype == "corr") { + if (mycolor == 'w') game.chatRead = game.chatReadWhite; + else if (mycolor == 'b') game.chatRead = game.chatReadBlack; + // NOTE: clocks in seconds + game.moves.sort((m1, m2) => m1.idx - m2.idx); //in case of + game.clocks = [tc.mainTime, tc.mainTime]; + const L = game.moves.length; + if (game.score == "*") { + // Adjust clocks + if (L >= 2) { + game.clocks[L % 2] -= + (Date.now() - game.moves[L-1].played) / 1000; } - // Now that we used idx and played, re-format moves as for live games - game.moves = reformattedMoves; } - if (gtype == "live" && game.clocks[0] < 0) { - //game unstarted + // Now that we used idx and played, re-format moves as for live games + game.moves = game.moves.map(m => m.squares); + } + else if (gtype == "live") { + if (game.clocks[0] < 0) { + // Game is unstarted. clock is ignored until move 2 game.clocks = [tc.mainTime, tc.mainTime]; - if (game.score == "*") { - game.initime[0] = Date.now(); - if (myIdx >= 0) { - // I play in this live game; corr games don't have clocks+initime - GameStorage.update(game.id, { - clocks: game.clocks, - initime: game.initime - }); - } + if (myIdx >= 0) { + // I play in this live game + GameStorage.update(game.id, { + clocks: game.clocks + }); } + } else { + if (!!game.initime) + // It's my turn: clocks not updated yet + game.clocks[myIdx] -= (Date.now() - game.initime) / 1000; } - if (game.drawOffer) { - if (game.drawOffer == "t") - //three repetitions - this.drawOffer = "threerep"; + } + else + // gtype == "import" + game.clocks = [tc.mainTime, tc.mainTime]; + // Live games before 26/03/2020 don't have chat history: + if (!game.chats) game.chats = []; //TODO: remove line + // Sort chat messages from newest to oldest + game.chats.sort((c1, c2) => c2.added - c1.added); + if ( + myIdx >= 0 && + game.chats.length > 0 && + (!game.chatRead || game.chatRead < game.chats[0].added) + ) { + // A chat message arrived since my last reading: + document.getElementById("chatBtn").classList.add("somethingnew"); + } + // TODO: merge next 2 "if" conditions + if (!!game.drawOffer) { + if (game.drawOffer == "t") + // Three repetitions + this.drawOffer = "threerep"; + else { + // Draw offered by any of the players: + if (myIdx < 0) this.drawOffer = "received"; else { - if (myIdx < 0) this.drawOffer = "received"; - //by any of the players - else { - // I play in this game: - if ( - (game.drawOffer == "w" && myIdx == 0) || - (game.drawOffer == "b" && myIdx == 1) - ) - this.drawOffer = "sent"; - //all other cases - else this.drawOffer = "received"; - } + // I play in this game: + if ( + (game.drawOffer == "w" && myIdx == 0) || + (game.drawOffer == "b" && myIdx == 1) + ) + this.drawOffer = "sent"; + else this.drawOffer = "received"; } } - if (game.scoreMsg) game.scoreMsg = this.st.tr[game.scoreMsg]; //stored in english - delete game["moveToPlay"]; //in case of! - this.game = Object.assign( - {}, - game, - // NOTE: assign mycolor here, since BaseGame could also be VS computer + } + if (!!game.rematchOffer) { + if (myIdx < 0) this.rematchOffer = "received"; + else { + // I play in this game: + if ( + (game.rematchOffer == "w" && myIdx == 0) || + (game.rematchOffer == "b" && myIdx == 1) + ) + this.rematchOffer = "sent"; + else this.rematchOffer = "received"; + } + } + this.repeat = {}; //reset: scan past moves' FEN: + let repIdx = 0; + this.vr = new V(game.fenStart); + let curTurn = "n"; + game.moves.forEach(m => { + playMove(m, this.vr); + const fenIdx = this.vr.getFenForRepeat(); + this.repeat[fenIdx] = this.repeat[fenIdx] + ? this.repeat[fenIdx] + 1 + : 1; + }); + // Imported games don't have current FEN + if (!game.fen) game.fen = this.vr.getFen(); + if (this.repeat[repIdx] >= 3) this.drawOffer = "threerep"; + this.game = Object.assign( + // NOTE: assign mycolor here, since BaseGame could also be VS computer + { + type: gtype, + increment: tc.increment, + mycolor: mycolor, + // opponent sid not strictly required (or available), but easier + // at least oppsid or oppid is available anyway: + oppsid: myIdx < 0 ? undefined : game.players[1 - myIdx].sid, + oppid: myIdx < 0 ? undefined : game.players[1 - myIdx].id + }, + game + ); + this.$refs["basegame"].re_setVariables(this.game); + if (!this.gameIsLoading) { + // Initial loading: + this.gotMoveIdx = game.moves.length - 1; + // If we arrive here after 'nextGame' action, the board might be hidden + let boardDiv = document.querySelector(".game"); + if (!!boardDiv && boardDiv.style.visibility == "hidden") + boardDiv.style.visibility = "visible"; + } + this.re_setClocks(); + this.$nextTick(() => { + this.game.rendered = true; + // Did lastate arrive before game was rendered? + if (this.lastate) this.processLastate(); + }); + if (this.lastateAsked) { + this.lastateAsked = false; + this.sendLastate(game.oppsid); + } + if (this.gameIsLoading) { + this.gameIsLoading = false; + if (this.gotMoveIdx >= game.moves.length) + // Some moves arrived meanwhile... + this.askGameAgain(); + } + if (!!callback) callback(); + }, + loadVariantThenGame: async function(game, callback) { + await import("@/variants/" + game.vname + ".js") + .then((vModule) => { + window.V = vModule[game.vname + "Rules"]; + this.loadGame(game, callback); + }); + }, + // 3 cases for loading a game: + // - from indexedDB (running or completed live game I play) + // - from server (one correspondance game I play[ed] or not) + // - from remote peer (one live game I don't play, finished or not) + fetchGame: function(callback) { + +console.log("fecth"); + console.log(this.gameRef); + console.log(this.gameRef.match(/^i/)); + + if (Number.isInteger(this.gameRef) || !isNaN(parseInt(this.gameRef))) { + // corr games identifiers are integers + ajax( + "/games", + "GET", { - type: gtype, - increment: tc.increment, - mycolor: mycolor, - // opponent sid not strictly required (or available), but easier - // at least oppsid or oppid is available anyway: - oppsid: myIdx < 0 ? undefined : game.players[1 - myIdx].sid, - oppid: myIdx < 0 ? undefined : game.players[1 - myIdx].uid + data: { gid: this.gameRef }, + success: (res) => { + res.game.moves.forEach(m => { + m.squares = JSON.parse(m.squares); + }); + callback(res.game); + } } ); - this.re_setClocks(); - this.$nextTick(() => { - this.game.rendered = true; - // Did lastate arrive before game was rendered? - if (this.lastate) this.processLastate(); - }); - this.repeat = {}; //reset: scan past moves' FEN: - let repIdx = 0; - // NOTE: vr_tmp to obtain FEN strings is redundant with BaseGame - let vr_tmp = new V(game.fenStart); - game.moves.forEach(m => { - vr_tmp.play(m); - const fenObj = V.ParseFen(vr_tmp.getFen()); - repIdx = fenObj.position + "_" + fenObj.turn; - if (fenObj.flags) repIdx += "_" + fenObj.flags; - this.repeat[repIdx] = this.repeat[repIdx] - ? this.repeat[repIdx] + 1 - : 1; - }); - if (this.repeat[repIdx] >= 3) this.drawOffer = "threerep"; - if (callback) callback(); - }; - if (game) { - afterRetrieval(game); - return; - } - if (this.gameRef.rid) { - // Remote live game: forgetting about callback func... (TODO: design) - this.send("askfullgame", { target: this.gameRef.rid }); - } else { - // Local or corr game - GameStorage.get(this.gameRef.id, afterRetrieval); } + else if (!!this.gameRef.match(/^i/)) + // Game import (maybe remote) + ImportgameStorage.get(this.gameRef, callback); + else + // Local live game (or remote) + GameStorage.get(this.gameRef, callback); }, re_setClocks: function() { + this.virtualClocks = this.game.clocks.map(s => ppt(s).split(':')); if (this.game.moves.length < 2 || this.game.score != "*") { // 1st move not completed yet, or game over: freeze time - this.virtualClocks = this.game.clocks.map(s => ppt(s)); return; } const currentTurn = this.vr.turn; + const currentMovesCount = this.game.moves.length; const colorIdx = ["w", "b"].indexOf(currentTurn); - let countdown = - this.game.clocks[colorIdx] - - (Date.now() - this.game.initime[colorIdx]) / 1000; - this.virtualClocks = [0, 1].map(i => { - const removeTime = - i == colorIdx ? (Date.now() - this.game.initime[colorIdx]) / 1000 : 0; - return ppt(this.game.clocks[i] - removeTime); - }); - let clockUpdate = setInterval(() => { - if ( - countdown < 0 || - this.vr.turn != currentTurn || - this.game.score != "*" - ) { - clearInterval(clockUpdate); - if (countdown < 0) - this.gameOver( - this.vr.turn == "w" ? "0-1" : "1-0", - this.st.tr["Time"] + this.clockUpdate = setInterval( + () => { + if ( + this.game.clocks[colorIdx] < 0 || + this.game.moves.length > currentMovesCount || + this.game.score != "*" + ) { + clearInterval(this.clockUpdate); + this.clockUpdate = null; + if (this.game.clocks[colorIdx] < 0) + this.gameOver( + currentTurn == "w" ? "0-1" : "1-0", + "Time" + ); + } else { + this.$set( + this.virtualClocks, + colorIdx, + ppt(Math.max(0, --this.game.clocks[colorIdx])).split(':') ); - } else - this.$set( - this.virtualClocks, - colorIdx, - ppt(Math.max(0, --countdown)) - ); - }, 1000); + } + }, + 1000 + ); }, - // Post-process a move (which was just played in BaseGame) - processMove: function(move) { - if (this.game.type == "corr" && move.color == this.game.mycolor) { + // Update variables and storage after a move: + processMove: function(move, data) { + if (this.game.type == "import") + // Shouldn't receive any messages in this mode: + return; + if (!data) data = {}; + const moveCol = this.vr.turn; + const colorIdx = ["w", "b"].indexOf(moveCol); + const nextIdx = 1 - colorIdx; + const doProcessMove = () => { + const origMovescount = this.game.moves.length; + // The move is (about to be) played: stop clock + clearInterval(this.clockUpdate); + this.clockUpdate = null; + if (moveCol == this.game.mycolor && !data.receiveMyMove) { + if (this.drawOffer == "received") + // I refuse draw + this.drawOffer = ""; + if (this.game.type == "live" && origMovescount >= 2) { + this.game.clocks[colorIdx] += this.game.increment; + // For a correct display in casqe of disconnected opponent: + this.$set( + this.virtualClocks, + colorIdx, + ppt(this.game.clocks[colorIdx]).split(':') + ); + GameStorage.update(this.gameRef, { + // It's not my turn anymore: + initime: null + }); + } + } + // Update current game object: + playMove(move, this.vr); + if (!data.score) + // Received move, score is computed in BaseGame, but maybe not yet. + // ==> Compute it here, although this is redundant (TODO) + data.score = this.vr.getCurrentScore(); + if (data.score != "*") this.gameOver(data.score); + this.game.moves.push(move); + this.game.fen = this.vr.getFen(); + if (this.game.type == "corr") { + // In corr games, just reset clock to mainTime: + this.game.clocks[colorIdx] = extractTime(this.game.cadence).mainTime; + } + // If repetition detected, consider that a draw offer was received: + const fenObj = this.vr.getFenForRepeat(); + this.repeat[fenObj] = + !!this.repeat[fenObj] + ? this.repeat[fenObj] + 1 + : 1; + if (this.repeat[fenObj] >= 3) this.drawOffer = "threerep"; + else if (this.drawOffer == "threerep") this.drawOffer = ""; + if (!!this.game.mycolor && !data.receiveMyMove) { + // NOTE: 'var' to see that variable outside this block + var filtered_move = getFilteredMove(move); + } + if (moveCol == this.game.mycolor && !data.receiveMyMove) { + // Notify turn on MyGames page: + this.notifyMyGames( + "turn", + { + gid: this.gameRef, + turn: this.vr.turn + } + ); + } + // Since corr games are stored at only one location, update should be + // done only by one player for each move: if ( - !confirm( - this.st.tr["Move played:"] + - " " + - move.notation + - "\n" + - this.st.tr["Are you sure?"] - ) + this.game.type == "live" && + !!this.game.mycolor && + moveCol != this.game.mycolor && + this.game.moves.length >= 2 ) { - this.$set(this.game, "moveToUndo", move); - return; + // Receive a move: update initime + this.game.initime = Date.now(); + GameStorage.update(this.gameRef, { + // It's my turn now! + initime: this.game.initime + }); } - } - const colorIdx = ["w", "b"].indexOf(move.color); - const nextIdx = ["w", "b"].indexOf(this.vr.turn); - // https://stackoverflow.com/a/38750895 - if (this.game.mycolor) { - const allowed_fields = ["appear", "vanish", "start", "end"]; - // NOTE: 'var' to see this variable outside this block - var filtered_move = Object.keys(move) - .filter(key => allowed_fields.includes(key)) - .reduce((obj, key) => { - obj[key] = move[key]; - return obj; - }, {}); - } - // Send move ("newmove" event) to people in the room (if our turn) - let addTime = 0; - if (move.color == this.game.mycolor) { - if (this.drawOffer == "received") - //I refuse draw - this.drawOffer = ""; - if (this.game.moves.length >= 2) { - //after first move - const elapsed = Date.now() - this.game.initime[colorIdx]; - // elapsed time is measured in milliseconds - addTime = this.game.increment - elapsed / 1000; - } - const sendMove = Object.assign({}, filtered_move, { - addTime: addTime, - cancelDrawOffer: this.drawOffer == "" - }); - this.send("newmove", { data: sendMove }); - // (Add)Time indication: useful in case of lastate infos requested - move.addTime = addTime; - } else addTime = move.addTime; //supposed transmitted - // Update current game object: - this.game.moves.push(move); - this.game.fen = move.fen; - this.game.clocks[colorIdx] += addTime; - // move.initime is set only when I receive a "lastate" move from opponent - this.game.initime[nextIdx] = move.initime || Date.now(); - this.re_setClocks(); - // If repetition detected, consider that a draw offer was received: - const fenObj = V.ParseFen(move.fen); - let repIdx = fenObj.position + "_" + fenObj.turn; - if (fenObj.flags) repIdx += "_" + fenObj.flags; - this.repeat[repIdx] = this.repeat[repIdx] ? this.repeat[repIdx] + 1 : 1; - if (this.repeat[repIdx] >= 3) this.drawOffer = "threerep"; - else if (this.drawOffer == "threerep") this.drawOffer = ""; - // Since corr games are stored at only one location, update should be - // done only by one player for each move: - if ( - !!this.game.mycolor && - (this.game.type == "live" || move.color == this.game.mycolor) - ) { - let drawCode = ""; - switch (this.drawOffer) { - case "threerep": - drawCode = "t"; - break; - case "sent": - drawCode = this.game.mycolor; - break; - case "received": - drawCode = this.vr.turn; - break; + if ( + !!this.game.mycolor && + !data.receiveMyMove && + (this.game.type == "live" || moveCol == this.game.mycolor) + ) { + let drawCode = ""; + switch (this.drawOffer) { + case "threerep": + drawCode = "t"; + break; + case "sent": + drawCode = this.game.mycolor; + break; + case "received": + drawCode = V.GetOppCol(this.game.mycolor); + break; + } + if (this.game.type == "corr") { + // corr: only move, fen and score + this.updateCorrGame({ + fen: this.game.fen, + move: { + squares: filtered_move, + idx: origMovescount + }, + // Code "n" for "None" to force reset (otherwise it's ignored) + drawOffer: drawCode || "n" + }); + } + else { + const updateStorage = () => { + GameStorage.update(this.gameRef, { + fen: this.game.fen, + move: filtered_move, + moveIdx: origMovescount, + clocks: this.game.clocks, + drawOffer: drawCode + }); + }; + // The active tab can update storage immediately + if (this.focus) updateStorage(); + // Small random delay otherwise + else setTimeout(updateStorage, 500 + 1000 * Math.random()); + } } - if (this.game.type == "corr") { - GameStorage.update(this.gameRef.id, { - fen: move.fen, - move: { - squares: filtered_move, - played: Date.now(), - idx: this.game.moves.length - 1 - }, - drawOffer: drawCode || "n" //"n" for "None" to force reset (otherwise it's ignored) - }); - } //live - else { - GameStorage.update(this.gameRef.id, { - fen: move.fen, + // Send move ("newmove" event) to people in the room (if our turn) + if (moveCol == this.game.mycolor && !data.receiveMyMove) { + let sendMove = { move: filtered_move, - clocks: this.game.clocks, - initime: this.game.initime, - drawOffer: drawCode + index: origMovescount, + // color is required to check if this is my move + // (if several tabs opened) + color: moveCol, + cancelDrawOffer: this.drawOffer == "" + }; + if (this.game.type == "live") + sendMove["clock"] = this.game.clocks[colorIdx]; + // (Live) Clocks will re-start when the opponent pingback arrive + this.opponentGotMove = false; + this.send("newmove", {data: sendMove}); + // If the opponent doesn't reply gotmove soon enough, re-send move: + // Do this at most 2 times, because mpore would mean network issues, + // opponent would then be expected to disconnect/reconnect. + let counter = 1; + const currentUrl = document.location.href; + this.retrySendmove = setInterval( + () => { + if ( + counter >= 3 || + this.opponentGotMove || + document.location.href != currentUrl //page change + ) { + clearInterval(this.retrySendmove); + return; + } + const oppsid = this.getOppsid(); + if (!oppsid) + // Opponent is disconnected: he'll ask last state + clearInterval(this.retrySendmove); + else { + this.send("newmove", { data: sendMove, target: oppsid }); + counter++; + } + }, + 1500 + ); + } + else + // Not my move or I'm an observer: just start other player's clock + this.re_setClocks(); + }; + if ( + this.game.type == "corr" && + moveCol == this.game.mycolor && + !data.receiveMyMove + ) { + let boardDiv = document.querySelector(".game"); + const afterSetScore = () => { + doProcessMove(); + if (this.st.settings.gotonext && this.nextIds.length > 0) + this.showNextGame(); + else { + // The board might have been hidden: + if (boardDiv.style.visibility == "hidden") + boardDiv.style.visibility = "visible"; + if (data.score == "*") this.re_setClocks(); + } + }; + let el = document.querySelector("#buttonsConfirm > .acceptBtn"); + // We may play several moves in a row: in case of, remove listener: + let elClone = el.cloneNode(true); + el.parentNode.replaceChild(elClone, el); + elClone.addEventListener( + "click", + () => { + document.getElementById("modalConfirm").checked = false; + if (!!data.score && data.score != "*") + // Set score first + this.gameOver(data.score, null, afterSetScore); + else afterSetScore(); + } + ); + // PlayOnBoard is enough, and more appropriate for Synchrone Chess + V.PlayOnBoard(this.vr.board, move); + const position = this.vr.getBaseFen(); + V.UndoOnBoard(this.vr.board, move); + if (["all","byrow"].includes(V.ShowMoves)) { + this.curDiag = getDiagram({ + position: position, + orientation: V.CanFlip ? this.game.mycolor : "w" }); + document.querySelector("#confirmDiv > .card").style.width = + boardDiv.offsetWidth + "px"; + } else { + // Incomplete information: just ask confirmation + // Hide the board, because otherwise it could reveal infos + boardDiv.style.visibility = "hidden"; + this.moveNotation = getFullNotation(move); } + document.getElementById("modalConfirm").checked = true; + } + else { + // Normal situation + if (!!data.score && data.score != "*") + this.gameOver(data.score, null, doProcessMove); + else doProcessMove(); } }, - resetChatColor: function() { - // TODO: this is called twice, once on opening an once on closing - document.getElementById("chatBtn").classList.remove("somethingnew"); - }, - processChat: function(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 }); + cancelMove: function() { + let boardDiv = document.querySelector(".game"); + if (boardDiv.style.visibility == "hidden") + boardDiv.style.visibility = "visible"; + document.getElementById("modalConfirm").checked = false; + this.$refs["basegame"].cancelLastMove(); }, - gameOver: function(score, scoreMsg) { + // In corr games, callback to change page only after score is set: + gameOver: function(score, scoreMsg, callback) { this.game.score = score; - this.game.scoreMsg = this.st.tr[ - scoreMsg ? scoreMsg : getScoreMessage(score) - ]; + if (!scoreMsg) scoreMsg = getScoreMessage(score); + this.game.scoreMsg = scoreMsg; + this.$set(this.game, "scoreMsg", scoreMsg); const myIdx = this.game.players.findIndex(p => { - return p.sid == this.st.user.sid || p.uid == this.st.user.id; + return p.sid == this.st.user.sid || p.id == this.st.user.id; }); if (myIdx >= 0) { - //OK, I play in this game - GameStorage.update(this.gameRef.id, { + // OK, I play in this game + const scoreObj = { score: score, scoreMsg: scoreMsg - }); - // Notify the score to main Hall. TODO: only one player (currently double send) + }; + if (this.game.type == "live") { + GameStorage.update(this.gameRef, scoreObj); + // Notify myself locally if I'm elsewhere: + if (!this.focus) { + notify( + "Game over", + { body: score + " : " + scoreMsg } + ); + } + if (!!callback) callback(); + } + else this.updateCorrGame(scoreObj, callback); + // Notify the score to main Hall. + // TODO: only one player (currently double send) this.send("result", { gid: this.game.id, score: score }); + // Also to MyGames page (TODO: doubled as well...) + this.notifyMyGames( + "score", + { + gid: this.gameRef, + score: score + } + ); } + else if (!!callback) callback(); } } };