From aa6ce1d05490701a05974760f1aa073729ec1b56 Mon Sep 17 00:00:00 2001
From: Benjamin Auder <benjamin.auder@somewhere>
Date: Sun, 7 Jan 2018 20:59:01 +0100
Subject: [PATCH] Finish temporary TODO - last version before 'matoso2'

---
 TODO           |   2 -
 css/index.css  |   7 +-
 doc/index.html |  12 ++-
 doc/index.pug  |  12 ++-
 js/index.js    | 196 +++++++++++++++++++++++++------------------------
 5 files changed, 122 insertions(+), 107 deletions(-)

diff --git a/TODO b/TODO
index 4449d4c..2c1e349 100644
--- a/TODO
+++ b/TODO
@@ -26,5 +26,3 @@ Sauvegarde en temps réel sous forme players / rounds --> use Dexie http://dexie
  7) Rubrique "Parcours" comme MaToSo (tout OK là-dedans, affichage avec connaissances en cours)
     --> boites de dimensions précises, position absolute, [21x29.7] / 2
  9) Drapeaux pays...
-
-En attendant, améliorer algo appariements dans version actuelle et chrono amélioré.
diff --git a/css/index.css b/css/index.css
index 3d7468b..6f0544d 100644
--- a/css/index.css
+++ b/css/index.css
@@ -65,7 +65,6 @@ img.logo {
 }
 
 .btn {
-  background-color: #3498db;
   background-image: linear-gradient(to bottom, #3498db, #2980b9);
   box-shadow: 0px 2px 3px #666666;
   font-family: Arial;
@@ -77,7 +76,6 @@ img.logo {
 }
 
 .btn:hover {
-  background-color: #3cb0fd;
   background-image: linear-gradient(to bottom, #3cb0fd, #3498db);
   text-decoration: none;
 }
@@ -128,6 +126,11 @@ button.cancel:hover {
 	background-image: linear-gradient(to bottom, #fc433c, #d93434);
 }
 
+button:disabled, button:disabled:hover {
+	background-image: linear-gradient(to bottom, #aaa, #777);
+	cursor: default;
+}
+
 /* players div */
 
 #players {
diff --git a/doc/index.html b/doc/index.html
index c7ab15d..1a73966 100644
--- a/doc/index.html
+++ b/doc/index.html
@@ -10,7 +10,7 @@
       <li>Bouton "(Ré)initialiser" : charger la liste des joueurs, puis cliquer sur les absents</li>
       <li>Aller dans la section "appariements" et cliquer sur le bouton "Nouvelle ronde"</li>
       <li>Lancer le chrono (section "Chronomètre", puis clic gauche [puis F11])</li>
-      <li>Après la ronde, cliquer sur chaque table pour indiquer les (mini-)points, <br> <strong>puis cliquer sur "Valider".</strong></li>
+      <li>Après la ronde, cliquer sur chaque table pour indiquer les (mini-)points, <br> <strong>puis cliquer sur "Nouvelle ronde".</strong></li>
     </ol>
     <p>Le classement est mis à jour dans la rubrique correspondante.</p>
     <p>Voir aussi la <a href="https://auder.net/westcastle_demo.webm">vidéo de démonstration</a></p>
@@ -50,6 +50,11 @@
     <p>
       En cas d'erreur, re-cliquer sur la table à corriger : cliquer ensuite sur "Annuler", puis ré-entrer les scores.
       
+    </p>
+    <p>Même s'il n'y a plus aucune ronde, ne pas oublier de <strong>cliquer sur "Nouvelle ronde"</strong> :
+      cela mémorise les appariements actuels, et surtout enregistre les derniers scores en mémoire.
+      le bouton "Annuler", quant à lui, efface les scores déjà entrés et relance la ronde (nouvel appariement).
+      
     </p>
     <h2>Chronomètre</h2>
     <p class="small-spacing">
@@ -58,8 +63,9 @@
       
     </p>
     <ul>
-      <li>un clic gauche lance le chrono ou le met en pause. Un gong retentit une fois le temps écoulé.</li>
-      <li>un clic droit réinitialise le chrono (à 1h30 = 90 minutes)</li>
+      <li>un clic gauche lance le chrono ou le met en pause. Un gong retentit au début et une fois le temps écoulé.</li>
+      <li>Un appui sur la barre d'espace permet d'éditer le temps initial (en minutes).</li>
+      <li>un clic droit réinitialise le chrono (90 minutes = 1h30 par défaut).</li>
       <li>un clic sur la croix en haut à droite revient à la section appariements (suite logique).</li>
     </ul>
     <p>
diff --git a/doc/index.pug b/doc/index.pug
index 3e68ab4..88778dd 100644
--- a/doc/index.pug
+++ b/doc/index.pug
@@ -13,7 +13,7 @@ html
 			li Bouton "(Ré)initialiser" : charger la liste des joueurs, puis cliquer sur les absents
 			li Aller dans la section "appariements" et cliquer sur le bouton "Nouvelle ronde"
 			li Lancer le chrono (section "Chronomètre", puis clic gauche [puis F11])
-			li Après la ronde, cliquer sur chaque table pour indiquer les (mini-)points, #[br] #[strong puis cliquer sur "Valider".]
+			li Après la ronde, cliquer sur chaque table pour indiquer les (mini-)points, #[br] #[strong puis cliquer sur "Nouvelle ronde".]
 		p Le classement est mis à jour dans la rubrique correspondante.
 
 		p Voir aussi la #[a(href="https://auder.net/westcastle_demo.webm") vidéo de démonstration]
@@ -54,6 +54,11 @@ html
 		p.
 			En cas d'erreur, re-cliquer sur la table à corriger : cliquer ensuite sur "Annuler", puis ré-entrer les scores.
 
+		p.
+			Même s'il n'y a plus aucune ronde, ne pas oublier de #[strong cliquer sur "Nouvelle ronde"] :
+			cela mémorise les appariements actuels, et surtout enregistre les derniers scores en mémoire.
+			le bouton "Annuler", quant à lui, efface les scores déjà entrés et relance la ronde (nouvel appariement).
+
 		h2 Chronomètre
 
 		p.small-spacing.
@@ -61,8 +66,9 @@ html
 			(la touche peut varier suivant le navigateur...). Ensuite :
 
 		ul
-			li un clic gauche lance le chrono ou le met en pause. Un gong retentit une fois le temps écoulé.
-			li un clic droit réinitialise le chrono (à 1h30 = 90 minutes)
+			li un clic gauche lance le chrono ou le met en pause. Un gong retentit au début et une fois le temps écoulé.
+			li Un appui sur la barre d'espace permet d'éditer le temps initial (en minutes).
+			li un clic droit réinitialise le chrono (90 minutes = 1h30 par défaut).
 			li un clic sur la croix en haut à droite revient à la section appariements (suite logique).
 
 		p.
diff --git a/js/index.js b/js/index.js
index b51d590..226aede 100644
--- a/js/index.js
+++ b/js/index.js
@@ -77,9 +77,9 @@ new Vue({
 					<div v-show="currentIndex < 0">
 						<div class="button-container-horizontal">
 							<button class="btn cancel" :class="{hide: tables.length==0}" @click="cancelRound()" title="Annule la ronde courante : tous les scores en cours seront perdus, et un nouveau tirage effectué. ATTENTION : action irréversible">
-								Valider
+								Annuler
 							</button>
-							<button id="doPairings" class="btn" :class="{cancel: tables.length>0}" :disabled="scored.some( s => { return !s; })" @click="doPairings()" title="Répartit les joueurs actifs aléatoirement sur les tables">
+							<button id="doPairings" class="btn" :disabled="scored.some( s => { return !s; })" @click="doPairings()" title="Répartit les joueurs actifs aléatoirement sur les tables">
 								Nouvelle ronde
 							</button>
 						</div>
@@ -113,7 +113,7 @@ new Vue({
 							<button :class="{hide:scored[currentIndex]}" class="btn validate" @click="setScore()" title="Enregistre le score dans la base">
 								Enregistrer
 							</button>
-							<button :class="{hide:!scored[currentIndex]}" class="btn cancel" @click="resetScore()" title="Annule le score précédemment enregistré">
+							<button :class="{hide:!scored[currentIndex]}" class="btn cancel" @click="cancelScore()" title="Annule le score précédemment enregistré">
 								Annuler
 							</button>
 							<button class="btn" @click="closeScoreForm()">Fermer</button>
@@ -122,66 +122,46 @@ new Vue({
 				</div>
 			`,
 			methods: {
-				// TODO: télécharger la ronde courante
-				// TODO: mémoriser les appariements passés pour éviter que les mêmes joueurs se rencontrent plusieurs fois
-				// --> dans la base: tableau rounds, rounds[0] : {tables[0,1,...], chacune contenant 4 indices de joueurs; + sessions[0,1,...]}
-				// --> devrait séparer les components en plusieurs fichiers...
-				// cas à 5 joueurs : le joueur exempt doit tourner (c'est fait automatiquement en fait)
+				// TODO: télécharger la ronde courante (faudrait aussi mémoriser les points...)
+				// --> je devrais séparer les components en plusieurs fichiers maintenant
 				cancelRound: function() {
 					this.scored.forEach( (s,i) => {
 						if (s)
 						{
 							// Cancel this table
-							this.currentIndex = i; //TODO: clumsy. funcions should take "index" as argument
-							this.resetScore();
+							this.currentIndex = i; //TODO: clumsy. functions should take "index" as argument
+							this.cancelScore();
 						}
 					});
 					this.currentIndex = -1;
 					this.doPairings();
 				},
 				doPairings: function() {
-					let rounds = JSON.parse(localStorage.getItem("rounds"));
-
+					let rounds = JSON.parse(localStorage.getItem("rounds")) || [];
 					if (this.scored.some( s => { return s; }))
 					{
 						this.commitScores(); //TODO: temporary: shouldn't be here... (incremental commit)
-						if (rounds === null)
-							rounds = [];
 						rounds.push(this.tables);
+						localStorage.setItem("rounds", JSON.stringify(rounds));
 					}
-
-					// 1) Compute the "meeting" matrix: who played who and how many times
-					let meetMat = _.range(this.players.length).map( i => {
-						_.range(this.players.length).map( j => {
-							return 0;
-						});
-					});
-					rounds.forEach( r => { //for each round
-						r.forEach( t => { //for each table within round
-							for (let i=0; i<4; i++) //TODO: these loops are ugly
-							{
-								for (let j=0; j<4; j++)
-								{
-									if (j!=i)
-										meetMat[i][j]++;
-								}
-							}
-						});
-					});
-
-					// 2) Pre-compute tables repartition (in numbers): depends on active players count % 4
+					this.currentIndex = -1; //required if reset while scoring
+					let tables = [];
+					// 1) Pre-compute tables repartition (in numbers): depends on active players count % 4
 					let activePlayers = this.players
-						.map( (p,i) => { return Object.Assign({}, p, {index:i}); })
+						.map( (p,i) => { return Object.assign({}, p, {index:i}); })
 						.filter( p => { return p.available; });
 					let repartition = _.times(Math.floor(activePlayers.length/4), _.constant(4));
-					switch (activePlayers.length % 4)
+					let remainder = activePlayers.length % 4;
+					if (remainder > 0)
+						repartition.push(remainder);
+					switch (remainder)
 					{
 						case 1:
 							// Need 2 more
 							if (repartition.length-1 >= 2)
 							{
-								repartition[0]--;
-								repartition[1]--;
+								repartition[repartition.length-3] --  ;
+								repartition[repartition.length-2] --  ;
 								repartition[repartition.length-1] += 2;
 							}
 							break;
@@ -189,73 +169,95 @@ new Vue({
 							// Need 1 more
 							if (repartition.length-1 >= 1)
 							{
-								repartition[0]--;
-								repartition[repartition.length-1]++;
+								repartition[repartition.length-2] --  ;
+								repartition[repartition.length-1] ++  ;
 							}
 							break;
 					}
-
-					// 3) Sort people by total games played (increasing) - naturally solve the potential unpaired case
-					let totalGames = _.range(this.players.length).map( i => { return 0; });
-					rounds.forEach( r => {
-						r.forEach(t => {
-							t.forEach( p => {
-								totalGames[p]++;
-							})
-						})
-					});
-					let sortedPlayers = activePlayers
-						.map( (p,i) => { return Object.Assign({}, p, {games:totalGames[p.index]}); })
-						.sort( (a,b) => { return a.games - b.games; });
-
-					// 4) Affect people on tables, following total games sorted order (with random sampling on ex-aequos)
-					// --> et surtout en minimisant la somme des rencontres précédentes (ci-dessus : cas particulier rare à peu de joueurs)
-//TODO
-					// Simple case first: 4 by 4
-					let tables = [];
-					let currentTable = [];
-					let ordering = _.shuffle(_.range(this.players.length));
-					for (let i=0; i<ordering.length; i++)
+					// 2) Shortcut for round 1: just spread at random
+					if (rounds.length == 0)
 					{
-						if ( ! this.players[ordering[i]].available )
-							continue;
-						if (currentTable.length >= 4)
-						{
-							tables.push(currentTable);
-							currentTable = [];
-						}
-						currentTable.push(ordering[i]);
+						let currentTable = [];
+						let ordering = _.shuffle(_.range(activePlayers.length));
+						let tableIndex = 0;
+						ordering.forEach( i => {
+							currentTable.push(activePlayers[i].index);
+							if (currentTable.length == repartition[tableIndex])
+							{
+								if (currentTable.length == 3)
+									currentTable.push(0); //add Toto
+								// flush
+								tables.push(currentTable);
+								currentTable = [];
+								tableIndex++;
+							}
+						});
 					}
-					// Analyse remainder
-					this.unpaired = [];
-					if (currentTable.length != 0)
+					else
 					{
-						if (currentTable.length < 3)
-						{
-							let missingPlayers = 3 - currentTable.length;
-							// Pick players from 'missingPlayers' random different tables, if possible
-							if (tables.length >= missingPlayers)
+						// General case after round 1:
+						// NOTE: alternative method, deterministic: player 1 never move, player 2 moves by 1, ...and so on
+						// --> but this leads to inferior pairings (e.g. 2 tables 8 players)
+						// -----
+						// 2bis) Compute the "meeting" matrix: who played who and how many times
+						let meetMat = _.range(this.players.length).map( i => {
+							return _.times(this.players.length, _.constant(0));
+						});
+						rounds.forEach( r => { //for each round
+							r.forEach( t => { //for each table within round
+								for (let i=0; i<4; i++) //TODO: these loops are ugly
+								{
+									for (let j=i+1; j<4; j++)
+										meetMat[t[i]][t[j]]++;
+								}
+							});
+						});
+						// 3) Fill tables by minimizing row sums of meetMat
+						const playersCount = activePlayers.length;
+						repartition.forEach( r => {
+							// Pick first player at random among active players, unless there is one unpaired guy
+							let firstPlayer = this.unpaired[0]; //can be undefined
+							if (!firstPlayer || activePlayers.length < playersCount)
 							{
-								let tblNums = _.sample(_.range(tables.length), missingPlayers);
-								tblNums.forEach( num => {
-									currentTable.push(tables[num].pop());
+								let randIndex = _.sample( _.range(activePlayers.length) );
+								firstPlayer = activePlayers[randIndex].index;
+								activePlayers.splice(randIndex, 1);
+							}
+							else
+								activePlayers.splice( activePlayers.findIndex( item => { return item.index == firstPlayer; }), 1 );
+							let table = [ firstPlayer ];
+							for (let i=1; i<r; i++)
+							{
+								// Minimize row sums of meetMat for remaining players
+								let counts = [];
+								activePlayers.forEach( u => {
+									let count = 0;
+									let candidate = u.index;
+									table.forEach( p => {
+										count += meetMat[p][candidate];
+										count += meetMat[candidate][p];
+									});
+									counts.push( {index:u.index, count:count } );
 								});
+								counts.sort( (a,b) => { return a.count - b.count; });
+								table.push(counts[0].index);
+								activePlayers.splice( activePlayers.findIndex( item => { return item.index == counts[0].index; }), 1 );
 							}
-						}
-						if (currentTable.length >= 3)
-							tables.push(currentTable);
-						else
-							this.unpaired = currentTable;
+							if (table.length == 3)
+								table.push(0); //add Todo
+							tables.push(table);
+						});
 					}
-					// Ensure that all tables have 4 players
-					tables.forEach( t => {
-						if (t.length < 4)
-							t.push(0); //index of "Toto", ghost player
-					});
+					if (tables.length >= 1 && tables[tables.length-1].length < 3)
+						this.unpaired = tables.pop();
+					else
+						this.unpaired = [];
 					this.tables = tables;
-					this.sessions = tables.map( t => { return []; }); //empty sessions
-					this.scored = tables.map( t => { return false; }); //nothing scored yet
-					this.currentIndex = -1; //required if reset while scoring
+					this.resetScores();
+				},
+				resetScores: function() {
+					this.sessions = this.tables.map( t => { return []; }); //empty sessions
+					this.scored = this.tables.map( t => { return false; }); //nothing scored yet
 				},
 				showScoreForm: function(table,index) {
 					if (this.sessions[index].length == 0)
@@ -305,7 +307,7 @@ new Vue({
 					Vue.set(this.scored, this.currentIndex, true);
 					this.currentIndex = -1;
 				},
-				resetScore: function() {
+				cancelScore: function() {
 					let pdts = this.getPdts();
 					for (let i=0; i<4; i++)
 					{
@@ -377,7 +379,7 @@ new Vue({
 						this.running = false;
 						return;
 					}
-					if (this.time == this.initialTime)
+					if (this.time == this.initialTime * 60)
 						new Audio("sounds/gong.mp3").play(); //gong at the beginning
 					setTimeout(() => {
 						if (this.running)
-- 
2.44.0