From 7f3484bd3530cd9f656e215d2d4d832d98bf5906 Mon Sep 17 00:00:00 2001 From: Benjamin Auder <benjamin.auder@somewhere> Date: Thu, 23 Jan 2020 10:11:31 +0100 Subject: [PATCH] Simplify abort, fix time in corr games --- client/src/views/Analyze.vue | 527 +++++++++++++++++++++++++++++++++++ client/src/views/Game.vue | 44 +-- 2 files changed, 540 insertions(+), 31 deletions(-) create mode 100644 client/src/views/Analyze.vue diff --git a/client/src/views/Analyze.vue b/client/src/views/Analyze.vue new file mode 100644 index 00000000..4ec370c5 --- /dev/null +++ b/client/src/views/Analyze.vue @@ -0,0 +1,527 @@ +<template lang="pug"> +.row + .col-sm-12.col-md-10.col-md-offset-1.col-lg-8.col-lg-offset-2 + input#modalAbort.modal(type="checkbox") + div(role="dialog" aria-labelledby="abortBoxTitle") + .card.smallpad.small-modal.text-center + label.modal-close(for="modalAbort") + h3#abortBoxTitle.section {{ st.tr["Terminate game?"] }} + button(@click="abortGame") {{ st.tr["Sorry I have to go"] }} + button(@click="abortGame") {{ st.tr["Game seems over"] }} + button(@click="abortGame") {{ st.tr["Opponent is gone"] }} + BaseGame(:game="game" :vr="vr" ref="basegame" + @newmove="processMove" @gameover="gameOver") + div Names: {{ game.players[0].name }} - {{ game.players[1].name }} + div(v-if="game.score=='*'") Time: {{ virtualClocks[0] }} - {{ virtualClocks[1] }} + .button-group(v-if="game.mode!='analyze' && game.score=='*'") + button(@click="offerDraw") Draw + button(@click="() => abortGame()") Abort + button(@click="resign") Resign + textarea(v-if="game.score=='*'" v-model="corrMsg") + Chat(:players="game.players") +</template> + +<script> +import BaseGame from "@/components/BaseGame.vue"; +import Chat from "@/components/Chat.vue"; +import { store } from "@/store"; +import { GameStorage } from "@/utils/gameStorage"; +import { ppt } from "@/utils/datetime"; +import { extractTime } from "@/utils/timeControl"; +import { ArrayFun } from "@/utils/array"; + +export default { + name: 'my-game', + components: { + 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: {players:[{name:""},{name:""}]}, //passed to BaseGame + corrMsg: "", //to send offline messages in corr games + virtualClocks: [0, 0], //initialized with true game.clocks + vr: null, //"variant rules" object initialized from FEN + drawOffer: "", //TODO: use for button style + people: [], //players + observers + }; + }, + watch: { + "$route": function(to, from) { + this.gameRef.id = to.params["id"]; + this.gameRef.rid = to.query["rid"]; + this.loadGame(); + }, + "game.clocks": function(newState) { + if (this.game.moves.length < 2) + { + // 1st move not completed yet: freeze time + this.virtualClocks = newState.map(s => ppt(s)); + return; + } + const currentTurn = this.vr.turn; + const colorIdx = ["w","b"].indexOf(currentTurn); + let countdown = newState[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(newState[i] - removeTime); + }); + let clockUpdate = setInterval(() => { + if (countdown < 0 || this.vr.turn != currentTurn || this.game.score != "*") + { + clearInterval(clockUpdate); + if (countdown < 0) + { + this.$refs["basegame"].endGame( + this.vr.turn=="w" ? "0-1" : "1-0", "Time"); + } + } + else + { + // TODO: with Vue 3, just do this.virtualClocks[colorIdx] = ppt(--countdown) + this.$set(this.virtualClocks, colorIdx, ppt(Math.max(0, --countdown))); + } + }, 1000); + }, + }, + // TODO: redundant code with Hall.vue (related to people array) + created: function() { + // Always add myself to players' list + const my = this.st.user; + this.people.push({sid: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 + if (!this.gameRef.rid) + this.loadGame(); //local or corr: can load from now + // 0.1] Ask server for room composition: + const initialize = () => { + // Poll clients + load game if stored remotely + this.st.conn.send(JSON.stringify({code:"pollclients"})); + if (!!this.gameRef.rid) + this.loadGame(); + }; + if (!!this.st.conn && this.st.conn.readyState == 1) //1 == OPEN state + initialize(); + else //socket not ready yet (initial loading) + this.st.conn.onopen = initialize; + this.st.conn.onmessage = this.socketMessageListener; + const socketCloseListener = () => { + store.socketCloseListener(); //reinitialize connexion (in store.js) + this.st.conn.addEventListener('message', this.socketMessageListener); + this.st.conn.addEventListener('close', socketCloseListener); + }; + this.st.conn.onclose = socketCloseListener; + }, + methods: { + getOppSid: function() { + if (!!this.game.oppsid) + return this.game.oppsid; + const opponent = this.people.find(p => p.id == this.game.oppid); + return (!!opponent ? opponent.sid : null); + }, + socketMessageListener: function(msg) { + const data = JSON.parse(msg.data); + switch (data.code) + { + // 0.2] Receive clients list (just socket IDs) + case "pollclients": + { + data.sockIds.forEach(sid => { + this.people.push({sid:sid, id:0, name:""}); + // Ask only identity + this.st.conn.send(JSON.stringify({code:"askidentity", target:sid})); + }); + break; + } + case "askidentity": + { + // Request for identification: reply if I'm not anonymous + if (this.st.user.id > 0) + { + this.st.conn.send(JSON.stringify( + // people[0] instead of st.user to avoid sending email + {code:"identity", user:this.people[0], target:data.from})); + } + break; + } + case "identity": + { + let player = this.people.find(p => p.sid == data.user.sid); + // NOTE: sometimes player.id fails because player is undefined... + // Probably because the event was meant for Hall? + if (!player) + return; + player.id = data.user.id; + player.name = data.user.name; + // Sending last state only for live games: corr games are complete + if (this.game.type == "live" && this.game.oppsid == player.sid) + { + // Send our "last state" informations to opponent + const L = this.game.moves.length; + this.st.conn.send(JSON.stringify({ + code: "lastate", + target: player.sid, + state: + { + lastMove: (L>0 ? this.game.moves[L-1] : undefined), + score: this.game.score, + movesCount: L, + drawOffer: this.drawOffer, + clocks: this.game.clocks, + } + })); + } + break; + } + case "askgame": + // Send current (live) game + const myGame = + { + // Minimal game informations: + id: this.game.id, + players: this.game.players.map(p => { return {name:p.name}; }), + vid: this.game.vid, + timeControl: this.game.timeControl, + }; + this.st.conn.send(JSON.stringify({code:"game", + game:myGame, target:data.from})); + break; + case "newmove": + // NOTE: this call to play() will trigger processMove() + this.$refs["basegame"].play(data.move, + "receive", this.game.vname!="Dark" ? "animate" : null); + break; + case "lastate": //got opponent infos about last move + { + const L = this.game.moves.length; + if (data.movesCount > L) + { + // Just got last move from him + this.$refs["basegame"].play(data.lastMove, + "receive", this.game.vname!="Dark" ? "animate" : null); + if (data.score != "*" && this.game.score == "*") + { + // Opponent resigned or aborted game, or accepted draw offer + // (this is not a stalemate or checkmate) + this.$refs["basegame"].endGame(data.score, "Opponent action"); + } + this.game.clocks = data.clocks; //TODO: check this? + this.drawOffer = data.drawOffer; //does opponent offer draw? + } + break; + } + case "resign": + this.$refs["basegame"].endGame( + this.game.mycolor=="w" ? "1-0" : "0-1", "Resign"); + break; + case "abort": + this.$refs["basegame"].endGame("?", "Abort: " + data.msg); + break; + case "draw": + this.$refs["basegame"].endGame("1/2", "Mutual agreement"); + break; + case "drawoffer": + this.drawOffer = "received"; + break; + case "askfullgame": + // TODO: use data.id to retrieve game in indexedDB (but for now only one running game so OK) + this.st.conn.send(JSON.stringify({code:"fullgame", game:this.game, target:data.from})); + break; + case "fullgame": + this.loadGame(data.game); + break; + // TODO: drawaccepted (click draw button before sending move + // ==> draw offer in move) + // ==> on "newmove", check "drawOffer" field + case "connect": + { + this.people.push({name:"", id:0, sid:data.from}); + this.st.conn.send(JSON.stringify({code:"askidentity", target:data.from})); + break; + } + case "disconnect": + ArrayFun.remove(this.people, p => p.sid == data.from); + break; + } + }, + offerDraw: function() { + // TODO: also for corr games + if (this.drawOffer == "received") + { + if (!confirm("Accept draw?")) + return; + const oppsid = this.getOppSid(); + if (!!oppsid) + this.st.conn.send(JSON.stringify({code:"draw", target:oppsid})); + this.$refs["basegame"].endGame("1/2", "Mutual agreement"); + } + else if (this.drawOffer == "sent") + this.drawOffer = ""; + else + { + if (!confirm("Offer draw?")) + return; + const oppsid = this.getOppSid(); + if (!!oppsid) + this.st.conn.send(JSON.stringify({code:"drawoffer", target:oppsid})); + } + }, + // + conn handling: "draw" message ==> agree for draw (if we have "drawOffered" at true) + receiveDrawOffer: function() { + //if (...) + // TODO: ignore if preventDrawOffer is set; otherwise show modal box with option "prevent future offers" + // if accept: send message "draw" + }, + abortGame: function(event) { + let modalBox = document.getElementById("modalAbort"); + if (!event) + { + // First call show options: + modalBox.checked = true; + } + else + { + modalBox.checked = false; //decision made: box disappear + const message = event.target.innerText; + // Next line will trigger a "gameover" event, bubbling up till here + this.$refs["basegame"].endGame("?", "Abort: " + message); + const oppsid = this.getOppSid(); + if (!!oppsid) + { + this.st.conn.send(JSON.stringify({ + code: "abort", + msg: message, + target: oppsid, + })); + } + } + }, + resign: function(e) { + if (!confirm("Resign the game?")) + return; + const oppsid = this.getOppSid(); + if (!!oppsid) + { + this.st.conn.send(JSON.stringify({ + code: "resign", + target: oppsid, + })); + } + // Next line will trigger a "gameover" event, bubbling up till here + this.$refs["basegame"].endGame( + this.game.mycolor=="w" ? "0-1" : "1-0", "Resign"); + }, + // 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) { + 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.timeControl.indexOf('d') >= 0 ? "corr" : "live"); + const tc = extractTime(game.timeControl); + 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.initime = [0, 0]; + const L = game.moves.length; + game.moves.sort((m1,m2) => m1.idx - m2.idx); //in case of + 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; + // Now that we used idx and played, re-format moves as for live games + game.moves = game.moves.map( (m) => { + const s = m.squares; + return { + appear: s.appear, + vanish: s.vanish, + start: s.start, + end: s.end, + message: m.message, + }; + }); + } + const myIdx = game.players.findIndex(p => { + return p.sid == this.st.user.sid || p.uid == this.st.user.id; + }); + if (gtype == "live" && game.clocks[0] < 0) //game unstarted + { + game.clocks = [tc.mainTime, tc.mainTime]; + 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, + }); + } + } + this.game = Object.assign({}, + game, + // NOTE: assign mycolor here, since BaseGame could also be VS computer + { + type: gtype, + increment: tc.increment, + mycolor: [undefined,"w","b"][myIdx+1], + // 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), + } + ); + }; + if (!!game) + return afterRetrieval(game); + if (!!this.gameRef.rid) + { + // Remote live game + // (TODO: send game ID as well, and receiver would pick the corresponding + // game in his current games; if allowed to play several) + this.st.conn.send(JSON.stringify( + {code:"askfullgame", target:this.gameRef.rid})); + // (send moves updates + resign/abort/draw actions) + } + else + { + // Local or corr game + GameStorage.get(this.gameRef.id, afterRetrieval); + } + }, + // Post-process a move (which was just played) + processMove: function(move) { + if (!this.game.mycolor) + return; //I'm just an observer + // Update storage (corr or live) + const colorIdx = ["w","b"].indexOf(move.color); + // https://stackoverflow.com/a/38750895 + const allowed_fields = ["appear", "vanish", "start", "end"]; + const 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.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; + } + let sendMove = Object.assign({}, filtered_move, {addTime: addTime}); + if (this.game.type == "corr") + sendMove.message = this.corrMsg; + const oppsid = this.getOppSid(); + this.people.forEach(p => { + if (p.sid != this.st.user.sid) + { + this.st.conn.send(JSON.stringify({ + code: "newmove", + target: p.sid, + move: sendMove, + })); + } + }); + if (this.game.type == "corr" && this.corrMsg != "") + { + // Add message to last move in BaseGame: + // TODO: not very good style... + this.$refs["basegame"].setCurrentMessage(this.corrMsg); + } + } + else + addTime = move.addTime; //supposed transmitted + const nextIdx = ["w","b"].indexOf(this.vr.turn); + // Since corr games are stored at only one location, update should be + // done only by one player for each move: + if (this.game.type == "live" || move.color == this.game.mycolor) + { + if (this.game.type == "corr") + { + GameStorage.update(this.gameRef.id, + { + fen: move.fen, + move: + { + squares: filtered_move, + message: this.corrMsg, + played: Date.now(), //TODO: on server? + idx: this.game.moves.length, + }, + }); + } + else //live + { + GameStorage.update(this.gameRef.id, + { + fen: move.fen, + move: filtered_move, + clocks: this.game.clocks.map((t,i) => i==colorIdx + ? this.game.clocks[i] + addTime + : this.game.clocks[i]), + initime: this.game.initime.map((t,i) => i==nextIdx + ? Date.now() + : this.game.initime[i]), + }); + } + } + // Also update current game object: + this.game.moves.push(move); + this.game.fen = move.fen; + //TODO: just this.game.clocks[colorIdx] += addTime; + this.$set(this.game.clocks, colorIdx, this.game.clocks[colorIdx] + addTime); + this.game.initime[nextIdx] = Date.now(); + // Finally reset curMoveMessage if needed + if (this.game.type == "corr" && move.color == this.game.mycolor) + this.corrMsg = ""; + }, + gameOver: function(score) { + this.game.mode = "analyze"; + this.game.score = score; + const myIdx = this.game.players.findIndex(p => { + return p.sid == this.st.user.sid || p.uid == this.st.user.id; + }); + if (myIdx >= 0) //OK, I play in this game + GameStorage.update(this.gameRef.id, { score: score }); + }, + }, +}; +</script> + +<style lang="sass"> +// TODO +</style> diff --git a/client/src/views/Game.vue b/client/src/views/Game.vue index 2d085c87..6146e270 100644 --- a/client/src/views/Game.vue +++ b/client/src/views/Game.vue @@ -1,21 +1,13 @@ <template lang="pug"> .row .col-sm-12.col-md-10.col-md-offset-1.col-lg-8.col-lg-offset-2 - input#modalAbort.modal(type="checkbox") - div(role="dialog" aria-labelledby="abortBoxTitle") - .card.smallpad.small-modal.text-center - label.modal-close(for="modalAbort") - h3#abortBoxTitle.section {{ st.tr["Terminate game?"] }} - button(@click="abortGame") {{ st.tr["Sorry I have to go"] }} - button(@click="abortGame") {{ st.tr["Game seems over"] }} - button(@click="abortGame") {{ st.tr["Opponent is gone"] }} BaseGame(:game="game" :vr="vr" ref="basegame" @newmove="processMove" @gameover="gameOver") div Names: {{ game.players[0].name }} - {{ game.players[1].name }} div(v-if="game.score=='*'") Time: {{ virtualClocks[0] }} - {{ virtualClocks[1] }} .button-group(v-if="game.mode!='analyze' && game.score=='*'") button(@click="offerDraw") Draw - button(@click="() => abortGame()") Abort + button(@click="abortGame") Abort button(@click="resign") Resign textarea(v-if="game.score=='*'" v-model="corrMsg") Chat(:players="game.players") @@ -102,8 +94,6 @@ export default { this.gameRef.rid = this.$route.query["rid"]; //may be undefined if (!this.gameRef.rid) this.loadGame(); //local or corr: can load from now - // TODO: mode analyse (/analyze/Atomic/rn - // ... fen = query[], vname=params[] ... // 0.1] Ask server for room composition: const initialize = () => { // Poll clients + load game if stored remotely @@ -226,7 +216,7 @@ export default { this.game.mycolor=="w" ? "1-0" : "0-1", "Resign"); break; case "abort": - this.$refs["basegame"].endGame("?", "Abort: " + data.msg); + this.$refs["basegame"].endGame("?", "Abort"); break; case "draw": this.$refs["basegame"].endGame("1/2", "Mutual agreement"); @@ -283,29 +273,20 @@ export default { // TODO: ignore if preventDrawOffer is set; otherwise show modal box with option "prevent future offers" // if accept: send message "draw" }, - abortGame: function(event) { - let modalBox = document.getElementById("modalAbort"); - if (!event) - { - // First call show options: - modalBox.checked = true; - } - else - { - modalBox.checked = false; //decision made: box disappear - const message = event.target.innerText; - // Next line will trigger a "gameover" event, bubbling up till here - this.$refs["basegame"].endGame("?", "Abort: " + message); - const oppsid = this.getOppSid(); - if (!!oppsid) + abortGame: function() { + if (!confirm(this.st.tr["Terminate game?"])) + return; + // Next line will trigger a "gameover" event, bubbling up till here + this.$refs["basegame"].endGame("?", "Abort"); + this.people.forEach(p => { + if (p.sid != this.st.user.sid) { this.st.conn.send(JSON.stringify({ code: "abort", - msg: message, - target: oppsid, + target: p.sid, })); } - } + }); }, resign: function(e) { if (!confirm("Resign the game?")) @@ -342,6 +323,7 @@ export default { [ 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.initime = [0, 0]; const L = game.moves.length; @@ -352,7 +334,7 @@ export default { for (let i=2; i<L; i++) { addTime[i%2] += tc.increment - - (game.moves[i].played - game.moves[i-1].played); + (game.moves[i].played - game.moves[i-1].played) / 1000; } for (let i=0; i<=1; i++) game.clocks[i] += addTime[i]; -- 2.44.0