From c292ebb2a014646005b01e27253c162f1d639387 Mon Sep 17 00:00:00 2001
From: Benjamin Auder <benjamin.auder@somewhere>
Date: Mon, 9 Mar 2020 10:37:30 +0100
Subject: [PATCH] Draft rematch (not working yet) + fix Crazyhouse
 getPromotedFen()

---
 client/src/utils/gameStorage.js   |   5 +-
 client/src/variants/Crazyhouse.js |   8 +-
 client/src/views/Game.vue         | 176 +++++++++++++++++++++++++++---
 client/src/views/Hall.vue         |  66 ++++++-----
 server/models/Game.js             |  12 +-
 server/routes/games.js            |   5 +-
 server/sockets.js                 |  21 +++-
 7 files changed, 237 insertions(+), 56 deletions(-)

diff --git a/client/src/utils/gameStorage.js b/client/src/utils/gameStorage.js
index 92db2991..59efb43a 100644
--- a/client/src/utils/gameStorage.js
+++ b/client/src/utils/gameStorage.js
@@ -43,7 +43,7 @@ export const GameStorage = {
   // Optional callback to get error status
   add: function(game, callback) {
     dbOperation((err,db) => {
-      if (err) {
+      if (!!err) {
         callback("error");
         return;
       }
@@ -51,6 +51,9 @@ export const GameStorage = {
       transaction.oncomplete = function() {
         callback(); //everything's fine
       };
+      transaction.onerror = function(err) {
+        callback(err); //duplicate key error (most likely)
+      };
       let objectStore = transaction.objectStore("games");
       objectStore.add(game);
     });
diff --git a/client/src/variants/Crazyhouse.js b/client/src/variants/Crazyhouse.js
index 984c902f..d677823d 100644
--- a/client/src/variants/Crazyhouse.js
+++ b/client/src/variants/Crazyhouse.js
@@ -66,11 +66,11 @@ export const VariantRules = class CrazyhouseRules extends ChessRules {
     let res = "";
     for (let i = 0; i < V.size.x; i++) {
       for (let j = 0; j < V.size.y; j++) {
-        if (this.promoted[i][j]) res += V.CoordsToSquare({ x: i, y: j });
+        if (this.promoted[i][j]) res += V.CoordsToSquare({ x: i, y: j }) + ",";
       }
     }
+    // Remove last comma:
     if (res.length > 0) res = res.slice(0, -1);
-    //remove last comma
     else res = "-";
     return res;
   }
@@ -98,8 +98,8 @@ export const VariantRules = class CrazyhouseRules extends ChessRules {
     this.promoted = ArrayFun.init(V.size.x, V.size.y, false);
     if (fenParsed.promoted != "-") {
       for (let square of fenParsed.promoted.split(",")) {
-        const [x, y] = V.SquareToCoords(square);
-        this.promoted[x][y] = true;
+        const coords = V.SquareToCoords(square);
+        this.promoted[coords.x][coords.y] = true;
       }
     }
   }
diff --git a/client/src/views/Game.vue b/client/src/views/Game.vue
index d920053a..a7cb6a90 100644
--- a/client/src/views/Game.vue
+++ b/client/src/views/Game.vue
@@ -1,5 +1,13 @@
 <template lang="pug">
 main
+  input#modalInfo.modal(type="checkbox")
+  div#infoDiv(
+    role="dialog"
+    data-checkbox="modalInfo"
+  )
+    .card.text-center
+      label.modal-close(for="modalInfo")
+      p(v-html="infoMessage")
   input#modalChat.modal(
     type="checkbox"
     @click="resetChatColor()"
@@ -82,7 +90,8 @@ main
           img(src="/images/icons/resign.svg")
       button.tooltip(
         v-else-if="!!game.mycolor"
-        @click="rematch()"
+        @click="clickRematch()"
+        :class="{['rematch-' + rematchOffer]: true}"
         :aria-label="st.tr['Rematch']"
       )
         img(src="/images/icons/rematch.svg")
@@ -152,6 +161,7 @@ export default {
       virtualClocks: [],
       vr: null, //"variant rules" object initialized from FEN
       drawOffer: "",
+      rematchOffer: "",
       people: {}, //players + observers
       onMygames: [], //opponents (or me) on "MyGames" page
       lastate: undefined, //used if opponent send lastate before game is ready
@@ -257,6 +267,7 @@ export default {
       this.virtualClocks = [[0,0], [0,0]];
       this.vr = null;
       this.drawOffer = "";
+      this.rematchOffer = "";
       this.onMygames = [];
       this.lastate = undefined;
       this.newChat = "";
@@ -350,6 +361,17 @@ export default {
         )
       );
     },
+    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;
+    },
     resetChatColor: function() {
       // TODO: this is called twice, once on opening an once on closing
       document.getElementById("chatBtn").classList.remove("somethingnew");
@@ -393,10 +415,6 @@ export default {
       this.$router.push(
         "/game/" + nextGid + "/?next=" + JSON.stringify(this.nextIds));
     },
-    rematch: function() {
-      alert("Unimplemented yet (soon :) )");
-      // TODO: same logic as for draw, but re-click remove rematch offer (toggle)
-    },
     askGameAgain: function() {
       this.gameIsLoading = true;
       const currentUrl = document.location.href;
@@ -560,7 +578,7 @@ export default {
             .filter(k =>
               [
                 "id","fen","players","vid","cadence","fenStart","vname",
-                "moves","clocks","initime","score","drawOffer"
+                "moves","clocks","initime","score","drawOffer","rematchOffer"
               ].includes(k))
             .reduce(
               (obj, k) => {
@@ -580,7 +598,8 @@ export default {
           if (
             (this.game.moves.length > 0 && this.vr.turn != this.game.mycolor) ||
             this.game.score != "*" ||
-            this.drawOffer == "sent"
+            this.drawOffer == "sent" ||
+            this.rematchOffer == "sent"
           ) {
             // Send our "last state" informations to opponent
             const L = this.game.moves.length;
@@ -591,6 +610,7 @@ export default {
               // 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,
               score: this.game.scoreMsg,
               movesCount: L,
@@ -683,6 +703,41 @@ export default {
           // NOTE: observers don't know who offered draw
           this.drawOffer = "received";
           break;
+        case "rematchoffer":
+          // NOTE: observers don't know who offered rematch
+          this.rematchOffer = data.data ? "received" : "";
+          break;
+        case "newgame": {
+          // A game started, redirect if I'm playing in
+          const gameInfo = data.data;
+          if (
+            gameInfo.players.some(p =>
+              p.sid == this.st.user.sid || p.uid == this.st.user.id)
+          ) {
+            this.$router.push("/game/" + gameInfo.id);
+          } else {
+            let urlRid = "";
+            if (gameInfo.cadence.indexOf('d') === -1) {
+              urlRid = "/?rid=";
+              // Select sid of any of the online players:
+              let onlineSid = [];
+              gameInfo.players.forEach(p => {
+                if (!!this.people[p.sid]) onlineSid.push(p.sid);
+              });
+              urlRid += onlineSid[Math.floor(Math.random() * onlineSid.length)];
+            }
+            this.infoMessage =
+              this.st.tr["Rematch in progress:"] +
+              " <a href='#/game/" +
+              gameInfo.id + urlRid +
+              "'>" +
+              "#/game/" +
+              gameInfo.id + urlRid +
+              "</a>";
+            document.getElementById("modalInfo").checked = true;
+          }
+          break;
+        }
         case "newchat":
           this.newChat = data.data;
           if (!document.getElementById("modalChat").checked)
@@ -721,6 +776,7 @@ export default {
         this.processMove(data.lastMove, { clock: data.clock });
       }
       if (data.drawSent) this.drawOffer = "received";
+      if (data.rematchSent) this.rematchOffer = "received";
       if (data.score != "*") {
         this.drawOffer = "";
         if (this.game.score == "*")
@@ -754,6 +810,80 @@ export default {
         } else this.updateCorrGame({ drawOffer: this.game.mycolor });
       }
     },
+    clickRematch: function() {
+      if (!this.game.mycolor) return; //I'm just spectator
+      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
+        };
+        let oppsid = this.getOppsid(); //may be null
+        this.send("rnewgame", { data: gameInfo, oppsid: oppsid });
+        if (this.game.type == "live") {
+          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
+              initime: [0, 0], //initialized later
+              score: "*"
+            }
+          );
+          GameStorage.add(game, (err) => {
+            // No error expected.
+            if (!err) {
+              if (this.st.settings.sound)
+                new Audio("/sounds/newgame.flac").play().catch(() => {});
+              this.$router.push("/game/" + gameInfo.id);
+            }
+          });
+        }
+        else {
+          // corr game
+          ajax(
+            "/games",
+            "POST",
+            {
+              // cid is useful to delete the challenge:
+              data: { gameInfo: gameInfo },
+              success: (response) => {
+                gameInfo.id = response.gameId;
+                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.id,
+            { 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.id,
+            { rematchOffer: '' }
+          );
+        } else this.updateCorrGame({ rematchOffer: 'n' });
+      }
+    },
     abortGame: function() {
       if (!this.game.mycolor || !confirm(this.st.tr["Terminate game?"])) return;
       this.gameOver("?", "Stop");
@@ -845,6 +975,7 @@ export default {
             }
           }
         }
+        // TODO: merge next 2 "if" conditions
         if (!!game.drawOffer) {
           if (game.drawOffer == "t")
             // Three repetitions
@@ -863,6 +994,18 @@ export default {
             }
           }
         }
+        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;
         let vr_tmp = new V(game.fenStart);
@@ -1123,13 +1266,8 @@ export default {
                 clearInterval(this.retrySendmove);
                 return;
               }
-              let oppsid = this.game.players[nextIdx].sid;
-              if (!oppsid) {
-                oppsid = Object.keys(this.people).find(
-                  sid => this.people[sid].id == this.game.players[nextIdx].uid
-                );
-              }
-              if (!oppsid || !this.people[oppsid])
+              const oppsid = this.getOppsid();
+              if (!oppsid)
                 // Opponent is disconnected: he'll ask last state
                 clearInterval(this.retrySendmove);
               else {
@@ -1237,6 +1375,10 @@ export default {
 </script>
 
 <style lang="sass" scoped>
+#infoDiv > .card
+  padding: 15px 0
+  max-width: 430px
+
 .connected
   background-color: lightgreen
 
@@ -1337,6 +1479,12 @@ span.yourturn
 .draw-threerep, .draw-threerep:hover
   background-color: #e4d1fc
 
+.rematch-sent, .rematch-sent:hover
+  background-color: lightyellow
+
+.rematch-received, .rematch-received:hover
+  background-color: lightgreen
+
 .somethingnew
   background-color: #c5fefe
 
diff --git a/client/src/views/Hall.vue b/client/src/views/Hall.vue
index 9656c967..4cb46ec9 100644
--- a/client/src/views/Hall.vue
+++ b/client/src/views/Hall.vue
@@ -682,9 +682,8 @@ export default {
         }
         case "game": //individual request
         case "newgame": {
-          // NOTE: it may be live or correspondance
           const game = data.data;
-          // Ignore games where I play (corr games)
+          // Ignore games where I play (will go in MyGames page)
           if (game.players.every(p =>
             p.sid != this.st.user.sid || p.id != this.st.user.id))
           {
@@ -720,7 +719,7 @@ export default {
           break;
         }
         case "startgame": {
-          // New game just started: data contain all information
+          // New game just started, I'm involved
           const gameInfo = data.data;
           if (this.classifyObject(gameInfo) == "live")
             this.startNewGame(gameInfo);
@@ -733,8 +732,7 @@ export default {
               "#/game/" +
               gameInfo.id +
               "</a>";
-            let modalBox = document.getElementById("modalInfo");
-            modalBox.checked = true;
+            document.getElementById("modalInfo").checked = true;
           }
           break;
         }
@@ -964,16 +962,17 @@ export default {
       const notifyNewgame = () => {
         const oppsid = this.getOppsid(c);
         if (!!oppsid)
-          //opponent is online
+          // Opponent is online
           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 });
+        // Send game info (only if live) to everyone except me and opponent
+        // TODO: this double message send could be avoided.
+        this.send("newgame", { data: gameInfo, oppsid: oppsid });
       };
       if (c.type == "live") {
         notifyNewgame();
         this.startNewGame(gameInfo);
-      } //corr: game only on server
-      else {
+      } else {
+        // corr: game only on server
         ajax(
           "/games",
           "POST",
@@ -991,25 +990,36 @@ export default {
     },
     // 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,
-        vname: this.getVname(gameInfo.vid),
-        created: Date.now(),
-        // Game state (including FEN): will be updated
-        moves: [],
-        clocks: [-1, -1], //-1 = unstarted
-        initime: [0, 0], //initialized later
-        score: "*"
-      });
-      GameStorage.add(game, (err) => {
-        // If an error occurred, game is not added: abort
-        if (!err) {
-          if (this.st.settings.sound)
-            new Audio("/sounds/newgame.flac").play().catch(() => {});
-          this.$router.push("/game/" + gameInfo.id);
+      const game = Object.assign(
+        {},
+        gameInfo,
+        {
+          // (other) Game infos: constant
+          fenStart: gameInfo.fen,
+          vname: this.getVname(gameInfo.vid),
+          created: Date.now(),
+          // Game state (including FEN): will be updated
+          moves: [],
+          clocks: [-1, -1], //-1 = unstarted
+          initime: [0, 0], //initialized later
+          score: "*"
         }
-      });
+      );
+      setTimeout(
+        () => {
+          GameStorage.add(game, (err) => {
+            // If an error occurred, game is not added: a tab already
+            // added the game and (if focused) is redirected toward it.
+            // If no error and the tab is hidden: do not show anything.
+            if (!err && !document.hidden) {
+              if (this.st.settings.sound)
+                new Audio("/sounds/newgame.flac").play().catch(() => {});
+              this.$router.push("/game/" + gameInfo.id);
+            }
+          });
+        },
+        document.hidden ? 500 + 1000 * Math.random() : 0
+      );
     }
   }
 };
diff --git a/server/models/Game.js b/server/models/Game.js
index fc485910..e45c1827 100644
--- a/server/models/Game.js
+++ b/server/models/Game.js
@@ -212,6 +212,8 @@ const GameModel =
         )
       ) && (
         !obj.drawOffer || !!(obj.drawOffer.match(/^[wbtn]$/))
+      ) && (
+        !obj.rematchOffer || !!(obj.rematchOffer.match(/^[wbn]$/))
       ) && (
         !obj.fen || !!(obj.fen.match(/^[a-zA-Z0-9, /-]*$/))
       ) && (
@@ -234,12 +236,18 @@ const GameModel =
       let modifs = "";
       // NOTE: if drawOffer is set, we should check that it's player's turn
       // A bit overcomplicated. Let's trust the client on that for now...
-      if (obj.drawOffer)
+      if (!!obj.drawOffer)
       {
-        if (obj.drawOffer == "n") //Special "None" update
+        if (obj.drawOffer == "n") //special "None" update
           obj.drawOffer = "";
         modifs += "drawOffer = '" + obj.drawOffer + "',";
       }
+      if (!!obj.rematchOffer)
+      {
+        if (obj.rematchOffer == "n") //special "None" update
+          obj.rematchOffer = "";
+        modifs += "rematchOffer = '" + obj.rematchOffer + "',";
+      }
       if (!!obj.fen)
         modifs += "fen = '" + obj.fen + "',";
       if (!!obj.score)
diff --git a/server/routes/games.js b/server/routes/games.js
index 57656f41..21aee475 100644
--- a/server/routes/games.js
+++ b/server/routes/games.js
@@ -8,14 +8,15 @@ const params = require("../config/parameters");
 // From main hall, start game between players 0 and 1
 router.post("/games", access.logged, access.ajax, (req,res) => {
   const gameInfo = req.body.gameInfo;
+  // Challenge ID is provided if game start from Hall:
   const cid = req.body.cid;
   if (
     Array.isArray(gameInfo.players) &&
     gameInfo.players.some(p => p.id == req.userId) &&
-    cid.toString().match(/^[0-9]+$/) &&
+    (!cid || cid.toString().match(/^[0-9]+$/)) &&
     GameModel.checkGameInfo(gameInfo)
   ) {
-    ChallengeModel.remove(cid);
+    if (!!cid) ChallengeModel.remove(cid);
     GameModel.create(
       gameInfo.vid, gameInfo.fen, gameInfo.cadence, gameInfo.players,
       (err,ret) => {
diff --git a/server/sockets.js b/server/sockets.js
index 39cf260b..f3224f1f 100644
--- a/server/sockets.js
+++ b/server/sockets.js
@@ -190,13 +190,24 @@ module.exports = function(wss) {
         // Notify all room: mostly game events
         case "newchat":
         case "newchallenge":
-        case "newgame":
         case "deletechallenge":
+        case "newgame":
         case "resign":
         case "abort":
         case "drawoffer":
+        case "rematchoffer":
         case "draw":
-          notifyRoom(page, obj.code, {data: obj.data});
+          if (!!obj.oppsid)
+            // "newgame" message from Hall: do not target players
+            notifyAllBut(page, "newgame", {data: obj.data}, [sid, obj.oppsid]);
+          else notifyRoom(page, obj.code, {data: obj.data});
+          break;
+
+        case "rnewgame":
+          // A rematch game started: players are already informed
+          notifyAllBut(page, "newgame", {data: obj.data}, [sid, obj.oppsid]);
+          notifyAllBut("/", "newgame", {data: obj.data}, [sid, obj.oppsid]);
+          notifyRoom("/mygames", "newgame", {data: obj.data});
           break;
 
         case "newmove": {
@@ -267,11 +278,11 @@ module.exports = function(wss) {
 
         case "getfocus":
         case "losefocus":
-          if (page == "/") notifyAllButMe("/", obj.code, { page: "/" });
+          if (page == "/") notifyAllBut("/", obj.code, { page: "/" }, [sid]);
           else {
             // Notify game room + Hall:
-            notifyAllButMe(page, obj.code);
-            notifyAllButMe("/", obj.code, { page: page });
+            notifyAllBut(page, obj.code, {}, [sid]);
+            notifyAllBut("/", obj.code, { page: page }, [sid]);
           }
           break;
 
-- 
2.44.0