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é.
This commit is contained in:
55
exercices/memory-pictogrammes/api/README.md
Normal file
55
exercices/memory-pictogrammes/api/README.md
Normal 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).
|
||||
329
exercices/memory-pictogrammes/api/scores.ashx
Normal file
329
exercices/memory-pictogrammes/api/scores.ashx
Normal 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();
|
||||
}
|
||||
}
|
||||
16
exercices/memory-pictogrammes/api/web.config.example
Normal file
16
exercices/memory-pictogrammes/api/web.config.example
Normal 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>
|
||||
Reference in New Issue
Block a user