Compare commits

6 Commits

Author SHA1 Message Date
0b818414ed Restaure index.html supprime par erreur, avec les masquages
Le commit precedent avait supprime index.html au lieu de le modifier.
Ce commit le retablit dans son integralite (429 lignes) avec le
display:none sur la documentation et les rapports de veille.
2026-07-21 08:31:08 +02:00
f027dfcf4c Masque provisoirement la documentation et les rapports de veille
Ajoute display:none aux liens de navigation, aux boutons du hero et aux
deux sections #documentation et #rapports. Le contenu reste dans le code
pour un reaffichage ulterieur.
2026-07-21 08:29:55 +02:00
34216a52f9 Fusion de exos-rework dans main
Retravaille le TP Nuisibles : inversion accueil/identification, style
BioCID, navigation regroupee dans la bottom bar, boutons d'action
simplifies et encadres finaux recentres.

# Conflicts:
#	.gitignore
2026-07-21 08:24:27 +02:00
8164b03cc4 Refonte du rapport de veille Certibiocide semaine 28
Enrichit et restructure le rapport HTML de la semaine 28 (06-12 juillet 2026).
2026-07-17 17:03:57 +02:00
317f98b711 Merge branch 'memory-results' : classement Memory en ligne 2026-07-15 22:35:33 +02:00
2a85ced1e6 Passe le classement Memory en ligne (ASP.NET + SQL Server)
Le classement du jeu Memory était stocké en localStorage (visible d'un seul
joueur, sur un seul poste). Il est désormais partagé en ligne via une API
ASP.NET/SQL Server adaptée à l'hébergement bsite.net.

- API scores.ashx (C#, compilée au vol par IIS) : lecture GET et
  enregistrement POST, création automatique de la table, ne conserve que le
  top 10 de chaque mode (facile / normal / difficile).
- leaderboard.js : le classement passe par fetch vers l'API (états
  chargement/erreur/vide, surbrillance de la ligne du joueur). Les
  statistiques personnelles restent stockées localement.
- memory-game.js : rafraîchit le classement après l'enregistrement (async).
- Style .is-me pour la ligne du joueur courant.
- web.config.example fourni ; le web.config réel (mot de passe) est ignoré.
2026-07-15 22:35:24 +02:00
10 changed files with 1351 additions and 620 deletions

3
.gitignore vendored
View File

@@ -1,5 +1,6 @@
.DS_Store .DS_Store
# Contient le mot de passe SQL Server — à éditer uniquement sur bsite.net # Contient le mot de passe de la base SQL Server — à créer/éditer uniquement sur
# le serveur bsite.net à partir de web.config.example (ne jamais versionner)
exercices/memory-pictogrammes/api/web.config exercices/memory-pictogrammes/api/web.config

View File

@@ -0,0 +1,55 @@
# API du classement Memory (ASP.NET + SQL Server)
Petit service **ASP.NET (C#) + SQL Server** qui héberge en ligne le
**classement partagé** du jeu Memory. Tous les joueurs enregistrent leurs
scores au même endroit et voient le même top 10 par mode (facile / normal /
difficile).
> Adapté à l'hébergement **FreeASPHosting.net / bsite.net** (Windows, IIS,
> base MSSQL 2016). Le fichier `scores.ashx` est compilé au vol par IIS :
> **aucune étape de build**, il suffit de le déposer.
## Contenu
| Fichier | Rôle |
|---|---|
| `scores.ashx` | Point d'entrée de l'API (GET pour lire, POST pour enregistrer). Crée la table automatiquement au premier appel. |
| `web.config.example` | Modèle de configuration à copier en `web.config`. |
| `web.config` | **À créer sur le serveur** avec le mot de passe SQL Server (clé `MemoryDb`). Non versionné. |
| `README.md` | Ce fichier. |
## Installation sur bsite.net
1. **Récupérer le mot de passe** de la base dans le panneau
*Manage Databases*. La base existe déjà :
- Serveur : `sql.bsite.net\MSSQL2016`
- Base / utilisateur : `nextgnformation_reponses_exercices`
2. **Créer `web.config`** à partir du modèle (`cp web.config.example web.config`),
puis remplacer `VOTRE_MOT_DE_PASSE` par le mot de passe réel dans la clé
`MemoryDb`.
3. **Déposer le dossier `api/`** via *My Files*, au même endroit que
`memory.html` (chemin attendu par le jeu :
`exercices/memory-pictogrammes/api/scores.ashx`).
4. C'est prêt : au premier score enregistré (ou premier appel GET), la table
`memory_scores` est créée automatiquement.
## Vérification rapide
- Lecture du classement :
`GET https://nextgnformation.bsite.net/exercices/memory-pictogrammes/api/scores.ashx?difficulty=all`
doit renvoyer `{"ok":true,"scores":[...]}`.
- Seuls les 10 meilleurs de chaque mode sont conservés : après chaque
enregistrement, les scores hors top 10 du mode concerné sont supprimés.
## Points d'attention
- **Ne poussez jamais `web.config` avec le vrai mot de passe dans un dépôt
public.** Éditez-le directement sur le serveur via *My Files*.
- Les statistiques personnelles (« Vos statistiques ») restent stockées
localement dans le navigateur de chaque joueur ; seul le classement est en ligne.
- Le score étant calculé côté navigateur, l'API valide les types et les bornes
mais ne garantit pas l'absence de triche (recalcul serveur possible plus tard).

View File

@@ -0,0 +1,329 @@
<%@ WebHandler Language="C#" Class="ScoresHandler" %>
/*
* API du classement Memory (pictogrammes de sécurité) — ASP.NET + SQL Server.
*
* GET scores.ashx?difficulty=easy|normal|hard|all[&limit=10]
* -> renvoie le classement (top N) trié par score décroissant.
*
* POST scores.ashx (corps JSON)
* { playerName, userId, score, moves, timeSeconds, difficulty, isPerfect }
* -> enregistre le score, ne conserve que le top N par difficulté,
* renvoie { ok, saved, rank, isTop }.
*
* Seul le top N (défaut 10) de chaque mode est conservé : les scores plus
* faibles sont supprimés après chaque insertion.
*
* Fichier compilé au vol par IIS : il suffit de le déposer, aucune build.
* La chaîne de connexion est lue dans web.config (clé "MemoryDb").
*/
using System;
using System.Collections.Generic;
using System.Data;
using System.Data.SqlClient;
using System.IO;
using System.Text;
using System.Web;
using System.Web.Configuration;
using System.Web.Script.Serialization;
public class ScoresHandler : IHttpHandler
{
static readonly string[] Difficulties = { "easy", "normal", "hard" };
const int TopN = 10;
public bool IsReusable { get { return false; } }
public void ProcessRequest(HttpContext ctx)
{
ctx.Response.ContentType = "application/json; charset=utf-8";
ctx.Response.AddHeader("X-Content-Type-Options", "nosniff");
var connString = WebConfigurationManager.ConnectionStrings["MemoryDb"] != null
? WebConfigurationManager.ConnectionStrings["MemoryDb"].ConnectionString
: null;
if (string.IsNullOrEmpty(connString))
{
Fail(ctx, 500, "Configuration manquante (connectionString 'MemoryDb' dans web.config).");
return;
}
try
{
using (var conn = new SqlConnection(connString))
{
conn.Open();
EnsureTable(conn);
if (ctx.Request.HttpMethod == "GET")
{
HandleGet(ctx, conn);
}
else if (ctx.Request.HttpMethod == "POST")
{
HandlePost(ctx, conn);
}
else
{
ctx.Response.StatusCode = 405;
ctx.Response.AddHeader("Allow", "GET, POST");
Write(ctx, new Dictionary<string, object> {
{ "ok", false }, { "error", "Méthode non autorisée." }
});
}
}
}
catch (Exception)
{
Fail(ctx, 500, "Erreur serveur.");
}
}
// --- Création de la table si besoin -------------------------------------
void EnsureTable(SqlConnection conn)
{
const string sql =
"IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'memory_scores') " +
"CREATE TABLE memory_scores (" +
" id INT IDENTITY(1,1) PRIMARY KEY," +
" player_name NVARCHAR(40) NOT NULL," +
" user_id NVARCHAR(64) NOT NULL," +
" score INT NOT NULL," +
" moves INT NOT NULL," +
" time_seconds INT NOT NULL," +
" difficulty NVARCHAR(16) NOT NULL," +
" is_perfect BIT NOT NULL DEFAULT 0," +
" created_at DATETIME NOT NULL" +
");";
using (var cmd = new SqlCommand(sql, conn)) { cmd.ExecuteNonQuery(); }
}
// --- GET : renvoie le classement ----------------------------------------
void HandleGet(HttpContext ctx, SqlConnection conn)
{
var difficulty = (ctx.Request.QueryString["difficulty"] ?? "all").Trim();
int limit;
if (!int.TryParse(ctx.Request.QueryString["limit"], out limit)) limit = TopN;
if (limit < 1) limit = 1;
if (limit > TopN) limit = TopN;
var rows = new List<Dictionary<string, object>>();
if (difficulty == "all")
{
foreach (var diff in Difficulties)
rows.AddRange(FetchTop(conn, diff, TopN));
rows.Sort(CompareRows);
if (rows.Count > limit) rows = rows.GetRange(0, limit);
}
else
{
if (Array.IndexOf(Difficulties, difficulty) < 0)
{
Fail(ctx, 400, "Difficulté inconnue.");
return;
}
rows = FetchTop(conn, difficulty, limit);
}
Write(ctx, new Dictionary<string, object> { { "ok", true }, { "scores", rows } });
}
// --- POST : enregistre un score puis élague au top N --------------------
void HandlePost(HttpContext ctx, SqlConnection conn)
{
string body;
using (var reader = new StreamReader(ctx.Request.InputStream, Encoding.UTF8))
body = reader.ReadToEnd();
Dictionary<string, object> data = null;
try { data = new JavaScriptSerializer().Deserialize<Dictionary<string, object>>(body); }
catch { data = null; }
if (data == null)
{
Fail(ctx, 400, "Corps JSON invalide.");
return;
}
var difficulty = Str(data, "difficulty");
if (Array.IndexOf(Difficulties, difficulty) < 0)
{
Fail(ctx, 400, "Difficulté inconnue.");
return;
}
// Nettoyage / bornage des entrées
var playerName = Str(data, "playerName").Trim();
playerName = StripControlChars(playerName);
if (playerName.Length == 0) playerName = "Anonyme";
if (playerName.Length > 40) playerName = playerName.Substring(0, 40);
var userId = OnlyIdChars(Str(data, "userId"));
if (userId.Length > 64) userId = userId.Substring(0, 64);
if (userId.Length == 0) userId = "anon";
int score = Clamp(Int(data, "score"), 0, 100000000);
int moves = Clamp(Int(data, "moves"), 0, 100000);
int time = Clamp(Int(data, "timeSeconds"), 0, 1000000);
int isPerfect = Bool(data, "isPerfect") ? 1 : 0;
int insertedId;
using (var cmd = new SqlCommand(
"INSERT INTO memory_scores " +
"(player_name, user_id, score, moves, time_seconds, difficulty, is_perfect, created_at) " +
"OUTPUT INSERTED.id " +
"VALUES (@name, @uid, @score, @moves, @time, @diff, @perfect, GETUTCDATE())", conn))
{
cmd.Parameters.Add("@name", SqlDbType.NVarChar, 40).Value = playerName;
cmd.Parameters.Add("@uid", SqlDbType.NVarChar, 64).Value = userId;
cmd.Parameters.Add("@score", SqlDbType.Int).Value = score;
cmd.Parameters.Add("@moves", SqlDbType.Int).Value = moves;
cmd.Parameters.Add("@time", SqlDbType.Int).Value = time;
cmd.Parameters.Add("@diff", SqlDbType.NVarChar, 16).Value = difficulty;
cmd.Parameters.Add("@perfect", SqlDbType.Bit).Value = isPerfect;
insertedId = (int)cmd.ExecuteScalar();
}
// Ne conserver que le top N pour ce mode
using (var cmd = new SqlCommand(
"WITH ranked AS (" +
" SELECT id, ROW_NUMBER() OVER (" +
" PARTITION BY difficulty ORDER BY score DESC, time_seconds ASC, id ASC) AS rn" +
" FROM memory_scores WHERE difficulty = @diff" +
") DELETE FROM memory_scores WHERE id IN (SELECT id FROM ranked WHERE rn > @topN)", conn))
{
cmd.Parameters.Add("@diff", SqlDbType.NVarChar, 16).Value = difficulty;
cmd.Parameters.Add("@topN", SqlDbType.Int).Value = TopN;
cmd.ExecuteNonQuery();
}
// Le score inséré a-t-il survécu à l'élagage ? Quel rang ?
var top = FetchTop(conn, difficulty, TopN);
int? rank = null;
for (int i = 0; i < top.Count; i++)
{
if ((int)top[i]["id"] == insertedId) { rank = i + 1; break; }
}
Write(ctx, new Dictionary<string, object> {
{ "ok", true },
{ "saved", true },
{ "isTop", rank.HasValue },
{ "rank", rank.HasValue ? (object)rank.Value : null }
});
}
// --- Accès données -------------------------------------------------------
List<Dictionary<string, object>> FetchTop(SqlConnection conn, string difficulty, int limit)
{
var result = new List<Dictionary<string, object>>();
using (var cmd = new SqlCommand(
"SELECT TOP (@limit) id, player_name, user_id, score, moves, time_seconds, " +
"difficulty, is_perfect, created_at " +
"FROM memory_scores WHERE difficulty = @diff " +
"ORDER BY score DESC, time_seconds ASC, id ASC", conn))
{
cmd.Parameters.Add("@limit", SqlDbType.Int).Value = limit;
cmd.Parameters.Add("@diff", SqlDbType.NVarChar, 16).Value = difficulty;
using (var r = cmd.ExecuteReader())
{
while (r.Read())
{
result.Add(new Dictionary<string, object> {
{ "id", (int)r["id"] },
{ "playerName", r["player_name"].ToString() },
{ "userId", r["user_id"].ToString() },
{ "score", (int)r["score"] },
{ "moves", (int)r["moves"] },
{ "timeSeconds", (int)r["time_seconds"] },
{ "difficulty", r["difficulty"].ToString() },
{ "isPerfect", Convert.ToBoolean(r["is_perfect"]) },
{ "date", ((DateTime)r["created_at"]).ToString("yyyy-MM-ddTHH:mm:ss") + "Z" }
});
}
}
}
return result;
}
int CompareRows(Dictionary<string, object> a, Dictionary<string, object> b)
{
int sa = (int)a["score"], sb = (int)b["score"];
if (sa != sb) return sb - sa;
return (int)a["timeSeconds"] - (int)b["timeSeconds"];
}
// --- Sérialisation / helpers --------------------------------------------
void Write(HttpContext ctx, object payload)
{
// On retire la clé "id" (interne) des lignes avant l'envoi.
var scrubbed = Scrub(payload);
ctx.Response.Write(new JavaScriptSerializer().Serialize(scrubbed));
}
object Scrub(object payload)
{
var dict = payload as Dictionary<string, object>;
if (dict != null && dict.ContainsKey("scores"))
{
var list = dict["scores"] as List<Dictionary<string, object>>;
if (list != null)
foreach (var row in list) row.Remove("id");
}
return payload;
}
void Fail(HttpContext ctx, int status, string message)
{
ctx.Response.StatusCode = status;
ctx.Response.Write(new JavaScriptSerializer().Serialize(
new Dictionary<string, object> { { "ok", false }, { "error", message } }));
}
static string Str(Dictionary<string, object> d, string key)
{
object v; return (d.TryGetValue(key, out v) && v != null) ? v.ToString() : "";
}
static int Int(Dictionary<string, object> d, string key)
{
object v; int n;
if (d.TryGetValue(key, out v) && v != null && int.TryParse(v.ToString(), out n)) return n;
return 0;
}
static bool Bool(Dictionary<string, object> d, string key)
{
object v;
if (!d.TryGetValue(key, out v) || v == null) return false;
if (v is bool) return (bool)v;
var s = v.ToString().ToLowerInvariant();
return s == "true" || s == "1";
}
static int Clamp(int value, int min, int max)
{
if (value < min) return min;
if (value > max) return max;
return value;
}
static string StripControlChars(string s)
{
var sb = new StringBuilder(s.Length);
foreach (var c in s) if (!char.IsControl(c)) sb.Append(c);
return sb.ToString();
}
static string OnlyIdChars(string s)
{
var sb = new StringBuilder(s.Length);
foreach (var c in s)
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-')
sb.Append(c);
return sb.ToString();
}
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<!--
MODÈLE de configuration. Sur le serveur bsite.net :
1. Copiez ce fichier en « web.config ».
2. Renseignez le mot de passe fourni par le panneau « Manage Databases ».
Le vrai « web.config » n'est PAS versionné (voir .gitignore) afin de ne
jamais exposer le mot de passe dans l'historique Git.
-->
<connectionStrings>
<add name="MemoryDb"
connectionString="Server=sql.bsite.net\MSSQL2016;Database=nextgnformation_reponses_exercices;User Id=nextgnformation_reponses_exercices;Password=VOTRE_MOT_DE_PASSE;"
providerName="System.Data.SqlClient" />
</connectionStrings>
</configuration>

View File

@@ -93,6 +93,13 @@
overflow: hidden; overflow: hidden;
} }
/* Ligne du joueur courant dans le classement */
.rank-item.is-me {
border-left-color: var(--amber-dark);
background: linear-gradient(135deg, var(--amber-light), var(--white));
box-shadow: 0 2px 8px rgba(186, 117, 23, 0.15);
}
.rank-top { .rank-top {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -1,89 +1,113 @@
/* ============================================ /* ============================================
LEADERBOARD & PLAYER STATISTICS LEADERBOARD & PLAYER STATISTICS
============================================
Le CLASSEMENT est désormais partagé EN LIGNE : tous les joueurs
enregistrent leurs scores via l'API PHP (api/scores.php) et voient le
même top 10 par mode de jeu.
Les STATISTIQUES PERSONNELLES (« Vos statistiques ») restent stockées
localement dans le navigateur du joueur, car le serveur ne conserve que
le top 10 de chaque mode (et non l'historique complet de chacun).
============================================ */ ============================================ */
/** /**
* Manager du classement * Manager du classement
*/ */
class LeaderboardManager { class LeaderboardManager {
static KEY_LEADERBOARD = 'nextgn_leaderboard'; // Point d'entrée de l'API (relatif à memory.html) — ASP.NET + SQL Server
static MAX_SCORES = 100; static API_URL = 'api/scores.ashx';
// Historique local des parties du joueur (pour ses stats perso)
static KEY_HISTORY = 'nextgn_player_history';
static MAX_HISTORY = 500;
// Cache mémoire du dernier classement récupéré (par difficulté)
static _cache = {};
/** /**
* Ajouter un score au classement * Enregistrer une partie :
* - envoie le score au classement en ligne
* - conserve la partie dans l'historique local (stats perso)
* @returns {Promise<Object>} résultat de l'API ({ ok, isTop, rank } ou { ok:false })
*/ */
static addScore(gameData) { static async addScore(gameData) {
const scores = this.getAllScores(); const record = {
const newScore = {
sessionId: generateUUID(),
playerName: getPlayerName(), playerName: getPlayerName(),
userId: getUserId(), userId: getUserId(),
score: gameData.score, score: gameData.score,
moves: gameData.moves, moves: gameData.moves,
timeSeconds: gameData.duration, timeSeconds: gameData.duration,
difficulty: gameData.difficulty, difficulty: gameData.difficulty,
date: new Date().toISOString(),
isPerfect: gameData.moves === DIFFICULTY_MODES[gameData.difficulty].paires, isPerfect: gameData.moves === DIFFICULTY_MODES[gameData.difficulty].paires,
won: gameData.won !== false won: gameData.won !== false,
date: new Date().toISOString()
}; };
scores.push(newScore); // 1) Historique local (statistiques personnelles)
this._recordLocalHistory(record);
// Limiter à MAX_SCORES (garder les plus récents) // 2) Classement en ligne
if (scores.length > this.MAX_SCORES) { try {
scores.sort((a, b) => new Date(b.date) - new Date(a.date)); const res = await fetch(this.API_URL, {
scores.splice(this.MAX_SCORES); method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(record)
});
const data = await res.json();
DEBUG.log('Score submitted:', data);
return data;
} catch (e) {
console.error('Leaderboard submit error:', e);
return { ok: false, error: 'network' };
} }
localStorage.setItem(this.KEY_LEADERBOARD, JSON.stringify(scores));
DEBUG.log('Score added:', newScore);
return newScore;
} }
/** /**
* Obtenir tous les scores * Ajouter la partie à l'historique local du joueur
*/ */
static getAllScores() { static _recordLocalHistory(record) {
const history = this._getLocalHistory();
history.push(record);
if (history.length > this.MAX_HISTORY) {
history.splice(0, history.length - this.MAX_HISTORY);
}
try { try {
const scores = JSON.parse(localStorage.getItem(this.KEY_LEADERBOARD)) || []; localStorage.setItem(this.KEY_HISTORY, JSON.stringify(history));
return scores; } catch (e) {
console.error('History save error:', e);
}
}
/**
* Historique local des parties du joueur
*/
static _getLocalHistory() {
try {
return JSON.parse(localStorage.getItem(this.KEY_HISTORY)) || [];
} catch (e) { } catch (e) {
console.error('Leaderboard error:', e);
return []; return [];
} }
} }
/** /**
* Obtenir le classement (top X) * Récupérer le classement en ligne (top X) pour un mode donné.
* @returns {Promise<Array>}
*/ */
static getLeaderboard(difficulty = 'all', limit = 10) { static async getLeaderboard(difficulty = 'all', limit = 10) {
let scores = this.getAllScores(); const url = `${this.API_URL}?difficulty=${encodeURIComponent(difficulty)}&limit=${limit}`;
const res = await fetch(url, { headers: { 'Accept': 'application/json' } });
// Filtrer par difficulté const data = await res.json();
if (difficulty !== 'all') { if (!data || data.ok !== true || !Array.isArray(data.scores)) {
scores = scores.filter(s => s.difficulty === difficulty); throw new Error('Réponse invalide du serveur');
} }
this._cache[difficulty] = data.scores;
// Trier par score (décroissant) return data.scores;
scores.sort((a, b) => {
if (b.score !== a.score) {
return b.score - a.score;
}
// En cas d'égalité, par temps
return a.timeSeconds - b.timeSeconds;
});
return scores.slice(0, limit);
} }
/** /**
* Obtenir les stats d'un joueur * Statistiques du joueur, calculées à partir de son historique LOCAL.
*/ */
static getPlayerStats(userId) { static getPlayerStats() {
const scores = this.getAllScores().filter(s => s.userId === userId); const scores = this._getLocalHistory();
if (scores.length === 0) { if (scores.length === 0) {
return { return {
@@ -116,61 +140,35 @@ class LeaderboardManager {
} }
/** /**
* Obtenir le rang d'un score * Mettre à jour l'affichage du classement (asynchrone).
*/ */
static getPlayerRank(userId, difficulty = 'all') { static async updateDisplay(difficulty = 'all') {
const leaderboard = this.getLeaderboard(difficulty, Infinity);
const rank = leaderboard.findIndex(s => s.userId === userId) + 1;
return rank > 0 ? rank : null;
}
/**
* Vérifier si c'est un nouveau record personnel
*/
static isPersonalRecord(userId, newScore) {
const stats = this.getPlayerStats(userId);
return newScore > stats.bestScore;
}
/**
* Obtenir les statistiques globales
*/
static getGlobalStats() {
const scores = this.getAllScores();
if (scores.length === 0) {
return {
totalGames: 0,
totalPlayers: 0,
averageScore: 0,
highestScore: 0,
fastestCompletion: Infinity
};
}
const uniquePlayers = new Set(scores.map(s => s.userId));
const totalScore = scores.reduce((a, b) => a + b.score, 0);
const highestScore = Math.max(...scores.map(s => s.score), 0);
const fastestCompletion = Math.min(...scores.map(s => s.timeSeconds), Infinity);
return {
totalGames: scores.length,
totalPlayers: uniquePlayers.size,
averageScore: Math.round(totalScore / scores.length),
highestScore: highestScore,
fastestCompletion: fastestCompletion === Infinity ? 0 : fastestCompletion
};
}
/**
* Mettre à jour l'affichage du classement
*/
static updateDisplay(difficulty = 'all') {
const leaderboard = this.getLeaderboard(difficulty, 10);
const leaderboardList = document.getElementById('leaderboard-list'); const leaderboardList = document.getElementById('leaderboard-list');
if (!leaderboardList) return; if (!leaderboardList) return;
// État de chargement
leaderboardList.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">⏳</div>
<div class="empty-state-text">Chargement du classement…</div>
</div>
`;
let leaderboard;
try {
leaderboard = await this.getLeaderboard(difficulty, 10);
} catch (e) {
console.error('Leaderboard load error:', e);
leaderboardList.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">⚠️</div>
<div class="empty-state-text">Classement indisponible</div>
<div class="empty-state-hint">Vérifiez votre connexion et réessayez.</div>
</div>
`;
return;
}
if (leaderboard.length === 0) { if (leaderboard.length === 0) {
leaderboardList.innerHTML = ` leaderboardList.innerHTML = `
<div class="empty-state"> <div class="empty-state">
@@ -182,11 +180,15 @@ class LeaderboardManager {
return; return;
} }
const myUserId = getUserId();
leaderboardList.innerHTML = ''; leaderboardList.innerHTML = '';
leaderboard.forEach((score, index) => { leaderboard.forEach((score, index) => {
const rankItem = document.createElement('div'); const rankItem = document.createElement('div');
rankItem.className = 'rank-item'; rankItem.className = 'rank-item';
if (score.userId === myUserId) {
rankItem.classList.add('is-me');
}
const rankNumber = this.getRankDisplay(index); const rankNumber = this.getRankDisplay(index);
const medal = index < 3 ? `class="rank-number medal-${index + 1}"` : 'class="rank-number"'; const medal = index < 3 ? `class="rank-number medal-${index + 1}"` : 'class="rank-number"';
@@ -209,92 +211,49 @@ class LeaderboardManager {
leaderboardList.appendChild(rankItem); leaderboardList.appendChild(rankItem);
}); });
analyticsManager.track('leaderboard_viewed', { filter: difficulty }); if (typeof analyticsManager !== 'undefined') {
analyticsManager.track('leaderboard_viewed', { filter: difficulty });
}
} }
/** /**
* Mettre à jour les statistiques du joueur * Mettre à jour les statistiques du joueur (locales).
*/ */
static updatePlayerStats() { static updatePlayerStats() {
const userId = getUserId(); const stats = this.getPlayerStats();
const stats = this.getPlayerStats(userId);
// Remplir les éléments HTML const totalGamesEl = document.getElementById('total-games');
document.getElementById('total-games').textContent = stats.totalGames; const bestScoreEl = document.getElementById('best-score');
document.getElementById('best-score').textContent = formatNumber(stats.bestScore); const bestTimeEl = document.getElementById('best-time');
document.getElementById('best-time').textContent = stats.bestTime === Infinity ? '--' : formatTime(stats.bestTime); const winRateEl = document.getElementById('win-rate');
document.getElementById('win-rate').textContent = `${stats.winRate}%`;
// Ajouter des classes pour animation si valeurs élevées if (totalGamesEl) totalGamesEl.textContent = stats.totalGames;
if (stats.bestScore > 5000) { if (bestScoreEl) bestScoreEl.textContent = formatNumber(stats.bestScore);
document.getElementById('best-score').parentElement.classList.add('high-value'); if (bestTimeEl) bestTimeEl.textContent = stats.bestTime === Infinity || stats.bestTime === 0 ? '--' : formatTime(stats.bestTime);
if (winRateEl) winRateEl.textContent = `${stats.winRate}%`;
if (bestScoreEl && stats.bestScore > 5000) {
bestScoreEl.parentElement.classList.add('high-value');
} }
if (winRateEl && stats.winRate >= 50) {
if (stats.winRate >= 50) { winRateEl.parentElement.classList.add('high-value');
document.getElementById('win-rate').parentElement.classList.add('high-value');
} }
} }
/** /**
* Afficher le rang du joueur * Effacer l'historique local du joueur (DEBUG).
* N'affecte PAS le classement en ligne partagé.
*/ */
static updatePlayerRank(difficulty = 'all') { static clearLocalHistory() {
const userId = getUserId(); if (confirm('Effacer VOS statistiques locales ? (le classement en ligne n\'est pas touché)')) {
const rank = this.getPlayerRank(userId, difficulty); localStorage.removeItem(this.KEY_HISTORY);
if (rank) {
const rankDisplay = document.getElementById('player-rank');
if (rankDisplay) {
rankDisplay.textContent = `Vous êtes #{rank}`;
}
}
}
/**
* Afficher les statistiques globales
*/
static displayGlobalStats() {
const globalStats = this.getGlobalStats();
DEBUG.log('Global Statistics:', globalStats);
return globalStats;
}
/**
* Nettoyer les scores (DEBUG)
*/
static clearAll() {
if (confirm('Êtes-vous ABSOLUMENT certain de vouloir supprimer TOUS les scores?')) {
localStorage.removeItem(this.KEY_LEADERBOARD);
this.updateDisplay();
this.updatePlayerStats(); this.updatePlayerStats();
DEBUG.log('✓ Tous les scores ont été supprimés'); DEBUG.log('✓ Historique local effacé');
return true; return true;
} }
return false; return false;
} }
/**
* Exporter les scores en CSV
*/
static exportCSV() {
const scores = this.getAllScores();
let csv = 'Rang,Joueur,Score,Difficulté,Temps (s),Mouvements,Parfait,Date\n';
scores.forEach((score, index) => {
csv += `${index + 1},"${score.playerName}",${score.score},"${score.difficulty}",${score.timeSeconds},${score.moves},${score.isPerfect ? 'Oui' : 'Non'},"${score.date}"\n`;
});
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `leaderboard-${new Date().toISOString().slice(0, 10)}.csv`;
link.click();
URL.revokeObjectURL(url);
}
/** /**
* Obtenir l'affichage du rang (numéro ou médaille) * Obtenir l'affichage du rang (numéro ou médaille)
*/ */
@@ -313,78 +272,11 @@ class LeaderboardManager {
} }
} }
/**
* Statistiques avancées
*/
class AdvancedStats {
/**
* Obtenir la distribution des scores par difficulté
*/
static getDistributionByDifficulty() {
const scores = LeaderboardManager.getAllScores();
const distribution = {};
Object.keys(DIFFICULTY_MODES).forEach(difficulty => {
distribution[difficulty] = scores.filter(s => s.difficulty === difficulty).length;
});
return distribution;
}
/**
* Obtenir la tendance temporelle
*/
static getTimelineTrend(days = 7) {
const scores = LeaderboardManager.getAllScores();
const now = new Date();
const timeline = {};
for (let i = 0; i < days; i++) {
const date = new Date(now);
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().slice(0, 10);
timeline[dateStr] = scores.filter(s => s.date.slice(0, 10) === dateStr).length;
}
return timeline;
}
/**
* Obtenir la moyenne de temps par difficulté
*/
static getAverageTimeByDifficulty() {
const scores = LeaderboardManager.getAllScores();
const averages = {};
Object.keys(DIFFICULTY_MODES).forEach(difficulty => {
const diffScores = scores.filter(s => s.difficulty === difficulty);
if (diffScores.length > 0) {
const totalTime = diffScores.reduce((a, b) => a + b.timeSeconds, 0);
averages[difficulty] = Math.round(totalTime / diffScores.length);
}
});
return averages;
}
/**
* Taux de réussite global
*/
static getGlobalSuccessRate() {
const scores = LeaderboardManager.getAllScores();
if (scores.length === 0) return 0;
const perfectGames = scores.filter(s => s.isPerfect).length;
return Math.round((perfectGames / scores.length) * 100);
}
}
// ============================================ // ============================================
// INITIALISATION // INITIALISATION
// ============================================ // ============================================
// Exporter pour utilisation globale // Exporter pour utilisation globale
window.LeaderboardManager = LeaderboardManager; window.LeaderboardManager = LeaderboardManager;
window.AdvancedStats = AdvancedStats;
DEBUG.log('Leaderboard Manager initialized'); DEBUG.log('Leaderboard Manager initialized (online mode)');

View File

@@ -347,10 +347,14 @@ class MemoryGameEngine {
achievements: achievements.map(a => a.id) achievements: achievements.map(a => a.id)
}); });
// Sauvegarder score // Sauvegarder le score (classement en ligne) puis rafraîchir l'affichage
LeaderboardManager.addScore({ LeaderboardManager.addScore({
...gameData, ...gameData,
score: finalScore.totalScore score: finalScore.totalScore
}).then(() => {
const activeFilter = document.querySelector('.tab-btn.active')?.getAttribute('data-filter') || 'all';
LeaderboardManager.updateDisplay(activeFilter);
LeaderboardManager.updatePlayerStats();
}); });
// Afficher modal victoire // Afficher modal victoire

View File

@@ -292,10 +292,10 @@ function getRank(index) {
} }
/** /**
* Vérifier si le score est dans le top 10 * Vérifier si le score est dans le top 10 (classement en ligne, asynchrone)
*/ */
function isTopScore(score, difficulty = 'all') { async function isTopScore(score, difficulty = 'all') {
const scores = LeaderboardManager.getLeaderboard(difficulty, 10); const scores = await LeaderboardManager.getLeaderboard(difficulty, 10);
return scores.some(s => s.score === score.score && s.userId === score.userId); return scores.some(s => s.score === score.score && s.userId === score.userId);
} }
@@ -318,57 +318,25 @@ function formatDate(isoDate) {
} }
/** /**
* Obtenir les statistiques d'un joueur * Obtenir les statistiques locales du joueur
*/ */
function getPlayerStatistics(userId) { function getPlayerStatistics() {
const scores = LeaderboardManager.getAllScores().filter(s => s.userId === userId); return LeaderboardManager.getPlayerStats();
if (scores.length === 0) {
return {
totalGames: 0,
bestScore: 0,
averageScore: 0,
averageTime: 0,
bestTime: Infinity,
winRate: 0,
perfectGames: 0
};
}
const bestScore = Math.max(...scores.map(s => s.score), 0);
const totalScore = scores.reduce((a, b) => a + b.score, 0);
const totalTime = scores.reduce((a, b) => a + b.timeSeconds, 0);
const bestTime = Math.min(...scores.map(s => s.timeSeconds), Infinity);
const perfectGames = scores.filter(s => s.isPerfect).length;
const winRate = (perfectGames / scores.length) * 100;
return {
totalGames: scores.length,
bestScore: bestScore,
averageScore: Math.round(totalScore / scores.length),
averageTime: Math.round(totalTime / scores.length),
bestTime: bestTime === Infinity ? 0 : bestTime,
winRate: Math.round(winRate),
perfectGames: perfectGames
};
} }
/** /**
* Déboguer : afficher tous les scores en console * Déboguer : afficher l'historique local du joueur en console
*/ */
function debugScores() { function debugScores() {
console.table(LeaderboardManager.getAllScores()); console.table(LeaderboardManager._getLocalHistory());
} }
/** /**
* Déboguer : nettoyer tous les scores * Déboguer : effacer les statistiques locales du joueur
* (le classement en ligne partagé n'est pas affecté)
*/ */
function clearAllScores() { function clearAllScores() {
if (confirm('Êtes-vous sûr de vouloir supprimer tous les scores?')) { LeaderboardManager.clearLocalHistory();
localStorage.removeItem('nextgn_leaderboard');
console.log('✓ Tous les scores ont été supprimés');
location.reload();
}
} }
/** /**

View File

@@ -32,8 +32,8 @@
<div class="logo">Gauthier Chombart & Nathan Chauwin</div> <div class="logo">Gauthier Chombart & Nathan Chauwin</div>
<nav> <nav>
<a href="#formations">Exercices interactifs</a> <a href="#formations">Exercices interactifs</a>
<a href="#documentation">Documentation</a> <a href="#documentation" style="display:none;">Documentation</a>
<a href="#rapports">Rapports de veille</a> <a href="#rapports" style="display:none;">Rapports de veille</a>
<a href="#about">À propos</a> <a href="#about">À propos</a>
<a href="#contact">Contact</a> <a href="#contact">Contact</a>
</nav> </nav>
@@ -53,8 +53,8 @@
<div class="cta-group"> <div class="cta-group">
<a href="#formations" class="btn btn-primary">Suivre les exercices interactifs</a> <a href="#formations" class="btn btn-primary">Suivre les exercices interactifs</a>
<a href="#documentation" class="btn btn-secondary">Documentation</a> <a href="#documentation" class="btn btn-secondary" style="display:none;">Documentation</a>
<a href="#rapports" class="btn btn-secondary">Rapports de veille</a> <a href="#rapports" class="btn btn-secondary" style="display:none;">Rapports de veille</a>
</div> </div>
<div class="stats"> <div class="stats">
@@ -154,7 +154,7 @@
</section> </section>
<!-- DOCUMENTATION TECHNIQUE SECTION --> <!-- DOCUMENTATION TECHNIQUE SECTION -->
<section id="documentation" class="documentation-section"> <section id="documentation" class="documentation-section" style="display:none;">
<div class="section-header"> <div class="section-header">
<h2>Documentation technique et biologie des espèces</h2> <h2>Documentation technique et biologie des espèces</h2>
<p>Accédez à notre base de connaissances : études détaillées, analyses scientifiques et rapports techniques sur les nuisibles, les biocides et les enjeux réglementaires.</p> <p>Accédez à notre base de connaissances : études détaillées, analyses scientifiques et rapports techniques sur les nuisibles, les biocides et les enjeux réglementaires.</p>
@@ -191,7 +191,7 @@
</section> </section>
<!-- RAPPORTS DE VEILLE HEBDOMADAIRES --> <!-- RAPPORTS DE VEILLE HEBDOMADAIRES -->
<section id="rapports" class="rapports-section"> <section id="rapports" class="rapports-section" style="display:none;">
<div class="section-header"> <div class="section-header">
<h2>Rapports de veille hebdomadaires</h2> <h2>Rapports de veille hebdomadaires</h2>
<p>Suivez chaque semaine l'actualité réglementaire, les opportunités de marché et les tendances du secteur Certibiocide en France et en Europe.</p> <p>Suivez chaque semaine l'actualité réglementaire, les opportunités de marché et les tendances du secteur Certibiocide en France et en Europe.</p>

File diff suppressed because it is too large Load Diff