Merge branch 'lmazet/trivabble-avoid_serving_git_file'
authorRaphaël Jakse <raphael.trivabble@jakse.fr>
Sun, 3 May 2020 11:43:52 +0000 (13:43 +0200)
committerRaphaël Jakse <raphael.trivabble@jakse.fr>
Sun, 3 May 2020 11:43:52 +0000 (13:43 +0200)
1  2 
public/l10n/js/fr.js
public/trivabble.css
public/trivabble.js
server/trivabble-server.js

index 0000000000000000000000000000000000000000,533c13c5aa3b9b334182e216e46020991dd9d8b1..5c6c727d47f9efd3868b7e0c80f2d2a472727e90
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,1 +1,1 @@@
 -(function(){var _=myAlert.l10n;_("fr","OK","OK");_("fr","Yes","Oui");_("fr","No","Non");_("fr","Cancel","Annuler");_=trivabble.l10n;_("fr","Your game is not over. Are you sure you want to leave now?","Votre partie n'est pas terminée. Êtes-vous sûr·e de vouloir la quitter ?");_("fr","You are about to leave the current game. To recover it, please note its number: {0}","Vous allez quitter la partie en cours. Pour la retrouver, notez bien son numéro : {0}");_("fr","Sorry, a problem just happened. The page must be reloaded. If the problem is not too serious, you should be able to keep playing normally. Otherwise, contact the person who is able to fix the problem. Click on “Yes” to reload the page.","Désolé, une erreur s'est produite. La page doit être chargée de nouveau et si le problème n'est pas très grave, vous devriez pouvoir continuer à jouer normalement. Sinon, contactez la personne qui devrait pouvoir régler le problème. Cliquez sur « Oui » pour recharger la page.");_("fr","To join a game, please give the number which is displayed on your adversary(ies)' screen.\nIf you do not know it, ask them.\n\nWarning: your adversary must not take your number, (s)he must keep his/her own. If you whish to recover your current game, please not the following number: {0}.","Pour rejoindre une partie, donnez le numéro qui s'affiche chez votre (vos) adversaire(s).\nSi vous ne le connaissez pas, demandez-lui (leur).\n\nAttention : votre adversaire ne doit pas prendre votre numéro, il doit garder le sien. Si vous souhaitez retrouver votre partie actuelle plus tard, gardez le numéro suivant : {0}.");_("fr","It seems your did not give a correct number, or you clicked on “Cancel”. As a result, the current game continues, if any. To join a game, click on “Join a game” again.","Il semblerait que vous n'avez pas donné un numéro correct, ou que vous avez cliqué sur « Annuler ». Par conséquent, la partie en cours continue, s'il y a une partie en cours. Pour rejoindre une partie, cliquez à nouveau sur « Rejoindre une partie ».");_("fr","To change your name, enter a new one. You can keep using your current name by cancelling. Please note that if you change your name and you have games in progress, you will not be able to keep playing them anymore unless you get back to your current name.","Pour changer de nom, saisissez-en un nouveau. Vous pouvez continuer à utiliser votre ancien nom en annulant. Veuillez noter que si vous changez de nom et que vous avez des parties en cours, vous ne pourrez plus jouer à celles-ci, sauf si vous reprenez votre ancien nom.");_("fr","You cannot take another tile: your rack is full.","Vous ne pouvez pas tirer de pièce : votre chevalet est complet.");_("fr","Are you sure you want to put all the tiles back in the bag (in order to play another game)?","Êtes-vous sûr·e de vouloir remettre toutes les lettres du jeu dans le sac (en vue de jouer une nouvelle partie) ?");_("fr","Are you sure you want to put all your tiles back in the bag?","Êtes-vous sûr·e de vouloir remettre toutes vos lettres dans le sac ?");_("fr","It seems your did not give your name. You need to do it for the game to run properly.","Il semblerait que vous n'avez pas renseigné votre nom. Il est nécessaire de le faire pour le bon déroulement du jeu.");_("fr","Hello! To begin, enter your name. Your adversaries will see this name when you play with them.","Bonjour ! Pour commencer, saisissez votre nom. Vos adversaires verront ce nom quand vous jouerez avec eux.");_("fr","Double\nLetter","Lettre\nDouble");_("fr","Double\nWord","Mot\nDouble");_("fr","Triple\nLetter","Lettre\nTriple");_("fr","Triple\nWord","Mot\nTriple");_("fr","Join adversaries","Rejoindre des adversaires");_("fr","You are:","Vous êtes :");_("fr","Change","Changer");_("fr","(name to give)","(nom à renseigner)");_("fr","Put back all the tiles\nin the bag","Remettre toutes les pièces\ndans le sac");_("fr","Number of your game:","Numéro de votre partie :");_("fr","(pending)","(en attente)");_("fr","Write a message to your adversaries here","Écrivez un message à vos adversaires ici");_("fr","Send message","Envoyer le message");_("fr","Sound of the tiles","Bruit des pièces");_("fr","Sound of messages","Bruit des messages");_("fr","Number of tiles in the bag:","Nombre de pièces dans le sac :");_("fr","Click on it to take one.","Cliquez dessus pour en prendre une.");_("fr","Put back all the tiles in the bag","Ranger toutes les pièces dans le sac");_("fr","Participant","Participant·e");_("fr","Rack","Chevalet");_("fr","Score","Score");_("fr","Waiting for other participants…","En attente des autres participants…");_("fr","Click on someone's score\nto change it.","Cliquez sur le score de quelqu'un\npour le changer.");_("fr","Put back all the tiles of\nyour rack in the bag","Remettre toutes les pièces du\nchevalet dans le sac");_("fr","Interface language:","Langue de l'interface :");_("fr","Sorry to disturb you, but we need to reload the game so you can continue playing. You will not lose anything except the messages in the chat. Are you ready?","Désolé pour le dérangement, mais nous avons besoin de recharger le jeu pour que vous poussiez continuer à jouer. Vous ne perdrez rien sauf les messages dans le chat. Êtes-vous prêt·e ?");_("fr","To continue playing, click here","Pour continuer à jouer, cliquez ici");_("fr","{0}: ","{0} : ");_("fr","Whoops, there is a problem. We are trying to fix it as soon as possible. Please wait a few seconds. If it persists, please contact the person who is able to fix the problem.","Oups, il y a un problème. Nous essayons de corriger cela le plus vite possible. Merci de bien vouloir patienter quelques secondes. Si cela persiste, n’hésitez pas à contacter la personne qui peut s’occuper de ce problème");_("fr","The problem is solved, sorry for the inconvenience!","Le problème est résolu. Désolé pour la gêne occasionnée !");_("fr","It seems your browser went offline. Please reconnect to continue playing.","On dirait que votre navigateur est hors ligne. Merci de bien vouloir vous reconnecter pour continuer à jouer.");_("fr","Turn","Tour");_("fr","Who's turn? Click on the Turn button!","À qui le tour ? Cliquez sur le bouton Tour !");_("fr","Show my rack to other players","Montrer mon jeu aux autres");_("fr","Are you sure you want to show your rack to everybody?","Êtes-vous sûr·e de vouloir montrer votre jeu à tout le monde ?");_("fr","jocker","jocker");_("fr","Here is my rack:","Voici mon jeu :");_("fr","{0} shows their rack:","{0} montre son jeu :");_("fr","You cannot take another tile: the bag is empty.","Vous ne pouvez pas tirer de pièce : le sac est vide.");if(trivabble.applyL10n){trivabble.applyL10n();}})();
++(function(){var _=myAlert.l10n;_("fr","OK","OK");_("fr","Yes","Oui");_("fr","No","Non");_("fr","Cancel","Annuler");_=trivabble.l10n;_("fr","Your game is not over. Are you sure you want to leave now?","Votre partie n'est pas terminée. Êtes-vous sûr·e de vouloir la quitter ?");_("fr","You are about to leave the current game. To recover it, please note its number: {0}","Vous allez quitter la partie en cours. Pour la retrouver, notez bien son numéro : {0}");_("fr","Sorry, a problem just happened. The page must be reloaded. If the problem is not too serious, you should be able to keep playing normally. Otherwise, contact the person who is able to fix the problem. Click on “Yes” to reload the page.","Désolé, une erreur s'est produite. La page doit être chargée de nouveau et si le problème n'est pas très grave, vous devriez pouvoir continuer à jouer normalement. Sinon, contactez la personne qui devrait pouvoir régler le problème. Cliquez sur « Oui » pour recharger la page.");_("fr","Enter the new score of {0}:","Saisissez le nouveau score de {0} :");_("fr","Enter the score to add to {0}:","Saisissez le score à ajouter à {0} :");_("fr","To join a game, please give the number which is displayed on your adversary(ies)' screen.\nIf you do not know it, ask them.\n\nWarning: your adversary must not take your number, (s)he must keep his/her own. If you whish to recover your current game, please not the following number: {0}.","Pour rejoindre une partie, donnez le numéro qui s'affiche chez votre (vos) adversaire(s).\nSi vous ne le connaissez pas, demandez-lui (leur).\n\nAttention : votre adversaire ne doit pas prendre votre numéro, il doit garder le sien. Si vous souhaitez retrouver votre partie actuelle plus tard, gardez le numéro suivant : {0}.");_("fr","It seems your did not give a correct number, or you clicked on “Cancel”. As a result, the current game continues, if any. To join a game, click on “Join a game” again.","Il semblerait que vous n'avez pas donné un numéro correct, ou que vous avez cliqué sur « Annuler ». Par conséquent, la partie en cours continue, s'il y a une partie en cours. Pour rejoindre une partie, cliquez à nouveau sur « Rejoindre une partie ».");_("fr","To change your name, enter a new one. You can keep using your current name by cancelling. Please note that if you change your name and you have games in progress, you will not be able to keep playing them anymore unless you get back to your current name.","Pour changer de nom, saisissez-en un nouveau. Vous pouvez continuer à utiliser votre ancien nom en annulant. Veuillez noter que si vous changez de nom et que vous avez des parties en cours, vous ne pourrez plus jouer à celles-ci, sauf si vous reprenez votre ancien nom.");_("fr","You cannot take another tile: your rack is full.","Vous ne pouvez pas tirer de pièce : votre chevalet est complet.");_("fr","Are you sure you want to put all the tiles back in the bag (in order to play another game)?","Êtes-vous sûr·e de vouloir remettre toutes les lettres du jeu dans le sac (en vue de jouer une nouvelle partie) ?");_("fr","Are you sure you want to put all your tiles back in the bag?","Êtes-vous sûr·e de vouloir remettre toutes vos lettres dans le sac ?");_("fr","It seems your did not give your name. You need to do it for the game to run properly.","Il semblerait que vous n'avez pas renseigné votre nom. Il est nécessaire de le faire pour le bon déroulement du jeu.");_("fr","Hello! To begin, enter your name. Your adversaries will see this name when you play with them.","Bonjour ! Pour commencer, saisissez votre nom. Vos adversaires verront ce nom quand vous jouerez avec eux.");_("fr","Double\nLetter","Lettre\nDouble");_("fr","Double\nWord","Mot\nDouble");_("fr","Triple\nLetter","Lettre\nTriple");_("fr","Triple\nWord","Mot\nTriple");_("fr","Join adversaries","Rejoindre des adversaires");_("fr","You are:","Vous êtes :");_("fr","Change","Changer");_("fr","(name to give)","(nom à renseigner)");_("fr","Put back all the tiles\nin the bag","Remettre toutes les pièces\ndans le sac");_("fr","Number of your game:","Numéro de votre partie :");_("fr","(pending)","(en attente)");_("fr","Write a message to your adversaries here","Écrivez un message à vos adversaires ici");_("fr","Send message","Envoyer le message");_("fr","Sound of the tiles","Bruit des pièces");_("fr","Sound of messages","Bruit des messages");_("fr","Number of tiles in the bag:","Nombre de pièces dans le sac :");_("fr","Click on it to take one.","Cliquez dessus pour en prendre une.");_("fr","Put back all the tiles in the bag","Ranger toutes les pièces dans le sac");_("fr","Participant","Participant·e");_("fr","Rack","Chevalet");_("fr","Score","Score");_("fr","Waiting for other participants…","En attente des autres participants…");_("fr","Click on someone's score\nto change it.","Cliquez sur le score de quelqu'un\npour le changer.");_("fr","Put back all the tiles of\nyour rack in the bag","Remettre toutes les pièces du\nchevalet dans le sac");_("fr","Interface language:","Langue de l'interface :");_("fr","Sorry to disturb you, but we need to reload the game so you can continue playing. You will not lose anything except the messages in the chat. Are you ready?","Désolé pour le dérangement, mais nous avons besoin de recharger le jeu pour que vous poussiez continuer à jouer. Vous ne perdrez rien sauf les messages dans le chat. Êtes-vous prêt·e ?");_("fr","To continue playing, click here","Pour continuer à jouer, cliquez ici");_("fr","{0}: ","{0} : ");_("fr","Whoops, there is a problem. We are trying to fix it as soon as possible. Please wait a few seconds. If it persists, please contact the person who is able to fix the problem.","Oups, il y a un problème. Nous essayons de corriger cela le plus vite possible. Merci de bien vouloir patienter quelques secondes. Si cela persiste, n’hésitez pas à contacter la personne qui peut s’occuper de ce problème");_("fr","The problem is solved, sorry for the inconvenience!","Le problème est résolu. Désolé pour la gêne occasionnée !");_("fr","It seems your browser went offline. Please reconnect to continue playing.","On dirait que votre navigateur est hors ligne. Merci de bien vouloir vous reconnecter pour continuer à jouer.");_("fr","Turn","Tour");_("fr","Who's turn? Click on the Turn button!","À qui le tour ? Cliquez sur le bouton Tour !");_("fr","Show my rack to other players","Montrer mon jeu aux autres");_("fr","Are you sure you want to show your rack to everybody?","Êtes-vous sûr·e de vouloir montrer votre jeu à tout le monde ?");_("fr","jocker","jocker");_("fr","Here is my rack:","Voici mon jeu :");_("fr","{0} shows their rack:","{0} montre son jeu :");_("fr","You cannot take another tile: the bag is empty.","Vous ne pouvez pas tirer de pièce : le sac est vide.");if(trivabble.applyL10n){trivabble.applyL10n();}})();
index 0000000000000000000000000000000000000000,80cf56679c01cd53434d646461eb4821199e3509..b36380735a7560407f1597cbce655c34630aaa31
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,447 +1,462 @@@
 -    padding:0.33ex 1ex
+ #board {
+     border-collapse:collapse;
+ }
+ 
+ a {
+     text-decoration:none;
+     color:blue;
+ }
+ 
+ html {
+     font-family: "Droid Sans", sans-serif;
+ }
+ 
+ html, #board, [draggable], .tile {
+     -webkit-touch-callout: none;
+     -webkit-user-select: none;
+     -khtml-user-select: none;
+     -moz-user-select: none;
+     -ms-user-select: none;
+     user-select: none;
+ 
+   -moz-user-select: none;
+   -khtml-user-select: none;
+   -webkit-user-select: none;
+   user-select: none;
+ }
+ 
+ #chat-messages, #number, #name {
+     -webkit-touch-callout: default;
+     -webkit-user-select: element;
+     -khtml-user-select: element;
+     -moz-user-select: element;
+     -ms-user-select: element;
+     user-select: element;
+ 
+   -moz-user-select: element;
+   -khtml-user-select: element;
+   -webkit-user-select: element;
+   user-select: element;
+ }
+ 
+ #chat-messages .error {
+     color: #F66;
+ }
+ 
+ #chat-messages .ok {
+     color: green;
+ }
+ 
+ #clear-game, #help-scores, #clear-rack, #help-turn {
+     white-space:pre-wrap;
+ }
+ 
+ [draggable] {
+   /* Required to make elements draggable in old WebKit */
+   -khtml-user-drag: element;
+   -webkit-user-drag: element;
+ }
+ 
+ #help-clear, #help-bag, #help-scores, #help-turn {
+     font-size:small
+ }
+ 
+ #board td {
+     text-transform:uppercase;
+     border:1px solid #220;
+     background:#FFC;
+     vertical-align:middle;
+     text-align:center;
+ }
+ 
+ #board td .tile-placeholder {
+     position: relative;
+     height: 100%;
+     width: 100%;
+     top: 0px;
+     left: 0px;
+     display: flex;
+     justify-content: center;
+     flex-direction: column;
+ }
+ 
+ #board th {
+     font-size:small;
+     color:gray;
+     font-weight:normal
+ }
+ 
+ .corner {
+     visibility:hidden;
+     border:none !important
+ }
+ 
+ .special-cell-tripleWord {
+     background:#F77 !important;
+     box-shadow: 0 0 1px #F77;
+ }
+ 
+ .special-cell-tripleLetter {
+     background:#77F !important;
+     box-shadow: 0 0 1px #77F;
+ }
+ 
+ .special-cell-doubleLetter {
+     background:#7CF !important;
+     box-shadow: 0 0 1px #7CF;
+ }
+ 
+ .special-cell-doubleWord {
+     background:#FBB !important;
+     box-shadow: 0 0 1px #FBB;
+ }
+ 
+ .special-cell-label {
+     display:inline-block;
+     white-space:pre-wrap;
+     font-size:7px;
+ }
+ 
+ #center-cell .special-cell-label {
+     font-weight:bold;
+     color:black;
+     font-size:large
+ }
+ 
+ body {
+     display:-webkit-flex;
+     display:flex;
+     -webkit-flex-direction:row;
+     flex-direction:row;
+     justify-content:safe center;
+ }
+ 
+ @media (max-width: 940px) {
+     body {
+         justify-content:start;
+     }
+ }
+ 
+ #menu, #panel {
+     display:-webkit-flex;
+     display:flex;
+     -webkit-flex-direction:column;
+     flex-direction:column
+ }
+ 
+ button {
+     margin: 0.25em 0;
+     min-height:3em;
+     background:#AFA;
+     border:1px solid gray;
+ }
+ 
+ button:hover {
+     background:#FD7;
+     cursor:pointer
+ }
+ 
+ #rack {
+     background:#EEC;
+     font-size:0;
+     border-radius:4px;
+     display:inline-block;
+     padding:2px 5px;
+     white-space:pre;
+     box-shadow:0 0 2px gray
+ }
+ 
+ #board td {
+     height:32px;
+     width: 32px;
+ }
+ 
+ #rack .tile-placeholder {
+     height:32px;
+     width: 32px;
+ }
+ 
+ .tile {
+     background-color: #EE8;
+     transition:background-color 1s, border 1s;
+     display: inline-block;
+     width: 95%;
+     height: 95%;
+     left: 2.5%;
+     top: 2.5%;
+     position: absolute;
+     border: 1px solid #AA5;
+     box-sizing: border-box;
+     text-align: center;
+     font-size:1rem;
+     cursor:pointer
+ }
+ 
+ .tile.tile-highlight {
+     background-color: #FFF;
+     font-weight: bold;
+     animation: highlight 4s ease-in;
+     border: 1px solid black;
+ }
+ 
+ @keyframes highlight {
+     0% {
+         background:red;
+         color:white;
+     }
+ 
+     100% {
+         background:white
+     }
+ }
+ 
+ .tile-score {
+     font-size: 0.5em;
+     position: absolute;
+     bottom: 1px;
+     right: 2px;
+ }
+ 
+ #chat-messages .tile {
+     position:relative;
+     cursor:auto;
+     top:0;
+     left:0;
+     width: 1.5em;
+     height: 1.5em;
+     margin:2px;
+     vertical-align:bottom;
+ }
+ 
+ #chat-messages .tile-letter {
+     font-size:0.9em;
+ }
+ 
+ #chat-messages .tile-score {
+     font-size: 0.4em;
+ }
+ 
+ #chat-messages.blink {
+     animation:chat-blink 1s
+ }
+ 
+ #chat-messages .info {
+     color:gray;
+     font-style:italic;
+ }
+ 
+ td.blink {
+     animation:td-blink 1s
+ }
+ 
+ .current-player {
+     font-weight:bold;
+ }
+ 
+ .current-player td:first-child:before {
+     position:absolute;
+     left:-1em;
+     color:red;
+     content: "→";
+ }
+ 
+ @keyframes chat-blink {
+     0% {
+         background:rgba(255, 255, 150, 0);
+     }
+ 
+     50% {
+         background:rgba(255, 255, 150, 1);
+     }
+ 
+     100% {
+         background:rgba(255, 255, 150, 0);
+     }
+ }
+ 
+ 
+ @keyframes td-blink {
+     0% {
+         background:rgba(100, 200, 255, 0);
+     }
+ 
+     50% {
+         background:rgba(100, 200, 255, 1);
+     }
+ 
+     100% {
+         background:rgba(100, 200, 255, 0);
+     }
+ }
+ 
+ 
+ 
+ .tile-target {
+     background:rgba(255,255,255, 0.9);
+ /* } */
+ 
+ /* #bag.tile-target, td .tile-target { */
+     outline: 1px solid #F33
+ }
+ 
+ #rack .tile-placeholder {
+     position:relative;
+     margin-left:1px;
+     margin-right:1px;
+     display:inline-block;
+     vertical-align:middle;
+     background:#CCA;
+ }
+ 
+ #panel {
+     -webkit-justify-content:center;
+     justify-content:center;
+     text-align:center
+ }
+ 
+ #bag, #rack-outer {
+     -webkit-align-self:center;
+     align-self:center;
+     text-align:center;
+ }
+ 
+ #p-name {
+     text-align:center;
+     font-size:small
+ }
+ 
+ .minibutton {
+     padding:0;
+     min-height:0;
+     font-size:small
+ }
+ 
+ .msg-sender {
+     color:blue;
+ }
+ 
+ #chat-messages {
+     overflow:auto
+ }
+ 
+ #bag {
+     width:165px;
+     height:160px;
+     background:url(bag.svg) no-repeat;
+     background-size:100% 100%
+ }
+ 
+ #bag:hover {
+     cursor:pointer;
+     opacity:0.8
+ }
+ 
+ #chat-messages {
+     border:1px solid silver;
+     box-sizing:border-box;
+     width: 100%;
+     margin-bottom:1px;
+     height:15em;
+ }
+ 
+ #chat-ta {
+     width:100%;
+     box-sizing:border-box;
+     height:3em
+ }
+ 
+ #participants {
+     font-size:small;
+     border-collapse:collapse;
+     margin:0 auto 1ex auto;
+ }
+ 
+ #participants th, #participants td {
+     border:1px solid gray;
 -#participants .score-cell {
 -    min-width:4em;
++    padding:0.33ex 0.5ex
+ }
+ 
 -#participants .score-cell:hover, .turn-cell button:hover {
++#participants .score-cell > span {
++    display:inline-block;
++    min-width:2.5em;
+ }
+ 
++#participants .score-cell > span:hover, .turn-cell button:hover {
+     cursor:pointer;
+     background:#FFA
+ }
+ 
++#participants .score-cell button {
++    min-height: auto;
++    font-size:x-small;
++    padding:0 0.5ex;
++    margin:0;
++    background:#CCC;
++    color:back;
++    border:0;
++}
++
++#participants .score-cell button:hover {
++    background:#DDD;
++}
++
+ #participants th {
+     font-weight:normal;
+     background:#EEE;
+     font-size:smaller
+ }
+ 
+ #participants-placeholder {
+     font-style:italic;
+ }
+ 
+ .turn-cell button {
+     padding:0 2px;
+     background:white;
+     min-height:1em;
+     height:1em;
+     margin:0;
+     display:flex;
+ }
+ 
+ .turn-cell img {
+     width:1em;
+     vertical-align: middle;
+ }
+ 
+ @media screen and (max-height:650px) {
+     #board td , #rack .tile-placeholder {
+         height:28px;
+         width: 28px;
+     }
+ }
+ @media screen and (max-height:600px) {
+     #board td , #rack .tile-placeholder {
+         height:24px;
+         width: 24px;
+     }
+ }
+ 
+ @media screen and (max-height:550px) {
+     #chat-messages {
+         height:7em;
+     }
+ }
+ 
+ #prefs-sound {
+     margin:1ex 0 0 0;
+ }
+ 
+ #prefs-sound p {
+     margin:0;
+     padding:0;
+     font-size:small
+ }
+ 
+ #lang-selection {
+     font-size:small;
+ }
+ 
+ #prefs-sound input, #prefs-sound label {
+     vertical-align:middle
+ }
index 0000000000000000000000000000000000000000,3e5268b608796c72217a33d3165efaa6567306ac..c1d71c621238ddaa04e5ce7194538ccc250912a3
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,1611 +1,1657 @@@
 -    const VERSION = 202004262313;
+ /**
+  * Copyright (C) 2016-2020 Raphaël Jakse <raphael.trivabble@jakse.fr>
+  *
+  * @licstart
+  * This file is part of Trivabble.
+  *
+  * Trivabble is free software: you can redistribute it and/or modify it
+  * under the terms of the GNU Affero General Public License (GNU AGPL)
+  * as published by the Free Software Foundation, either version 3 of
+  * the License, or (at your option) any later version.
+  *
+  * Trivabble is distributed in the hope that it will be useful, but
+  * WITHOUT ANY WARRANTY; without even the implied warranty of
+  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+  * GNU Affero General Public License for more details.
+  *
+  * You should have received a copy of the GNU Affero General Public License
+  * along with Trivabble.  If not, see <http://www.gnu.org/licenses/>.
+  * @licend
+  *
+  * @source: https://trivabble.1s.fr/
+  * @source: https://gitlab.com/raphj/trivabble/
+  */
+ 
+ /*global libD, myConfirm, myAlert, myPrompt*/
+ 
+ (function () {
+     "use strict";
+ 
 -                if (moveCMD.indexTo !== moveCMD.indexFrom) {
 -                    sendCmds([
 -                        (moveCMD.from === "rack")
 -                            ? moveRack
 -                            : moveCMD
 -                    ]);
++    const VERSION = 202005011900;
+ 
+     const Conf = window.TrivabbleConf || {};
+ 
+     function setConf(parameterName, defaultValue) {
+         if (typeof Conf[parameterName] === "undefined") {
+             Conf[parameterName] = defaultValue;
+         }
+     }
+ 
+     setConf("POLLING_DELAY",        2000);
+     setConf("ENABLE_WEBSOCKETS",    true);
+     setConf("ENABLE_EVENT_SOURCE",  true);
+     setConf("MAX_WEBSOCKET_ERRORS", 1);
+     setConf("APP_PATH",             "");
+ 
+     const _ = (window.libD && libD.l10n) ? libD.l10n() : function (s) {
+         return s;
+     };
+ 
+     const trivabble = window.trivabble = {l10n: _};
+ 
+     function format(s, v) {
+         return s.replace("{0}", v);
+     }
+ 
+     let board;
+     let rack;
+     const boardCells = [];
+     let scoreOf;
+     let bag;
+ 
+     const playerLetters = [];
+     let currentPlayer = "";
+ 
+     let audioNotification;
+     let audioChat;
+     let chatMessages;
+     let chatTextarea;
+     let helpBag;
+     let helpClear;
+ 
+     let tablePlayers = {};
+     let participantPlaceholder;
+     let participants;
+     let name;
+ 
+     let blockMove = 0;
+     let remainingLetters = 0;
+     let needsRestart = false;
+     const gameInProgress = false;
+ 
+     let eventSource = null;
+     let webSocket = null;
+ 
+     let connectionLostMessage = null;
+     let boundEventShowConnectionLost = null;
+     let retryPollingTimeout = 0;
+     let pollingServer = false;
+     let connectionStopped = false;
+ 
+     let currentMessageId = 1;
+     const waitingMsgs = [];
+ 
+     let serverVersion = 0;
+ 
+     // HTTP path url prefix for any socket (xhr,ess or ws) from browser to server access
+     function getApiEntryPoint() {
+         return Conf.APP_PATH + "/:trivabble";
+     }
+ 
+     function mouseDown(ele, fun, stop) {
+         const meth = stop ? "removeEventListener" : "addEventListener";
+         ele[meth]("mousedown", fun, false);
+     }
+ 
+     function mouseUp(ele, fun, stop) {
+         const meth = stop ? "removeEventListener" : "addEventListener";
+         ele[meth]("mouseup", fun, false);
+     }
+ 
+     function mouseMove(ele, fun, stop) {
+         const meth = stop ? "removeEventListener" : "addEventListener";
+         ele[meth]("mousemove", fun, false);
+     }
+ 
+     function setRack(rack) {
+         for (let i = 0; i < 7; i++) {
+             setTileParent(playerLetters[i], rack[i] || "");
+         }
+     }
+ 
+     function getFreeRackSpaceIndex() {
+         for (let i = 0; i < 7; i++) {
+             if (!playerLetters[i].getElementsByClassName("tile")[0]) {
+                 return i;
+             }
+         }
+ 
+         return -1;
+     }
+ 
+     function showConnectionLost(msg) {
+         unbindEventsShowConnectionLost();
+ 
+         if (!connectionLostMessage) {
+             connectionLostMessage = chatMessage("", msg || _("Whoops, there is a problem. We are trying to fix it as soon as possible. Please wait a few seconds. If it persists, please contact the person who is able to fix the problem."));
+             connectionLostMessage.classList.add("error");
+         }
+     }
+ 
+     function unbindEventsShowConnectionLost() {
+         if (boundEventShowConnectionLost) {
+             document.body.removeEventListener("mousemove", boundEventShowConnectionLost);
+             document.body.removeEventListener("mousedown", boundEventShowConnectionLost);
+             document.body.removeEventListener("touchstart", boundEventShowConnectionLost);
+             boundEventShowConnectionLost = null;
+         }
+     }
+ 
+     function removeElem(elem) {
+         elem.parentNode.removeChild(elem);
+     }
+ 
+     function connectionReady() {
+         pollingReady = true;
+         retriedImmediately = false;
+ 
+         unbindEventsShowConnectionLost();
+ 
+         if (connectionLostMessage) {
+             connectionLostMessage.querySelector(".msg-content").textContent = _("The problem is solved, sorry for the inconvenience!");
+             connectionLostMessage.classList.remove("error");
+             connectionLostMessage.classList.add("ok");
+ 
+             const removeMessage = removeElem.bind(null, connectionLostMessage);
+             connectionLostMessage.addEventListener("click", removeMessage);
+             connectionLostMessage.addEventListener("touch", removeMessage);
+ 
+             connectionLostMessage = null;
+         }
+     }
+ 
+     let tileInitCoords      = null;
+     let tileInitMouseCoords = null;
+     let tileDest            = null;
+     let tileInitDest        = null;
+     let movingTile          = null;
+     let rackBCR             = null;
+     let boardBCR            = null;
+     let bagBCR              = null;
+     let moveCMD             = null;
++    let rackChanged         = false;
+ 
+     function getLetter(l) {
+         const tile = l.getElementsByClassName("tile")[0];
+         if (!tile) {
+             return "";
+         }
+ 
+         return tile.getElementsByClassName("tile-letter")[0].textContent;
+     }
+ 
+     function isParentOf(p, elem) {
+         while (elem) {
+             if (elem === p) {
+                 return true;
+             }
+             elem = elem.parentNode;
+         }
+ 
+         return false;
+     }
+ 
+     function dragTileEnd() {
+         movingTile.style.left   = "";
+         movingTile.style.top    = "";
+         movingTile.style.width  = "";
+         movingTile.style.height = "";
+ 
+         mouseUp(document,   dragTileEnd,  true);
+         mouseMove(document, dragTileMove, true);
+ 
+         if (tileDest === bag) {
+             moveCMD.to = "bag";
+             moveCMD.indexTo = -1;
+             movingTile.parentNode.removeChild(movingTile);
+         } else if (tileDest) {
+             tileDest.appendChild(movingTile);
+ 
+             if (isParentOf(board, tileDest)) {
+                 for (const tile of [].slice.call(board.getElementsByClassName("tile-highlight"))) {
+                     tile.classList.remove("tile-highlight");
+                 }
+             }
+         } else if (tileInitDest.getElementsByClassName("tile")[0]) {
+             for (let i = 0; i < 7; i++) {
+                 if (!playerLetters[i].getElementsByClassName("tile")[0]) {
+                     playerLetters[i].appendChild(movingTile);
+                     break;
+                 }
+             }
+         } else {
+             tileInitDest.appendChild(movingTile);
+         }
+ 
+         if (tileDest) {
+             tileDest.classList.remove("tile-target");
+ 
+             const moveRack = {
+                 cmd: "setRack",
+                 rack: playerLetters.map(getLetter)
+             };
+ 
+             if (moveCMD.to === moveCMD.from) {
 -                sendCmds([moveCMD]);
++                if (moveCMD.indexTo === moveCMD.indexFrom) {
++                    if (rackChanged) {
++                        sendCmds([moveRack]);
++                    }
++                } else if (moveCMD.from === "rack") {
++                    sendCmds([moveRack]);
++                } else if (rackChanged) {
++                    sendCmds([moveCMD, moveRack]);
++                } else {
++                    sendCmds([moveCMD]);
+                 }
+             } else if (moveCMD.to === "rack") {
+                 moveRack.rack[moveCMD.indexTo] = "";
+                 sendCmds([moveRack, moveCMD]);
+             } else {
 -                    row.appendChild(document.createElement("td"));
++                if (rackChanged) {
++                    sendCmds([moveCMD, moveRack]);
++                } else {
++                    sendCmds([moveCMD]);
++                }
+             }
+         }
+ 
+         tileInitMouseCoords = null;
+         tileInitCoords      = null;
+         tileInitDest        = null;
+         tileDest            = null;
+         movingTile          = null;
+         boardBCR            = null;
+         bagBCR              = null;
+         rackBCR             = null;
+         moveCMD             = null;
+     }
+ 
+     function dragTileMove(e) {
+         let newLeft = (tileInitCoords.left + (e.clientX - tileInitMouseCoords.clientX));
+         let newTop  = (tileInitCoords.top  + (e.clientY - tileInitMouseCoords.clientY));
+ 
+         movingTile.style.left = (newLeft + window.scrollX) + "px";
+         movingTile.style.top  = (newTop  + window.scrollY) + "px";
+ 
+         let newDest = null;
+ 
+         newTop  += tileInitCoords.height / 2;
+         newLeft += tileInitCoords.width / 2;
+ 
+         if (
+             (newTop  > boardBCR.top  && newTop  < (boardBCR.top + boardBCR.height)) &&
+             (newLeft > boardBCR.left && newLeft < (boardBCR.left + boardBCR.width))
+         ) {
+             const rowIndex = Math.floor(
+                 (
+                     (newTop  - boardBCR.top)  / boardBCR.height
+                 ) * board.rows.length
+             );
+ 
+             if (rowIndex > 0 && rowIndex < board.rows.length - 1) {
+                 const row = board.rows[rowIndex];
+ 
+                 const colIndex = Math.floor(
+                     (
+                         (newLeft - boardBCR.left) / boardBCR.width
+                     ) * row.cells.length
+                 );
+ 
+                 if (colIndex > 0 && colIndex < row.cells.length - 1) {
+                     newDest = row.cells[colIndex].firstChild;
+                 }
+             }
+ 
+             if (newDest && newDest.getElementsByClassName("tile")[0]) {
+                 newDest = null;
+             }
+ 
+             if (newDest) {
+                 moveCMD.to = "board";
+                 moveCMD.indexTo = boardCells.indexOf(newDest.parentNode);
+             }
+         } else if (
+             (newTop  > rackBCR.top  && newTop  < (rackBCR.top + rackBCR.height)) &&
+             (newLeft > rackBCR.left && newLeft < (rackBCR.left + rackBCR.width))
+         ) {
+             const index = Math.floor(
+                 (
+                     (newLeft - rackBCR.left) / rackBCR.width
+                 ) * playerLetters.length
+             );
+ 
+             newDest = playerLetters[index];
+ 
+             if (newDest && newDest.getElementsByClassName("tile")[0]) {
+                 let i = index + 1;
+                 let tile;
+ 
+                 while (playerLetters[i]) {
+                     tile = playerLetters[i].getElementsByClassName("tile")[0];
+                     if (!tile) {
+                         let j = i;
+                         while (j > index) {
++                            rackChanged = true;
+                             playerLetters[j].appendChild(
+                                 playerLetters[j - 1].getElementsByClassName("tile")[0]
+                             );
+                             j--;
+                         }
+                         break;
+                     }
+                     i++;
+                 }
+ 
+                 if (newDest.getElementsByClassName("tile")[0]) {
+                     i = index - 1;
+ 
+                     while (playerLetters[i]) {
+                         tile = playerLetters[i].getElementsByClassName("tile")[0];
+                         if (!tile) {
+                             let j = i;
+                             while (j < index) {
+                                 playerLetters[j].appendChild(
+                                     playerLetters[j + 1].getElementsByClassName("tile")[0]
+                                 );
+                                 j++;
+                             }
+                             break;
+                         }
+                         i--;
+                     }
+                 }
+             }
+ 
+             if (newDest.getElementsByClassName("tile")[0]) {
+                 newDest = null;
+             } else {
+                 moveCMD.to = "rack";
+                 moveCMD.indexTo = index;
+             }
+         } else if (
+             (newTop  > bagBCR.top  && newTop  < (bagBCR.top + bagBCR.height)) &&
+             (newLeft > bagBCR.left && newLeft < (bagBCR.left + bagBCR.width))
+         ) {
+             newDest = bag;
+         }
+ 
+         if (newDest !== tileDest) {
+             if (tileDest) {
+                 tileDest.classList.remove("tile-target");
+             }
+ 
+             if (newDest) {
+                 newDest.classList.add("tile-target");
+             }
+         }
+ 
+         tileDest = newDest;
+     }
+ 
+     function dragTileBegin(e) {
+         preventDefault(e);
+ 
+         if (blockMove || waitingMsgs.length) {
+             return;
+         }
+ 
+         if (!pollingReady) {
+             showConnectionLost();
+             return;
+         }
+ 
+         loadAudio();
+ 
++        rackChanged = false;
+         movingTile = e.currentTarget;
+ 
+         tileInitMouseCoords = e;
+         tileInitCoords      = movingTile.getBoundingClientRect();
+         tileInitDest        = movingTile.parentNode;
+ 
+         boardBCR = board.getBoundingClientRect();
+         rackBCR  = rack.getBoundingClientRect();
+         bagBCR   = bag.getBoundingClientRect();
+ 
+         let from;
+         let index;
+ 
+         let p = movingTile.parentNode;
+         let oldP = movingTile;
+         let oldOldP = null;
+ 
+         while (p) {
+             if (p === board) {
+                 from  = "board";
+                 index = boardCells.indexOf(oldOldP);
+ 
+                 break;
+             }
+ 
+             if (p === rack) {
+                 from  = "rack";
+                 index = playerLetters.indexOf(oldP);
+ 
+                 break;
+             }
+ 
+             oldOldP = oldP;
+             oldP = p;
+             p = p.parentNode;
+         }
+ 
+         moveCMD = {
+             cmd: "moveLetter",
+             from: from,
+             indexFrom: index
+         };
+ 
+         mouseMove(document, dragTileMove);
+         mouseUp(document,   dragTileEnd);
+ 
+         movingTile.style.left   = tileInitCoords.left + window.scrollX + "px";
+         movingTile.style.top    = tileInitCoords.top  + window.scrollY + "px";
+         movingTile.style.width  = tileInitCoords.width  + "px";
+         movingTile.style.height = tileInitCoords.height + "px";
+ 
+         document.body.appendChild(movingTile);
+     }
+ 
+     function setLetter(tile, letter, highlight) {
+         tile.firstChild.textContent = letter;
+         tile.lastChild.textContent = scoreOf[letter] || "";
+         if (highlight) {
+             tile.classList.add("tile-highlight");
+             if (tilesSound.checked) {
+                 audioNotification.play();
+             }
+         } else {
+             tile.classList.remove("tile-highlight");
+         }
+     }
+ 
+     function preventDefault(e) {
+         e.preventDefault();
+         e.stopPropagation();
+         return false;
+     }
+ 
+     function makeLetter(letter, highlight, noEvents) {
+         const tile = document.createElement("span");
+         tile.translate = false;
+         tile.className = "tile";
+         tile.appendChild(document.createElement("span"));
+         tile.lastChild.className = "tile-letter";
+         tile.appendChild(document.createElement("span"));
+         tile.lastChild.className = "tile-score";
+ 
+         if (!noEvents) {
+             mouseDown(tile, dragTileBegin);
+ 
+             tile.addEventListener("contextmenu", preventDefault);
+             tile.addEventListener("touchstart", preventDefault);
+             tile.addEventListener("touchmove", preventDefault);
+         }
+ 
+         setLetter(tile, letter, highlight);
+         return tile;
+     }
+ 
+     function setTileParent(p, letter, highlight) {
+         let tile = p.getElementsByClassName("tile")[0];
+         if (tile) {
+             if (letter) {
+                 setLetter(tile, letter, highlight);
+             } else {
+                 p.removeChild(tile);
+             }
+         } else if (letter) {
+             tile = makeLetter(letter, highlight);
+             p.appendChild(tile);
+         }
+     }
+ 
+     function setCell(index, letter, highlight) {
+         setTileParent(boardCells[index].getElementsByClassName("tile-placeholder")[0], letter, highlight);
+     }
+ 
+     function setBoard(board) {
+         for (let i = 0; i < 15 * 15; i++) {
+             setCell(i, board[i]);
+         }
+     }
+ 
+     function set(key, value) {
+         switch (key) {
+             case "playerName":
+                 name.textContent = localStorage.trivabblePlayerName = value;
+                 break;
+             case "gameNumber":
+                 document.getElementById("number").textContent = localStorage.trivabbleGameNumber = value;
+                 break;
+         }
+     }
+ 
+     function checkGameInProgress(f) {
+         if (!gameInProgress) {
+             return f();
+         }
+ 
+         myConfirm(
+             _("Your game is not over. Are you sure you want to leave now?"),
+             function () {
+                 myAlert(
+                     format(
+                         _("You are about to leave the current game. To recover it, please note its number: {0}"),
+                         localStorage.trivabbleGameNumber
+                     ),
+                     f
+                 );
+             }
+         );
+     }
+ 
+     let pollingReady = false;
+     let retriedImmediately = false;
+ 
+     function forceReload(msg) {
+         needsRestart = true;
+         myConfirm(
+             msg,
+             function () {
+                 location.reload();
+             }, function () {
+                 blockMove = 1000;
+                 document.getElementById("panel").appendChild(document.createElement("a"));
+                 document.getElementById("panel").lastChild.href = "#";
+                 document.getElementById("panel").lastChild.onclick = location.reload.bind(location);
+                 document.getElementById("panel").lastChild.textContent = _("To continue playing, click here");
+             }
+         );
+     }
+ 
+     function fatalError(e) {
+         if (!needsRestart) {
+             // No need to show the user that a problem just happened if a message asking to restart has already been shown.
+             forceReload(_("Sorry, a problem just happened. The page must be reloaded. If the problem is not too serious, you should be able to keep playing normally. Otherwise, contact the person who is able to fix the problem. Click on “Yes” to reload the page."));
+         }
+ 
+         throw (e || new Error("interrupting the game because of a fatal error"));
+     }
+ 
+     function jsonError(json, e) {
+         console.error("An error ocurred while parsing this JSON message:", json);
+         fatalError(e);
+     }
+ 
+     function setRackCell(index, letter) {
+         setTileParent(playerLetters[index], letter);
+     }
+ 
+     function blinkTransitionEnd(element) {
+         element.removeEventListener("animationend", element._te, false);
+         element.classList.remove("blink");
+     }
+ 
+     function blink(element) {
+         element._te = blinkTransitionEnd.bind(null, element);
+         element.addEventListener("animationend", element._te, false);
+         setTimeout(element._te, 5000);
+         element.classList.add("blink");
+     }
+ 
+     function chatMessage(sender, content) {
+         const msgDom = document.createElement("div");
+ 
+         msgDom.className = "msg";
+ 
+         if (sender) {
+             msgDom.appendChild(document.createElement("span"));
+             msgDom.lastChild.className = "msg-sender";
+             msgDom.lastChild.textContent = _("{0}: ").replace("{0}", sender);
+         }
+ 
+         if (content instanceof Element) {
+             msgDom.appendChild(content);
+         } else {
+             msgDom.appendChild(document.createElement("span"));
+             msgDom.lastChild.className = "msg-content";
+             msgDom.lastChild.textContent = content;
+         }
+ 
+         chatMessages.appendChild(msgDom);
+ 
+         chatMessages.scrollTop = chatMessages.scrollHeight;
+ 
+         if (sender && sender !== localStorage.trivabblePlayerName) {
+             if (msgSound.checked) {
+                 audioChat.play();
+             }
+ 
+             blink(chatMessages);
+         }
+ 
+         return msgDom;
+     }
+ 
+     function handleChatMessage(msg) {
+         if (msg.specialMsg) {
+             if (msg.specialMsg.type === "rack") {
+                 const content = document.createElement("div");
+                 content.appendChild(document.createElement("span"));
+                 content.lastChild.className = "info";
+                 content.lastChild.textContent = format(_("{0} shows their rack:"), msg.sender);
+ 
+                 const letters = document.createElement("div");
+                 letters.className = "tile-list";
+ 
+                 for (let i = 0; i < msg.specialMsg.rack.length; i++) {
+                     letters.appendChild(makeLetter(msg.specialMsg.rack[i], false, true));
+                 }
+ 
+                 content.appendChild(letters);
+                 chatMessage("", content);
+                 return;
+             }
+         }
+ 
+         chatMessage(msg.sender, msg.content);
+     }
+ 
+ 
+     function refreshCurrentPlayer() {
+         const row = tablePlayers[currentPlayer];
+         if (currentPlayer && row && !row.classList.contains("current-player")) {
+             row.classList.add("current-player");
+             blink(row.firstChild);
+         }
+     }
+ 
+     function setCurrentPlayer(player) {
+         if (currentPlayer && tablePlayers[currentPlayer]) {
+             tablePlayers[currentPlayer].classList.remove("current-player");
+         }
+ 
+         currentPlayer = player;
+         refreshCurrentPlayer();
+     }
+ 
+     function handleReceivedData(data) {
+         if (Array.isArray(data)) {
+             data.forEach(handleReceivedData);
+             return;
+         }
+ 
+         if (data.id) {
+             const pos = waitingMsgs.indexOf(data.id);
+             if (pos !== -1) {
+                 waitingMsgs.splice(pos, 1);
+             }
+         }
+ 
+         if (data.version) {
+             serverVersion = data.version;
+         }
+ 
+         if (data.pleaseRestart) {
+             forceReload(_("Sorry to disturb you, but we need to reload the game so you can continue playing. You will not lose anything except the messages in the chat. Are you ready?"));
+         }
+ 
+         if (data.error) {
+             fatalError(new Error("Error from the server: " + data.error + " - " + data.reason));
+         }
+ 
+         if (data.stopping) {
+             stopConnection();
+ 
+             retryPolling(
+                 (typeof data.stopping === "number" && data.stopping > 0)
+                     ? data.stopping
+                     : 0
+             );
+         }
+ 
+         if (data.msg) {
+             handleChatMessage(data.msg);
+         }
+ 
+         if (typeof data.currentPlayer === "string") {
+             setCurrentPlayer(data.currentPlayer);
+         }
+ 
+         if (data.players) {
+             if (participantPlaceholder) {
+                 participantPlaceholder.parentNode.removeChild(participantPlaceholder);
+                 participantPlaceholder = null;
+             }
+ 
+             for (let i = 0; i < data.players.length; i++) {
+                 const player = data.players[i];
+                 const playerName = data.players[i].player;
+ 
+                 if (!tablePlayers[playerName]) {
+                     let before = null;
+ 
+                     for (let j = 1;  j < participants.rows.length; j++) {
+                         if (playerName < participants.rows[j].cells[0].textContent) {
+                             before = participants.rows[j];
+                             break;
+                         }
+                     }
+ 
+                     const row = document.createElement("tr");
+                     participants.insertBefore(row, before);
+                     row.appendChild(document.createElement("td"));
+                     row.lastChild.textContent = playerName;
 -                    row.lastChild.onclick = (
++
+                     row.appendChild(document.createElement("td"));
+ 
++                    row.appendChild(document.createElement("td"));
+                     row.lastChild.className = "score-cell";
 -                                    row.lastChild.textContent
++
++                    row.lastChild.appendChild(document.createElement("span"));
++                    row.lastChild.lastChild.onclick = (
+                         function (row) {
+                             return function () {
+                                 if (!pollingReady) {
+                                     showConnectionLost();
+                                     return;
+                                 }
+ 
+                                 myPrompt(
+                                     format(
+                                         _("Enter the new score of {0}:"),
+                                         row.firstChild.textContent
+                                     ),
+                                     function (res) {
+                                         res = parseInt(res);
+                                         if (!isNaN(res)) {
+                                             sendCmds([{
+                                                 cmd: "score",
+                                                 player: row.firstChild.textContent,
+                                                 score: res
+                                             }]);
+                                         }
+                                     },
 -                    const scoreCell = tablePlayers[playerName].childNodes[2];
++                                    row.lastChild.firstChild.textContent
+                                 );
+                             };
+                         }
+                     )(row);
++                    row.lastChild.appendChild(document.createTextNode(" "));
++                    row.lastChild.appendChild(document.createElement("button"));
++                    row.lastChild.lastChild.textContent = "+";
++                    row.lastChild.lastChild.onclick = (
++                        function (row) {
++                            return function() {
++                                if (!pollingReady) {
++                                    showConnectionLost();
++                                    return;
++                                }
++
++                                myPrompt(
++                                    format(
++                                        _("Enter the score to add to {0}:"),
++                                        row.firstChild.textContent
++                                    ),
++                                    function (res) {
++                                        res = parseInt(res);
++                                        if (!isNaN(res)) {
++                                            sendCmds([{
++                                                cmd: "score",
++                                                player: row.firstChild.textContent,
++                                                score: parseInt(row.childNodes[2].childNodes[0].textContent) + res
++                                            }]);
++                                        }
++                                    },
++                                    row.lastChild.firstChild.textContent
++                                );
++                            };
++                        }
++                    )(row);
++
+ 
+                     row.appendChild(document.createElement("td"));
+                     row.lastChild.className = "turn-cell";
+                     row.lastChild.appendChild(document.createElement("button"));
+                     const img = new Image();
+                     img.src = "baton.svg";
+                     row.lastChild.lastChild.appendChild(img);
+                     row.lastChild.lastChild.onclick = (function (playerName) {
+                         sendCmds([{
+                             cmd: "currentPlayer",
+                             player: playerName === currentPlayer ? "" : playerName
+                         }]);
+                     }).bind(null, playerName);
+ 
+                     tablePlayers[playerName] = row;
+                 }
+ 
+                 if (Object.prototype.hasOwnProperty.call(player, "score")) {
++                    const scoreCell = tablePlayers[playerName].childNodes[2].childNodes[0];
+                     scoreCell.textContent = player.score;
+                     blink(scoreCell);
+                 }
+ 
+                 if (Object.prototype.hasOwnProperty.call(player, "rackCount")) {
+                     const countCell = tablePlayers[playerName].childNodes[1];
+                     countCell.textContent = player.rackCount;
+                     blink(countCell);
+                 }
+             }
+ 
+             refreshCurrentPlayer();
+         }
+ 
+         if (data.playerName) {
+             set("playerName", data.playerName);
+         }
+ 
+         if (data.gameNumber) {
+             set("gameNumber", data.gameNumber);
+         }
+ 
+ 
+         if (data.letterValues) {
+             scoreOf = data.letterValues;
+         }
+ 
+         if (data.board) {
+             setBoard(data.board);
+         }
+ 
+         if (typeof data.remainingLetters === "number") {
+             remainingLetters = data.remainingLetters;
+             document.getElementById("remaining-letters").textContent = data.remainingLetters;
+             if (data.remainingLetters === 0) {
+                 helpBag.style.display = "none";
+                 helpClear.style.display = "";
+             } else {
+                 helpBag.style.display = "";
+                 helpClear.style.display = "none";
+             }
+         }
+ 
+         if (data.rack) {
+             setRack(data.rack);
+         }
+ 
+         if (!data.action) {
+             return;
+         }
+ 
+         switch (data.action) {
+             case "pushBag": //TODO
+                 break;
+ 
+             case "popBag": //TODO
+                 break;
+ 
+             case "reset": //TODO
+                 tablePlayers = {};
+                 while (participants.rows[1]) {
+                     participants.removeChild(participants.rows[1]);
+                 }
+                 sendCmds([{cmd: "hello"}]);
+                 break;
+ 
+             case "moveLetter":
+                 if (data.from === "board") {
+                     setCell(data.indexFrom, "");
+                 } else if (data.from === "rack") {
+                     setRackCell(data.indexFrom, "");
+                 }
+ 
+                 if (data.to === "board") {
+                     setCell(data.indexTo, data.letter, Object.prototype.hasOwnProperty.call(data, "player") && data.player !== localStorage.trivabblePlayerName);
+                 } else if (data.to === "rack") {
+                     setRackCell(data.indexTo, data.letter);
+                 }
+                 break;
+             case "setCell":
+                 setCell(data.indexTo, data.letter, Object.prototype.hasOwnProperty.call(data, "player") && data.player !== localStorage.trivabblePlayerName);
+                 break;
+             case "setRackCell":
+                 setRackCell(data.indexTo, data.letter);
+         }
+     }
+ 
+     function retryPolling(delay) {
+         if (needsRestart) {
+             return;
+         }
+ 
+         pollingReady = false;
+ 
+         if (!boundEventShowConnectionLost) {
+             boundEventShowConnectionLost = showConnectionLost.bind(null, null);
+ 
+             document.body.addEventListener("mousemove", boundEventShowConnectionLost);
+             document.body.addEventListener("mousedown", boundEventShowConnectionLost);
+             document.body.addEventListener("touchstart", boundEventShowConnectionLost);
+         }
+ 
+         if (retryPollingTimeout) {
+             return;
+         }
+ 
+         if (delay || retriedImmediately) {
+             retryPollingTimeout = setTimeout(
+                 function () {
+                     connectionStopped = false;
+                     pollingServer = false;
+                     startConnection();
+                     retryPollingTimeout = 0;
+                 },
+                 delay || Conf.POLLING_DELAY
+             );
+         } else {
+             retriedImmediately = true;
+             connectionStopped = false;
+             pollingServer = false;
+             startConnection();
+         }
+     }
+ 
+     function parseAndHandleReceivedData(r) {
+         let m;
+ 
+         try {
+             m = JSON.parse(r);
+         } catch (e) {
+             jsonError(r, e);
+         }
+ 
+         try {
+             handleReceivedData(m);
+         } catch (e) {
+             fatalError(e);
+         }
+     }
+ 
+     function closeConnections() {
+         if (eventSource) {
+             eventSource.onerror = null;
+             eventSource.close();
+             eventSource = null;
+         }
+ 
+         if (webSocket) {
+             webSocket.onclose = null;
+             webSocket.onerror = null;
+             webSocket.close();
+             webSocket = null;
+         }
+     }
+ 
+     function stopConnection() {
+         closeConnections();
+ 
+         if (retryPollingTimeout) {
+             clearTimeout(retryPollingTimeout);
+             retryPollingTimeout = 0;
+         }
+ 
+         connectionStopped = true;
+         pollingServer = false;
+         pollingReady = false;
+     }
+ 
+     let blacklistWebsockets = false;
+     let webSocketErrors = 0;
+ 
+     function bindConnectionEvents(connection) {
+         connection.onopen = connectionReady;
+ 
+         connection.onmessage = function (e) {
+             if (connection === webSocket) {
+                 webSocketErrors = 0;
+             }
+ 
+             connectionReady();
+             parseAndHandleReceivedData(e.data);
+         };
+ 
+         connection.onerror = function (e) {
+             console.error("Connection error:", e);
+ 
+             if (connection === eventSource) {
+                 retryPolling();
+             } else {
+                 webSocketErrors++;
+ 
+                 if (webSocketErrors > Conf.MAX_WEBSOCKET_ERRORS) {
+                     blacklistWebsockets = true;
+                 }
+             }
+         };
+ 
+         if (connection === webSocket) {
+             connection.onclose = function (e) {
+                 webSocket = null;
+                 if (e.code === 1002 || e.code === 1003) {
+                     // CLOSE_PROTOCOL_ERROR or CLOSE_UNSUPPORTED
+                     blacklistWebsockets = true;
+                     pollServerWithEventSource();
+                     return;
+                 }
+                 retryPolling();
+             };
+         }
+     }
+ 
+     function canConnect() {
+         return !pollingServer && !needsRestart && !connectionStopped && navigator.onLine !== false;
+         // DO NOT change navigator.onLine !== false by navigator.onLine === true
+         // This would block connection on a browser not supporting this property
+     }
+ 
+     function pollServerWithEventSource() {
+         if (canConnect() && Conf.ENABLE_EVENT_SOURCE && window.EventSource) {
+             closeConnections();
+             pollingServer = true;
+ 
+             eventSource = new EventSource(getApiEntryPoint() + "/sse/" + JSON.stringify(cmdsWithContext()));
+             bindConnectionEvents(eventSource);
+             return;
+         }
+ 
+         pollServerWithXHR();
+     }
+ 
+     function pollServerWithWebSocket() {
+         if (canConnect() && Conf.ENABLE_WEBSOCKETS && !blacklistWebsockets && window.WebSocket) {
+             closeConnections();
+             pollingServer = true;
+ 
+             webSocket = new WebSocket(
+                 (window.location.protocol === "http:" ? "ws://" : "wss://") +
+                 window.location.host +
+                 getApiEntryPoint() + "/ws/" +
+                 JSON.stringify(cmdsWithContext())
+             );
+ 
+             bindConnectionEvents(webSocket);
+             return;
+         }
+ 
+         pollServerWithEventSource();
+     }
+ 
+     function xhrRequest(data, onreadystatechange) {
+         const xhr = new XMLHttpRequest();
+ 
+         xhr.open("POST", getApiEntryPoint(), true);
+         xhr.setRequestHeader("Content-Type", "text/plain");
+         xhr.send(JSON.stringify(data));
+ 
+         xhr.onreadystatechange = onreadystatechange.bind(null, xhr);
+     }
+ 
+     function pollServerWithXHR() {
+         if (!canConnect()) {
+             return;
+         }
+ 
+         pollingServer = true;
+ 
+         let currentIndex = 0;
+         let expectedLength = 0;
+ 
+         xhrRequest(cmdsWithContext(), function (xhr) {
+             if (xhr.readyState === 2) {
+                 connectionReady();
+             } else if (xhr.readyState === 3) {
+                 connectionReady();
+ 
+                 while (true) {
+                     if (!expectedLength) {
+                         let i = currentIndex;
+                         while (i < xhr.responseText.length) {
+                             if ("0123456789".indexOf(xhr.responseText.charAt(i)) === -1) {
+                                 expectedLength = parseInt(xhr.responseText.substring(currentIndex, i));
+                                 currentIndex = i;
+                                 break;
+                             }
+                             ++i;
+                         }
+                     }
+ 
+                     if (expectedLength && (xhr.responseText.length >= currentIndex + expectedLength)) {
+                         const end = currentIndex + expectedLength;
+                         let msgs;
+ 
+                         try {
+                             msgs = JSON.parse(
+                                 xhr.responseText.substring(
+                                     currentIndex,
+                                     end
+                                 )
+                             );
+                             currentIndex = end;
+                             expectedLength = 0;
+                         } catch (e) {
+                             jsonError(xhr.responseText.substring(
+                                 currentIndex,
+                                 end
+                             ), e);
+                         }
+ 
+                         handleReceivedData(msgs);
+                     } else {
+                         break;
+                     }
+                 }
+             } else if (xhr.readyState === 4) {
+                 startConnection();
+             }
+         });
+     }
+ 
+     function startConnection() {
+         pollServerWithWebSocket();
+     }
+ 
+     function sendXHR(cmds) {
+         if (needsRestart) {
+             return;
+         }
+ 
+         blockMove++;
+ 
+         xhrRequest(cmdsWithContext(cmds), function (xhr) {
+             if (xhr.readyState === 4) {
+                 if (xhr.status === 0 || xhr.status >= 300) {
+                     setTimeout(sendCmds.bind(null, cmds), Conf.POLLING_DELAY);
+                     return;
+                 }
+ 
+                 parseAndHandleReceivedData(xhr.responseText);
+ 
+                 blockMove--;
+ 
+                 if (!pollingReady) {
+                     startConnection();
+                 }
+             }
+         });
+     }
+ 
+     function cmdsWithContext(cmds) {
+         return {
+             gameNumber: localStorage.trivabbleGameNumber || "",
+             playerName: localStorage.trivabblePlayerName,
+             version: VERSION,
+             cmds: cmds
+         };
+     }
+ 
+     function sendCmds(cmds) {
+         if (webSocket && webSocket.readyState === 1) {
+             if (serverVersion >= 202004281800) {
+                 for (const cmd of cmds) {
+                     waitingMsgs.push(currentMessageId);
+                     cmd.id = currentMessageId++;
+                 }
+             }
+ 
+             webSocket.send(JSON.stringify(cmds));
+             return;
+         }
+ 
+         if (!pollingReady) {
+             startConnection();
+             setTimeout(sendCmds, Conf.POLLING_DELAY, cmds);
+             return;
+         }
+ 
+         sendXHR(cmds);
+     }
+ 
+     function joinGame() {
+         checkGameInProgress(
+             function () {
+                 myPrompt(
+                     _("To join a game, please give the number which is displayed on your adversary(ies)' screen.\nIf you do not know it, ask them.\n\nWarning: your adversary must not take your number, (s)he must keep his/her own. If you whish to recover your current game, please not the following number: {0}.").replace("{0}", localStorage.trivabbleGameNumber),
+                     function (n) {
+                         n = parseInt(n);
+                         if (isNaN(n)) {
+                             myAlert(_("It seems your did not give a correct number, or you clicked on “Cancel”. As a result, the current game continues, if any. To join a game, click on “Join a game” again."));
+                         } else {
+                             localStorage.trivabbleGameNumber = n;
+                             location.reload();
+                         }
+                     }
+                 );
+             }
+         );
+     }
+ 
+     const specialTypesText = {
+         doubleLetter: _("Double\nLetter"),
+         doubleWord:   _("Double\nWord"),
+         tripleLetter: _("Triple\nLetter"),
+         tripleWord:   _("Triple\nWord")
+     };
+ 
+     function specialCell(type, cell) {
+         cell.firstChild.appendChild(document.createElement("span"));
+         cell.classList.add("special-cell");
+         cell.classList.add("special-cell-" + type);
+         cell.lastChild.lastChild.textContent = _(specialTypesText[type]);
+         cell.lastChild.lastChild.className = "special-cell-label";
+     }
+ 
+     function changeName() {
+         myPrompt(
+             _("To change your name, enter a new one. You can keep using your current name by cancelling. Please note that if you change your name and you have games in progress, you will not be able to keep playing them anymore unless you get back to your current name."),
+             function (newName) {
+                 if (newName && newName.trim()) {
+                     localStorage.trivabblePlayerName = newName.trim();
+                     name.textContent = localStorage.trivabblePlayerName;
+                 }
+             },
+             localStorage.trivabblePlayerName
+         );
+     }
+ 
+     function startGame(number) {
+         if (number) {
+             localStorage.trivabbleGameNumber = number;
+         }
+         startConnection();
+     }
+ 
+     let audioTileLoaded = false;
+     let audioMsgLoaded = false;
+     let tilesSound;
+     let msgSound;
+ 
+     function loadAudio() {
+         if (!audioTileLoaded && tilesSound.checked) {
+             audioTileLoaded = true;
+             audioNotification.load();
+         }
+ 
+         if (!audioMsgLoaded  && msgSound.checked) {
+             audioMsgLoaded = true;
+             audioChat.load();
+         }
+     }
+ 
+     function bagClicked(e) {
+         preventDefault(e);
+ 
+         if (blockMove || waitingMsgs.length) {
+             return;
+         }
+ 
+         if (!remainingLetters) {
+             myAlert(_("You cannot take another tile: the bag is empty."));
+             return;
+         }
+ 
+         loadAudio();
+ 
+         if (!pollingReady) {
+             showConnectionLost();
+             return;
+         }
+ 
+         const index = getFreeRackSpaceIndex();
+         if (index === -1) {
+             myAlert(_("You cannot take another tile: your rack is full."));
+         } else {
+             sendCmds(
+                 [{cmd: "moveLetter", from: "bag", to: "rack", indexTo: index}]
+             );
+         }
+     }
+ 
+     function clearGame() {
+         myConfirm(
+             _("Are you sure you want to put all the tiles back in the bag (in order to play another game)?"),
+             function () {
+                 sendCmds([{cmd: "resetGame"}]);
+             }
+         );
+     }
+ 
+     function clearRack() {
+         myConfirm(
+             _("Are you sure you want to put all your tiles back in the bag?"),
+             function () {
+                 sendCmds([
+                     {cmd: "moveLetter", from: "rack", indexFrom: 0, to: "bag", indexTo: -1},
+                     {cmd: "moveLetter", from: "rack", indexFrom: 1, to: "bag", indexTo: -1},
+                     {cmd: "moveLetter", from: "rack", indexFrom: 2, to: "bag", indexTo: -1},
+                     {cmd: "moveLetter", from: "rack", indexFrom: 3, to: "bag", indexTo: -1},
+                     {cmd: "moveLetter", from: "rack", indexFrom: 4, to: "bag", indexTo: -1},
+                     {cmd: "moveLetter", from: "rack", indexFrom: 5, to: "bag", indexTo: -1},
+                     {cmd: "moveLetter", from: "rack", indexFrom: 6, to: "bag", indexTo: -1}
+                 ]);
+                 setRack("");
+             }
+         );
+     }
+ 
+     function showRack() {
+         myConfirm(
+             _("Are you sure you want to show your rack to everybody?"),
+             function () {
+                 const letters = playerLetters.map(
+                     function (tilePlaceholer) {
+                         return tilePlaceholer.querySelector(".tile");
+                     }
+                 ).filter(function (tile) {
+                     return tile !== null;
+                 });
+ 
+                 sendCmds([{
+                     cmd: "msg",
+                     msg: _("Here is my rack:") + " " + letters.map(
+                         function (tile) {
+                             return (
+                                 tile.textContent === ""
+                                     ? _("joker")
+                                     : tile.querySelector(".tile-letter").textContent
+                             );
+                         }
+                     ).join(", "),
+                     specialMsg: {
+                         type: "rack",
+                         rack: letters.map(
+                             function (tile) {
+                                 return tile.querySelector(".tile-letter").textContent;
+                             }
+                         )
+                     }
+                 }]);
+             }
+         );
+ 
+     }
+ 
+     function initChat() {
+         chatMessages.style.width = chatMessages.offsetWidth + "px";
+ 
+         const btn = document.getElementById("chat-btn");
+ 
+         chatTextarea.onmouseup = function () {
+             chatMessages.style.width = chatTextarea.offsetWidth + "px";
+         };
+ 
+         btn.onclick = function () {
+             if (!pollingReady) {
+                 return;
+             }
+ 
+             loadAudio();
+             sendCmds([{
+                 cmd: "msg",
+                 msg: chatTextarea.value
+             }]);
+ 
+             chatTextarea.value = "";
+         };
+ 
+         chatTextarea.onkeydown = function (e) {
+             if (e.keyCode === 13) {
+                 preventDefault(e);
+                 chatTextarea.focus();
+                 btn.onclick();
+             }
+         };
+     }
+ 
+     function initSound() {
+         audioNotification = new Audio();
+         audioNotification.preload = "auto";
+         audioNotification.volume  = 1;
+ 
+         let audioSourceOGG = document.createElement("source");
+         audioSourceOGG.src = "notification.ogg";
+ 
+         let audioSourceMP3 = document.createElement("source");
+         audioSourceMP3.src = "notification.mp3";
+         audioNotification.appendChild(audioSourceOGG);
+         audioNotification.appendChild(audioSourceMP3);
+ 
+         audioChat = new Audio();
+         audioChat.preload = "auto";
+         audioChat.volume  = 1;
+ 
+         audioSourceOGG = document.createElement("source");
+         audioSourceOGG.src = "receive.ogg";
+ 
+         audioSourceMP3 = document.createElement("source");
+         audioSourceMP3.src = "receive.mp3";
+         audioChat.appendChild(audioSourceOGG);
+         audioChat.appendChild(audioSourceMP3);
+ 
+ 
+         tilesSound = document.getElementById("tiles-sound");
+         msgSound = document.getElementById("msg-sound");
+ 
+         tilesSound.onclick = function () {
+             localStorage.trivabbleTileSound = tilesSound.checked;
+         };
+ 
+         msgSound.onclick = function () {
+             localStorage.trivabbleMsgSound = msgSound.checked;
+         };
+ 
+         if (Object.prototype.hasOwnProperty.call(localStorage, "trivabbleMsgSound")) {
+             msgSound.checked = localStorage.trivabbleMsgSound === "true";
+         } else {
+             localStorage.trivabbleMsgSound = msgSound.checked;
+         }
+ 
+         if (Object.prototype.hasOwnProperty.call(localStorage, "trivabbleTileSound")) {
+             tilesSound.checked = localStorage.trivabbleTileSound === "true";
+         } else {
+             localStorage.trivabbleTilesSound = tilesSound.checked;
+         }
+     }
+ 
+     function repromptName(f) {
+         if (localStorage.trivabblePlayerName && localStorage.trivabblePlayerName.trim()) {
+             f();
+         } else {
+             myPrompt(
+                 _("It seems your did not give your name. You need to do it for the game to run properly."),
+                 function (name) {
+                     if (name && name.trim()) {
+                         localStorage.trivabblePlayerName = name.trim();
+                     }
+ 
+                     repromptName(f);
+                 }
+             );
+         }
+     }
+ 
+     trivabble.applyL10n = function () {
+         document.documentElement.lang = libD.lang;
+         document.documentElement.setAttribute("xml:lang", libD.lang);
+ 
+         for (const node of [].slice.call(document.querySelectorAll("[data-l10n]"))) {
+             if (node.dataset.l10n === "text-content") {
+                 node.textContent = _(node.textContent.trim());
+             } else {
+                 node.setAttribute(node.dataset.l10n, _(node.getAttribute(node.dataset.l10n)));
+             }
+         }
+ 
+         trivabble.run();
+     };
+ 
+     function langSelectionChange(e) {
+         localStorage.trivabbleLang = e.target.value;
+         location.reload();
+     }
+ 
+     function initGlobals() {
+         board         = document.getElementById("board");
+         rack          = document.getElementById("rack");
+         participants  = document.getElementById("participants");
+         name          = document.getElementById("name");
+         bag           = document.getElementById("bag");
+         chatMessages  = document.getElementById("chat-messages");
+         chatTextarea  = document.getElementById("chat-ta");
+         helpBag       = document.getElementById("help-bag");
+         helpClear     = document.getElementById("help-clear");
+         participantPlaceholder = document.getElementById("participants-placeholder");
+     }
+ 
+     function initEvents() {
+         mouseDown(bag, bagClicked);
+ 
+         document.getElementById("clear-game").onclick  = clearGame;
+         document.getElementById("change-name").onclick = changeName;
+         document.getElementById("join-game").onclick   = joinGame;
+         document.getElementById("clear-rack").onclick  = clearRack;
+         document.getElementById("show-rack").onclick   = showRack;
+         helpClear.onclick  = clearGame;
+     }
+ 
+     function initGame() {
+         if (!localStorage.trivabblePlayerName) {
+             myPrompt(
+                 _("Hello! To begin, enter your name. Your adversaries will see this name when you play with them."),
+                 function (name) {
+                     if (name && name.trim()) {
+                         localStorage.trivabblePlayerName = name;
+                     }
+                     repromptName(initGame);
+                 }
+             );
+ 
+             return;
+         }
+ 
+         name.textContent = localStorage.trivabblePlayerName;
+ 
+         const letters = "ABCDEFGHIJKLMNO";
+ 
+         const doubleLetter = {
+             "0,3":  true,
+             "0,11": true,
+             "2,6":  true,
+             "2,8":  true,
+             "3,0":  true,
+             "3,7":  true,
+             "3,14": true,
+             "6,2":  true,
+             "6,6":  true,
+             "6,8":  true,
+             "6,12": true
+         };
+ 
+         let cell;
+ 
+         for (let i = 0; i < 7; i++) {
+             const span = document.createElement("span");
+             span.className = "tile-placeholder";
+             rack.appendChild(span);
+             playerLetters.push(span);
+         }
+ 
+         for (let i = 0; i < 15; i++) {
+             board.rows[0].appendChild(document.createElement("th"));
+             board.rows[0].lastChild.textContent = i + 1;
+ 
+             board.appendChild(document.createElement("tr"));
+             board.lastChild.appendChild(document.createElement("th"));
+             board.lastChild.lastChild.textContent = letters[i];
+             for (let j = 0; j < 15; j++) {
+                 cell = document.createElement("td");
+                 boardCells.push(cell);
+                 board.lastChild.appendChild(cell);
+                 cell.appendChild(document.createElement("div"));
+                 cell.lastChild.className = "tile-placeholder";
+ 
+                 if (i === j && i === 7) {
+                     specialCell("doubleWord", board.lastChild.lastChild);
+                     cell = board.lastChild.lastChild.getElementsByClassName("special-cell-label")[0];
+                     cell.textContent = "★";
+                     board.lastChild.lastChild.id = "center-cell";
+                 } else if (i % 7 === 0 && j % 7 === 0) {
+                     specialCell("tripleWord", board.lastChild.lastChild);
+                 } else if ((i === j || i + j === 14) && (i < 5 || i > 9)) {
+                     specialCell("doubleWord", board.lastChild.lastChild);
+                 } else if ((i % 4 === 1) && (j % 4 === 1)) {
+                     specialCell("tripleLetter", board.lastChild.lastChild);
+                 } else if ((i < 8 && doubleLetter[i + "," + j]) || (i > 7 && doubleLetter[(14-i) + "," + j]) || (i === 7 && (j === 3 || j === 11))) {
+                     specialCell("doubleLetter", board.lastChild.lastChild);
+                 }
+             }
+ 
+             board.lastChild.appendChild(document.createElement("th"));
+             board.lastChild.lastChild.textContent = letters[i];
+         }
+ 
+         board.rows[0].appendChild(board.rows[0].cells[0].cloneNode(false));
+ 
+         board.appendChild(document.createElement("tr"));
+         board.lastChild.appendChild(board.rows[0].cells[0].cloneNode(false));
+ 
+         for (let i = 0; i < 15; i++) {
+             board.lastChild.appendChild(document.createElement("th"));
+             board.lastChild.lastChild.textContent = i + 1;
+         }
+ 
+         board.lastChild.appendChild(board.rows[0].cells[0].cloneNode(false));
+ 
+         if (localStorage.trivabbleGameNumber) {
+             document.getElementById("number").textContent = localStorage.trivabbleGameNumber;
+         }
+ 
+         startGame(localStorage.trivabbleGameNumber);
+     }
+ 
+     function initLang() {
+         const lang = libD.lang = localStorage.trivabbleLang || libD.lang;
+ 
+         const langSel = document.getElementById("select-lang");
+         langSel.value = lang;
+         langSel.onchange = langSelectionChange;
+ 
+         const script = document.createElement("script");
+         script.src = "l10n/js/" + lang + ".js";
+         script.onerror = trivabble.l10nError;
+         document.getElementsByTagName("head")[0].appendChild(script);
+     }
+ 
+     trivabble.run = function () {
+         initGlobals();
+         initEvents();
+         initChat();
+         initGame();
+         initSound();
+     };
+ 
+     trivabble.l10nError = trivabble.run;
+ 
+     window.addEventListener("DOMContentLoaded", initLang);
+ 
+     window.addEventListener("beforeunload", stopConnection);
+ 
+     window.addEventListener("offline", function () {
+         stopConnection();
+         showConnectionLost(_("It seems your browser went offline. Please reconnect to continue playing."));
+     });
+ 
+     window.addEventListener("online", startConnection);
+ 
+ }());
index 0000000000000000000000000000000000000000,51dacbaa27f35f28b579eaf59c31d6ea5b4a4027..42bb38b3dde0733eefb421bc32d23243e6c781d8
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,1202 +1,1208 @@@
+ /* eslint-disable no-process-env */
+ /**
+  * Copyright (C) 2016-2020 Raphaël Jakse <raphael.trivabble@jakse.fr>
+  *
+  * @licstart
+  * This file is part of Trivabble.
+  *
+  * Trivabble is free software: you can redistribute it and/or modify it
+  * under the terms of the GNU Affero General Public License (GNU AGPL)
+  * as published by the Free Software Foundation, either version 3 of
+  * the License, or (at your option) any later version.
+  *
+  * Trivabble is distributed in the hope that it will be useful, but
+  * WITHOUT ANY WARRANTY; without even the implied warranty of
+  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+  * GNU Affero General Public License for more details.
+  *
+  * You should have received a copy of the GNU Affero General Public License
+  * along with Trivabble.  If not, see <http://www.gnu.org/licenses/>.
+  * @licend
+  *
+  * @source: https://trivabble.1s.fr/
+  * @source: https://gitlab.com/raphj/trivabble/
+  */
+ 
+ /*eslint strict: [2, "global"]*/
+ 
+ "use strict";
+ 
+ const port = parseInt(process.env.TRIVABBLE_PORT || "3000");
+ const SAVE_TIMEOUT = 5000;
+ const KEEP_ALIVE = 30000;
+ const GAMES_BACKUP = process.env.TRIVABBLE_GAMES_BACKUP || "games.backup.json";
+ 
+ const VERSION = 202004281800;
+ 
+ function envTrue(name) {
+     return (process.env[name] || "").toLowerCase() === "true";
+ }
+ 
+ const DEV_ENABLE_SERVING_FILES = envTrue("DEV_ENABLE_SERVING_FILES");
+ const DEBUG_LOG = DEV_ENABLE_SERVING_FILES || envTrue("DEBUG_LOG");
+ 
+ if (DEV_ENABLE_SERVING_FILES) {
+     console.log("DEV_ENABLE_SERVING_FILES: Serving files in the current directory. Please never do this on a production server, this is for development purposes only.");
+ }
+ 
+ const debuglog = DEBUG_LOG ? console.log.bind(console) : () => null;
+ 
+ const http = require("http");
+ const fs   = require("fs");
+ const crypto = require("crypto");
+ 
+ const REQUEST_TYPE_LONG_POLLING = 1;
+ const REQUEST_TYPE_SSE          = 2;
+ const REQUEST_TYPE_WEBSOCKET    = 3;
+ 
+ const frBag = [
+     " ", " ", // Jokers
+     "E", "E", "E", "E", "E", "E", "E", "E", "E", "E", "E", "E", "E", "E", "E",
+     "A", "A", "A", "A", "A", "A", "A", "A", "A",
+     "I", "I", "I", "I", "I", "I", "I", "I",
+     "N", "N", "N", "N", "N", "N",
+     "O", "O", "O", "O", "O", "O",
+     "R", "R", "R", "R", "R", "R",
+     "S", "S", "S", "S", "S", "S",
+     "T", "T", "T", "T", "T", "T",
+     "U", "U", "U", "U", "U", "U",
+     "L", "L", "L", "L", "L",
+     "D", "D", "D",
+     "M", "M", "M",
+     "G", "G",
+     "B", "B",
+     "C", "C",
+     "P", "P",
+     "F", "F",
+     "H", "H",
+     "V", "V",
+     "J",
+     "Q",
+     "K",
+     "W",
+     "X",
+     "Y",
+     "Z"
+ ];
+ 
+ /* eslint-disable quote-props */
+ 
+ const frValues = {
+     " ": 0,
+     "E": 1,
+     "A": 1,
+     "I": 1,
+     "N": 1,
+     "O": 1,
+     "R": 1,
+     "S": 1,
+     "T": 1,
+     "U": 1,
+     "L": 1,
+     "D": 2,
+     "M": 2,
+     "G": 2,
+     "B": 3,
+     "C": 3,
+     "P": 3,
+     "F": 4,
+     "H": 4,
+     "V": 4,
+     "J": 8,
+     "Q": 8,
+     "K": 10,
+     "W": 10,
+     "X": 10,
+     "Y": 10,
+     "Z": 10
+ };
+ 
+ /* eslint-enable quote-props */
+ 
+ const games = {};
+ 
+ let saveTimeout = null;
+ let running = true;
+ const server = http.createServer(handleRequest);
+ server.setTimeout(0); // The default in node 13 is 0. Earlier versions have 120.
+ 
+ function saveGames(callback) {
+     fs.writeFile(GAMES_BACKUP, JSON.stringify(games), function (err) {
+         if (err) {
+             console.error("ERROR: Cannot save games!");
+         }
+ 
+         if (typeof callback === "function") {
+             return callback(err);
+         }
+     });
+ 
+     saveTimeout = null;
+ }
+ 
+ 
+ function shuffleInPlace(a) {
+     // https://stackoverflow.com/questions/6274339/how-can-i-shuffle-an-array
+ 
+     for (let i = a.length - 1; i > 0; i--) {
+         const j = Math.floor(Math.random() * (i + 1));
+         const x = a[i];
+         a[i] = a[j];
+         a[j] = x;
+     }
+ }
+ 
+ function Game() {
+     this.letterValues = frValues;
+     this.init();
+     this.listeningPlayers = [];
+     this.pendingEvents = [];
+ }
+ 
+ function writeMessage(responseAndType, data, terminate) {
+     if (!responseAndType[0]) {
+         return;
+     }
+ 
+     if (responseAndType[1] === REQUEST_TYPE_WEBSOCKET) {
+         webSocketWrite(responseAndType[0], data);
+     } else {
+         responseAndType[0][terminate ? "end" : "write"](
+             responseAndType[1] === REQUEST_TYPE_SSE
+                 ? "data:" + data + "\n\n"
+                 : data.length + data
+         );
+     }
+ 
+     if (terminate) {
+         stopKeepAlive(responseAndType);
+         if (responseAndType[1] === REQUEST_TYPE_WEBSOCKET && responseAndType[0] && !responseAndType[0].isDestroyed) {
+             responseAndType[0].end(webSocketCloseBuffer);
+             responseAndType[0] = null;
+         }
+     } else {
+         keepAlive(responseAndType);
+     }
+ }
+ 
+ function stop() {
+     console.log("Saving games a first time");
+     if (saveTimeout) {
+         clearTimeout(saveTimeout);
+         saveTimeout = null;
+     }
+ 
+     saveGames(function () {
+         running = false;
+         console.log("Closing connections and saving the game state...");
+ 
+         let listeningPlayerCount = 0;
+         let gamesCount = 0;
+ 
+         for (const gameID of Object.keys(games)) {
+             const game = games[gameID];
+             for (const listeningPlayer of game.listeningPlayers) {
+                 writeMessage(listeningPlayer, '{"stopping": 2000}', true);
+             }
+ 
+             if (game.listeningPlayers.length) {
+                 listeningPlayerCount += game.listeningPlayers.length;
+                 gamesCount++;
+             }
+         }
+ 
+         console.log(
+             "Stopped", gamesCount, "game" + (gamesCount === 1 ? "" : "s"),
+             "and", listeningPlayerCount, "player connection" + (listeningPlayerCount === 1 ? "" : "s") +
+             "."
+         );
+ 
+         server.close(saveGames);
+     });
+ }
+ 
+ const webSocketPingBuffer = Buffer.from([
+     0b10001001, 0b00000000
+ ]);
+ 
+ const webSocketCloseBuffer = Buffer.from([
+     0b10001000, 0b00000000
+ ]);
+ 
+ 
+ function stopKeepAlive(responseAndType) {
+     if (responseAndType.keepAliveTimeout) {
+         clearTimeout(responseAndType.keepAliveTimeout);
+         responseAndType.keepAliveTimeout = 0;
+     }
+ }
+ 
+ function keepAlive(responseAndType) {
+     stopKeepAlive(responseAndType);
+     responseAndType.keepAliveTimeout = setTimeout(function keepAliveTimeout() {
+         responseAndType[0].write(
+             responseAndType[1] === REQUEST_TYPE_WEBSOCKET
+                 ? webSocketPingBuffer
+                 : (
+                     responseAndType[1] === REQUEST_TYPE_SSE
+                         ? ":\n\n"
+                         : "2[]"
+                 )
+         );
+     }, KEEP_ALIVE);
+ }
+ 
+ function newBoard() {
+     const res = new Array(15 * 15);
+ 
+     for (let i = 0; i < 15 * 15; i++) {
+         res[i] = "";
+     }
+ 
+     return res;
+ }
+ 
+ Game.prototype.init = function () {
+     this.board = newBoard();
+     this.bag = frBag.slice();
+     this.racks = {};
+     this.scores = {};
+     this.lastUpdated = new Date();
+     this.currentPlayer = "";
+ 
+     shuffleInPlace(this.bag);
+ };
+ 
+ Game.prototype.toJSON = function () {
+     return {
+         board:            this.board,
+         bag:              this.bag,
+         racks:            this.racks,
+         scores:           this.scores,
+         lastUpdated:      this.lastUpdated.toISOString(),
+         currentPlayer:    this.currentPlayer
+     };
+ };
+ 
+ Game.fromJSON = function (obj) {
+     const game = new Game();
+     game.board            = obj.board  || newBoard();
+     game.bag              = obj.bag    || frBag.slice();
+     game.racks            = obj.racks  || {};
+     game.scores           = obj.scores || {};
+     game.lastUpdated      = obj.lastUpdated ? new Date(obj.lastUpdated) : new Date();
+     game.currentPlayer    = obj.currentPlayer || "";
+     return game;
+ };
+ 
+ Game.prototype.getPlayerRack = function (player) {
+     const playerID = "#" + player;
+     return (this.racks[playerID] || (this.racks[playerID] = []));
+ };
+ 
+ Game.prototype.getPlayerScore = function (player) {
+     const playerID = "#" + player;
+     return (this.scores[playerID] || (this.scores[playerID] = 0));
+ };
+ 
+ Game.prototype.setPlayerScore = function (player, score) {
+     const playerID = "#" + player;
+ 
+     if (!Object.prototype.hasOwnProperty.call(this.racks, playerID) || typeof score !== "number") {
+         return;
+     }
+ 
+     this.scores[playerID] = score;
+ 
+     this.pendingEvents.push({
+         players: [{
+             player: player,
+             score: score
+         }]
+     });
+ };
+ 
+ Game.prototype.setCurrentPlayer = function (player) {
+     this.currentPlayer = player;
+     this.pendingEvents.push({currentPlayer: player});
+ };
+ 
+ Game.prototype.playerJoined = function (playerName) {
+     if (playerName) {
+         this.getPlayerRack(playerName); // Create the player's rack
+     }
+ 
+     const players = [];
+ 
+     for (let player in this.racks) {
+         if (Object.prototype.hasOwnProperty.call(this.racks, player)) {
+             player = player.slice(1); // '#'
+             players.push({
+                 player: player,
+                 score: this.getPlayerScore(player),
+                 rackCount: countTiles(this.getPlayerRack(player))
+             });
+         }
+     }
+ 
+     this.pendingEvents.push({players: players});
+ };
+ 
+ Game.prototype.addListeningPlayer = function (playerName, responseAndType) {
+     const that = this;
+ 
+     that.listeningPlayers.push(responseAndType);
+     keepAlive(responseAndType);
+ 
+     let closed = false;
+ 
+     function close () {
+         if (closed) {
+             return;
+         }
+ 
+         closed = true;
+         stopKeepAlive(responseAndType);
+         responseAndType[0] = null;
+ 
+         const index = that.listeningPlayers.indexOf(responseAndType);
+         if (index !== -1) {
+             that.listeningPlayers[index] = that.listeningPlayers[that.listeningPlayers.length - 1];
+             that.listeningPlayers.pop();
+         }
+     }
+ 
+     responseAndType[0].on("error", close);
+     responseAndType[0].on("close", close);
+     responseAndType[0].on("finish", close);
+     responseAndType[0].on("prefinish", close);
+ 
+     this.playerJoined(playerName);
+     this.commit();
+ };
+ 
+ Game.prototype.commit = function () {
+     const msg = JSON.stringify(this.pendingEvents);
+     this.pendingEvents = [];
+ 
+     for (let i = 0; i < this.listeningPlayers.length; i++) {
+         while (i < this.listeningPlayers.length && !this.listeningPlayers[i]) {
+             this.listeningPlayers[i] = this.listeningPlayers[this.listeningPlayers.length - 1];
+             this.listeningPlayers.pop();
+         }
+ 
+         if (this.listeningPlayers[i]) {
+             writeMessage(this.listeningPlayers[i], msg);
+         }
+     }
+ 
+     if (saveTimeout === null) {
+         saveTimeout = setTimeout(saveGames, SAVE_TIMEOUT);
+     }
+ };
+ 
+ Game.prototype.bagPopLetter = function (player) {
+     if (this.bag.length) {
+         const letter = this.bag.pop();
+         this.pendingEvents.push({
+             player: player,
+             action: "popBag",
+             remainingLetters: this.bag.length
+         });
+ 
+         return letter;
+     }
+ 
+     return "";
+ };
+ 
+ Game.prototype.getCell = function (index) {
+     return this.board[index];
+ };
+ 
+ Game.prototype.setCell = function (index, letter, player) {
+     this.board[index] = letter;
+     this.pendingEvents.push({
+         player: player,
+         action: "setCell",
+         indexTo: index,
+         letter: letter
+     });
+ };
+ 
+ Game.prototype.bagPushLetter = function (letter, player) {
+     if (letter) {
+         this.bag.push(letter);
+         shuffleInPlace(this.bag);
+ 
+         this.pendingEvents.push({
+             player: player,
+             action: "pushBag",
+             remainingLetters: this.bag.length
+         });
+     }
+ };
+ 
+ Game.prototype.reset = function (player) {
+     this.init();
+     this.pendingEvents.push({
+         player: player,
+         action: "reset",
+         board: this.board,
+         remainingLetters: this.bag.length,
+         rack: []
+     });
+ 
+     this.playerJoined();
+ };
+ 
+ function newGameId() {
+     let number;
+ 
+     let k = 10000;
+     let retries = 0;
+ 
+     do {
+         number = Math.floor(Math.random() * k).toString();
+ 
+         if (retries > 10) {
+             retries = 0;
+             k *= 10;
+         } else {
+             retries++;
+         }
+     } while (games[number]);
+ 
+     return number.toString();
+ }
+ 
+ function countTiles(rack) {
+     let count = 0;
+ 
+     for (let i = 0; i < rack.length; i++) {
+         if (rack[i]) {
+             count++;
+         }
+     }
+ 
+     return count;
+ }
+ 
+ function joinGame(gameNumber) {
+     if (!gameNumber) {
+         gameNumber = newGameId();
+     }
+ 
+     const game = games[gameNumber] || (games[gameNumber] = new Game());
+ 
+     return {gameNumber, game};
+ }
+ 
+ function reply(message, response, cmdNumber, r) {
+     response.write(
+         JSON.stringify({...r, id: message.id || message.cmds[cmdNumber].id})
+     );
+ }
+ 
+ function handleCommand(cmdNumber, message, response) {
+     const {gameNumber, game} = joinGame(message.gameNumber);
+ 
+     game.lastUpdated = new Date();
+ 
+     const playerName = message.playerName;
+ 
+     let rack = null;
+     const cmd = message.cmds[cmdNumber];
+ 
+     switch (cmd.cmd) {
+         case "sync": // DEPRECATED. Here for old clients.
+         case "joinGame": {
+             reply(message, response, cmdNumber, {
+                 error: 0,
+                 gameNumber: gameNumber,
+                 playerName: playerName,
+                 currentPlayer: game.currentPlayer,
+                 rack: game.getPlayerRack(playerName),
+                 board: game.board,
+                 remainingLetters: game.bag.length,
+                 letterValues: frValues,
+                 version: VERSION
+             });
+             break;
+         }
+ 
+         case "hello": {
+             game.playerJoined(playerName);
+             reply(message, response, cmdNumber, {error: 0, version: VERSION});
+             break;
+         }
+ 
+         case "score": {
+             game.setPlayerScore(cmd.player, cmd.score);
+             reply(message, response, cmdNumber, {error: 0});
+             break;
+         }
+ 
+         case "currentPlayer": {
+             game.setCurrentPlayer(cmd.player);
+             reply(message, response, cmdNumber, {error: 0});
+             break;
+         }
+ 
+         case "moveLetter": {
+             let letter = "";
+ 
+             // This case can fail in various ways. Instead altering the state
+             // of the game immediately, we store the operations to run them
+             // at the very end, if nothing failed.
+             const operations = [];
+ 
+             switch (cmd.from) {
+                 case "rack":
+                     rack = game.getPlayerRack(playerName);
+ 
+                     if (cmd.indexFrom > 6 || cmd.indexFrom < 0) {
+                         reply(message, response, cmdNumber, {error: 1, reason: "Wrong indexFrom"});
+                         return false;
+                     }
+ 
+                     letter = rack[cmd.indexFrom];
++
++                    if (!letter) {
++                        reply(message, response, cmdNumber, {error: 1, reason: "Moving from an empty location"});
++                        return false;
++                    }
++
+                     operations.push(() => {
+                         rack[cmd.indexFrom] = "";
+                     });
+                     break;
+ 
+                 case "board":
+                     if (cmd.indexFrom < 0 || cmd.indexFrom >= 15 * 15) {
+                         reply(message, response, cmdNumber, {error: 1, reason: "Wrong indexFrom"});
+                         return false;
+                     }
+ 
+                     letter = game.getCell(cmd.indexFrom);
+                     operations.push(game.setCell.bind(game, cmd.indexFrom, "", playerName));
+                     break;
+ 
+                 case "bag":
+                     if (!game.bag.length) {
+                         reply(message, response, cmdNumber, {error: 1, reason: "Empty bag"});
+                         return false;
+                     }
+ 
+                     operations.push(() => {
+                         letter = game.bagPopLetter(playerName);
+                     });
+                     break;
+ 
+                 default:
+                     reply(message, response, cmdNumber, {error: 1, reason: "Moving letter from an unknown place"});
+                     return false;
+             }
+ 
+             switch (cmd.to) {
+                 case "rack":
+                     if (cmd.indexTo < 0 || cmd.indexTo > 6) {
+                         reply(message, response, cmdNumber, {error: 1, reason: "Wrong indexTo"});
+                         return false;
+                     }
+ 
+                     rack = rack || game.getPlayerRack(playerName);
+ 
+                     if (rack[cmd.indexTo]) {
+                         reply(message, response, cmdNumber, {error: 1, reason: "Moving a tile to a non-empty location"});
+                         return false;
+                     }
+ 
+                     operations.push(() => {
+                         rack[cmd.indexTo] = letter;
+                     });
+                     break;
+ 
+                 case "board":
+                     if (cmd.indexTo < 0 || cmd.indexTo >= 15 * 15) {
+                         reply(message, response, cmdNumber, {error: 1, reason: "Wrong indexTo"});
+                         return false;
+                     }
+ 
+                     operations.push(game.setCell.bind(game, cmd.indexTo, letter, playerName));
+                     break;
+ 
+                 case "bag":
+                     operations.push(game.bagPushLetter.bind(game, letter, playerName));
+                     break;
+ 
+                 default:
+                     response.write("{\"error\":1, \"reason\":\"Moving letter to an unknown place\"}");
+                     return false;
+             }
+ 
+             for (const operation of operations) {
+                 operation();
+             }
+ 
+             reply(message, response, cmdNumber, {
+                 error: 0,
+                 rack: (
+                     (cmd.from === "bag" && cmd.to === "rack")
+                         ? rack
+                         : undefined // eslint-disable-line no-undefined
+                 ),
+                 remainingLetters: game.bag.length
+             });
+ 
+             if (rack) {
+                 game.pendingEvents.push({
+                     players: [{
+                         player: playerName,
+                         rackCount: countTiles(rack)
+                     }]
+                 });
+             }
+             break;
+         }
+ 
+         case "setRack": {
+             if (cmd.rack.length > 7) {
+                 reply(message, response, cmdNumber, {
+                     error: 1,
+                     rack: rack,
+                     reason: "the new rack is not at the right size"
+                 });
+                 return false;
+             }
+ 
+             rack = game.getPlayerRack(playerName);
+ 
+             const oldRackSorted = rack.filter((l) => l);
+             oldRackSorted.sort();
+ 
+             const newRackSorted = cmd.rack.filter((l) => l);
+             newRackSorted.sort();
+ 
+             for (let i = 0; i < 7; i++) {
+                 if ((oldRackSorted[i] !== newRackSorted[i]) && (oldRackSorted[i] || newRackSorted[i])) {
+                     reply(message, response, cmdNumber, {
+                         error: 1,
+                         rack: rack,
+                         reason: "the new rack doesn't contain the same number of letters"
+                     });
+                     return false;
+                 }
+             }
+ 
+             for (let i = 0; i < 7; i++) {
+                 rack[i] = cmd.rack[i];
+             }
+ 
+             reply(message, response, cmdNumber, {error: 0});
+ 
+             break;
+         }
+ 
+         case "resetGame": {
+             game.reset();
+             reply(message, response, cmdNumber, {error: 0});
+             break;
+         }
+ 
+         case "msg": {
+             game.pendingEvents.push({
+                 msg: {
+                     sender: playerName,
+                     content: cmd.msg,
+                     specialMsg: cmd.specialMsg
+                 }
+             });
+ 
+             reply(message, response, cmdNumber, {error: 0});
+             break;
+         }
+ 
+         default: {
+             reply(message, response, cmdNumber, {error: 1, reason: "Unknown command"});
+             return false;
+         }
+     }
+ 
+     return true;
+ }
+ 
+ function handleCommands(message, responseAndType) {
+     if (!message.cmds || !message.cmds.length) {
+         const {gameNumber, game} = joinGame(message.gameNumber);
+ 
+         writeMessage(responseAndType,
+             JSON.stringify({
+                 playerName:       message.playerName,
+                 currentPlayer:    game.currentPlayer,
+                 gameNumber:       gameNumber,
+                 letterValues:     game.letterValues,
+                 rack:             game.getPlayerRack(message.playerName),
+                 board:            game.board,
+                 remainingLetters: game.bag.length,
+                 version:          VERSION
+             })
+         );
+ 
+         game.addListeningPlayer(message.playerName, responseAndType);
+         return;
+     }
+ 
+     let wsMessage = "";
+ 
+     const response = (
+         responseAndType[1] === REQUEST_TYPE_WEBSOCKET
+             ? {
+                 write: function (s) {
+                     wsMessage += s;
+                 },
+ 
+                 end: function (s) {
+                     if (s) {
+                         wsMessage += s;
+                     }
+                     webSocketWrite(responseAndType[0], wsMessage);
+                 }
+             }
+             : responseAndType[0]
+     );
+ 
+     response.write("[");
+ 
+     for (let i = 0; i < message.cmds.length; i++) {
+         if (i) {
+             response.write(",");
+         }
+ 
+         if (!handleCommand(i, message, response)) {
+             break;
+         }
+     }
+ 
+     response.end("]");
+ 
+     if (games[message.gameNumber]) {
+         games[message.gameNumber].commit();
+     }
+ }
+ 
+ // Thx https://medium.com/hackernoon/implementing-a-websocket-server-with-node-js-d9b78ec5ffa8
+ 
+ function webSocketWrite(socket, data) {
+     /* eslint-disable capitalized-comments */
+ 
+     // Copy the data into a buffer
+     data = Buffer.from(data);
+     const byteLength = data.length;
+ 
+     // Note: we're not supporting > 65535 byte payloads at this stage
+     const lengthByteCount = byteLength < 126 ? 0 : 2;
+     const payloadLength = lengthByteCount === 0 ? byteLength : 126;
+     const buffer = Buffer.alloc(2 + lengthByteCount + byteLength);
+ 
+     // Write out the first byte, using opcode `1` to indicate that the message
+     // payload contains text data
+     buffer.writeUInt8(0b10000001, 0);
+     buffer.writeUInt8(payloadLength, 1);
+ 
+     // Write the length of the payload to the second byte
+ 
+     if (lengthByteCount === 2) {
+         buffer.writeUInt16BE(byteLength, 2);
+     }
+ 
+     // Write the data to the data buffer
+     data.copy(buffer, 2 + lengthByteCount);
+     socket.write(buffer);
+ }
+ 
+ function webSocketGetMessage(buffer) {
+     /* eslint-disable no-bitwise, capitalized-comments */
+ 
+     let dataAfter = "";
+     let finalOffset = -1;
+ 
+     if (buffer.length < 2) {
+         return ["", 0];
+     }
+ 
+     const firstByte = buffer.readUInt8(0);
+ 
+ 
+     const isFinalFrame = Boolean((firstByte >>> 7) & 0x1);
+     // const [reserved1, reserved2, reserved3] = [
+     //     Boolean((firstByte >>> 6) & 0x1),
+     //     Boolean((firstByte >>> 5) & 0x1),
+     //     Boolean((firstByte >>> 4) & 0x1)
+     // ];
+ 
+     const opCode = firstByte & 0xF;
+ 
+     // We can return null to signify that this is a connection termination frame
+     if (opCode === 0x8) {
+         return null;
+     }
+ 
+     const secondByte = buffer.readUInt8(1);
+     const isMasked = (secondByte >>> 7) & 0x1;
+ 
+     // Keep track of our current position as we advance through the buffer
+     let currentOffset = 2;
+     let payloadLength = secondByte & 0x7F;
+     if (payloadLength > 125) {
+         if (payloadLength === 126) {
+             payloadLength = buffer.readUInt16BE(currentOffset);
+             currentOffset += 2;
+         } else {
+             // 127
+             // If this has a value, the frame size is ridiculously huge!
+             // const leftPart = buffer.readUInt32BE(currentOffset);
+             // const rightPart = buffer.readUInt32BE(currentOffset += 4);
+             // Honestly, if the frame length requires 64 bits, you're probably doing it wrong.
+             // In Node.js you'll require the BigInt type, or a special library to handle this.
+             throw new Error("Large websocket payloads not currently implemented");
+         }
+     }
+ 
+     if (payloadLength + currentOffset > buffer.length) {
+         return ["", 0];
+     }
+ 
+     if (!isFinalFrame) {
+         const message = webSocketGetMessage(buffer.slice(payloadLength + currentOffset));
+         if (message) {
+             if (message[1]) {
+                 dataAfter = message[0];
+                 finalOffset = message[1];
+             } else {
+                 return ["", 0];
+             }
+         } else if (message === null) {
+             // ??!?
+             return null;
+         }
+     }
+ 
+     let maskingKey;
+     if (isMasked) {
+         maskingKey = buffer.readUInt32BE(currentOffset);
+         currentOffset += 4;
+     }
+ 
+     // Allocate somewhere to store the final message data
+     const data = Buffer.alloc(payloadLength);
+ 
+     // Only unmask the data if the masking bit was set to 1
+     if (isMasked) {
+         // Loop through the source buffer one byte at a time, keeping track of which
+         // byte in the masking key to use in the next XOR calculation
+         for (let i = 0, j = 0; i < payloadLength; ++i, j = i % 4) {
+             // Extract the correct byte mask from the masking key
+             const shift = (j === 3) ? 0 : (3 - j) << 3;
+             const mask = (shift === 0 ? maskingKey : (maskingKey >>> shift)) & 0xFF;
+ 
+             // Read a byte from the source buffer
+             const source = buffer.readUInt8(currentOffset++);
+ 
+             // XOR the source byte and write the result to the data buffer
+             data.writeUInt8(mask ^ source, i);
+         }
+     } else {
+         // Not masked - we can just read the data as-is
+         buffer.copy(data, 0, currentOffset);
+     }
+ 
+     return [
+         opCode === 0x1
+             ? (data.toString("utf8") + dataAfter)
+             : "",
+         finalOffset === -1
+             ? currentOffset
+             : finalOffset
+     ];
+ }
+ 
+ function handleRequest(request, response) {
+     let post = "";
+     const responseAndType = [response, REQUEST_TYPE_LONG_POLLING];
+     let upgradedToWebSocket = false;
+ 
+     response.on("error", function connectionError(e) {
+         console.error("An error occurred while trying to write on a socket", e);
+         stopKeepAlive(responseAndType);
+     });
+ 
+     // Thx http://stackoverflow.com/questions/4295782/how-do-you-extract-post-data-in-node-js
+     request.on("data", function (data) {
+         if (upgradedToWebSocket) {
+             return;
+         }
+ 
+         post += data;
+ 
+         // Too much POST data, kill the connection!
+         if (post.length > 1e6) {
+             request.connection.destroy();
+         }
+     });
+ 
+     request.on("upgrade", function () {
+         upgradedToWebSocket = true;
+     });
+ 
+     request.on("error", function (error) {
+         console.error("Error while handling this request", request, error);
+     });
+ 
+     request.on("end", function () {
+         if (upgradedToWebSocket) {
+             return;
+         }
+ 
+         if (!running) {
+             response.statusCode = 503;
+             response.statusMessage = "Server is stopping";
+             response.end("Server is stopping");
+             return;
+         }
+ 
+         if (DEV_ENABLE_SERVING_FILES && !request.url.startsWith("/:")) {
+             if (request.url === "/") {
+                 request.url = "/index.html";
+             }
+ 
+             debuglog("Serving " + request.url);
+ 
+             fs.exists("public/" + request.url, function (exists) {
+                 if (exists) {
+                     fs.readFile("public/" + request.url, function(err, contents) {
+                         if (err) {
+                             response.statusCode = 500;
+                             response.setHeader("Content-Type", "text/plain; charset=utf-8");
+                             response.end(err);
+                         }
+ 
+                         let mime = "application/xhtml+xml; charset=utf-8";
+                         const mimes = {
+                             ".mp3": "audio/mpeg",
+                             ".ogg": "audio/ogg",
+                             ".js": "application/javascript; charset=utf-8",
+                             ".css": "text/css; charset=utf-8",
+                             ".svg": "image/svg+xml"
+                         };
+ 
+                         for (const i in mimes) {
+                             if (Object.prototype.hasOwnProperty.call(mimes, i) && request.url.endsWith(i)) {
+                                 mime = mimes[i];
+                                 if (i === ".js") {
+                                     contents = contents.toString().replace(
+                                         /(?<before>^|\s|\()(?:const|let)(?<after>\s)/gu,
+                                         "$1var$2"
+                                     );
+                                 }
+                                 break;
+                             }
+                         }
+ 
+                         response.setHeader("Content-Type", mime);
+                         response.end(contents);
+                     });
+                 } else {
+                     response.statusCode = 404;
+                     response.end("404");
+                 }
+             });
+ 
+             return;
+         }
+ 
+         const sseMatch = request.url.match(/\/(?::[^/]+\/)?sse\/(?<data>[\s\S]+)$/u);
+ 
+         if (sseMatch) {
+             response.setHeader("Content-Type", "text/event-stream");
+             post = decodeURIComponent(sseMatch.groups.data);
+             responseAndType[1] = REQUEST_TYPE_SSE;
+         } else {
+             response.setHeader("Content-Type",   "text/plain");
+             response.setHeader("Transfer-Encoding", "chunked");
+         }
+ 
+         debuglog("REQ:", request.url, post, responseAndType[1]);
+ 
+         try {
+             post = post && JSON.parse(post);
+         } catch (e) {
+             response.statusCode = 400;
+             response.setHeader("application/json");
+             response.end('{"error":1, "reason": "Unable to parse your JSON data"}');
+             return;
+         }
+ 
+         handleCommands(post, responseAndType);
+     });
+ }
+ 
+ fs.readFile(GAMES_BACKUP, function (err, data) {
+     try {
+         if (err) {
+             console.error("WARNING: Could not restore previous backup of the games");
+         } else {
+             const backup = JSON.parse(data);
+ 
+             for (const gameNumber in backup) {
+                 if (Object.prototype.hasOwnProperty.call(backup, gameNumber)) {
+                     games[gameNumber] = Game.fromJSON(backup[gameNumber]);
+                 }
+             }
+         }
+     } catch (e) {
+         console.error("WARNING: Could not restore previous backup of the games: file is broken:");
+         console.error("WARNING: ", e);
+     }
+ 
+     // See https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers
+     server.on("upgrade", function upgradeToWebsocket(request, socket) {
+         const responseAndType = [socket, REQUEST_TYPE_WEBSOCKET];
+ 
+         function closeSocket() {
+             responseAndType[0] = null;
+         }
+ 
+         socket.on("error", function (e) {
+             console.error("Socket error", e.toString());
+             closeSocket();
+         });
+ 
+         socket.on("close", closeSocket);
+ 
+         const webSocketMatch = request.url.match(/\/(?::[^/]+\/)?ws\/(?<data>[\s\S]+)$/u);
+ 
+         let decodedData;
+         try {
+             decodedData = decodeURIComponent(webSocketMatch.groups.data);
+         } catch (e) {
+             socket.end("HTTP/1.1 400 Bad Request");
+             console.error("WS: FAILED - Could not decode the given data " + webSocketMatch.groups.data);
+             return;
+         }
+ 
+         let message;
+ 
+         if (webSocketMatch) {
+             try {
+                 message = JSON.parse(decodedData);
+             } catch (e) {
+                 socket.end("HTTP/1.1 400 Bad Request");
+                 console.error("WS: FAILED - Could not parse the given data " + decodedData);
+                 return;
+             }
+         } else {
+             socket.end("HTTP/1.1 400 Bad Request");
+             return;
+         }
+ 
+         if (request.headers.upgrade.toLowerCase() !== "websocket") {
+             socket.end("HTTP/1.1 400 Bad Request");
+             console.error("WS: FAILED - HTTP header 'Upgrade' is not 'websocket' " + decodedData);
+             return;
+         }
+ 
+         socket.write(
+             "HTTP/1.1 101 Web Socket Protocol Handshake\r\n" +
+             "Upgrade: WebSocket\r\n" +
+             "Connection: Upgrade\r\n"
+         );
+ 
+         const webSocketKey = request.headers["sec-websocket-key"];
+ 
+         if (webSocketKey) {
+             socket.write(
+                 "Sec-WebSocket-Accept: " + (
+                     crypto
+                         .createHash("sha1")
+                         .update(webSocketKey + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11", "binary")
+                         .digest("base64")
+                 ) + "\r\n"
+             );
+         }
+ 
+         socket.write("\r\n");
+ 
+         debuglog("WS: established " + decodedData);
+ 
+         let receivedBytes = null;
+ 
+         socket.on("data", function webSocketData(buffer) {
+             if (receivedBytes) {
+                 buffer = Buffer.concat([receivedBytes, buffer]);
+                 receivedBytes = null;
+             }
+ 
+             while (true) {
+                 const wsMessage = webSocketGetMessage(buffer);
+                 if (wsMessage && wsMessage[1]) {
+                     if (wsMessage[0]) {
+                         debuglog("WS message: " + wsMessage[0]);
+                         try {
+                             message.cmds = JSON.parse(wsMessage[0]);
+                         } catch (e) {
+                             writeMessage(responseAndType, '{"error":1, "reason": "Unable to parse your JSON data"}');
+                             return;
+                         }
+ 
+                         handleCommands(message, responseAndType);
+                         message.cmds = null;
+                     }
+                 } else if (wsMessage === null) {
+                     if (responseAndType[0] && !socket.isDestroyed) {
+                         responseAndType[0] = null;
+                         socket.end(webSocketCloseBuffer);
+                     }
+ 
+                     return;
+                 }
+ 
+                 if (wsMessage[1] === buffer.length) {
+                     break;
+                 }
+ 
+                 if (wsMessage[1] === 0) {
+                     receivedBytes = buffer;
+                     return;
+                 }
+ 
+                 buffer = buffer.slice(wsMessage[1]);
+             }
+         });
+ 
+         handleCommands(message, responseAndType);
+     });
+ 
+     server.on("error", function (error) {
+         console.error("An error happened in the HTTP server", error);
+     });
+ 
+     server.listen(port, function() {
+         console.log("Server listening on: http://localhost:%s", port);
+     });
+ });
+ 
+ 
+ process.once("SIGINT", function () {
+     console.log("SIGINT received...");
+     stop();
+ });
+ 
+ process.once("SIGTERM", function () {
+     console.log("SIGTERM received...");
+     stop();
+ });
+ 
+ process.on("uncaughtException", function (err) {
+     console.log(err);
+ });