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é.
330 lines
12 KiB
Plaintext
330 lines
12 KiB
Plaintext
<%@ 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();
|
|
}
|
|
}
|