diff --git a/ChessCubing.App/Models/MatchModels.cs b/ChessCubing.App/Models/MatchModels.cs
index f3d393b..6e5c7f6 100644
--- a/ChessCubing.App/Models/MatchModels.cs
+++ b/ChessCubing.App/Models/MatchModels.cs
@@ -45,11 +45,20 @@ public sealed class MatchConfig
public sealed class MatchState
{
[JsonPropertyName("schemaVersion")]
- public int SchemaVersion { get; set; } = 3;
+ public int SchemaVersion { get; set; } = 4;
+
+ [JsonPropertyName("matchId")]
+ public string MatchId { get; set; } = Guid.NewGuid().ToString("N");
[JsonPropertyName("collaborationSessionId")]
public string? CollaborationSessionId { get; set; }
+ [JsonPropertyName("whiteSubject")]
+ public string? WhiteSubject { get; set; }
+
+ [JsonPropertyName("blackSubject")]
+ public string? BlackSubject { get; set; }
+
[JsonPropertyName("config")]
public MatchConfig Config { get; set; } = new();
@@ -95,6 +104,9 @@ public sealed class MatchState
[JsonPropertyName("result")]
public string? Result { get; set; }
+ [JsonPropertyName("resultRecordedUtc")]
+ public DateTime? ResultRecordedUtc { get; set; }
+
[JsonPropertyName("cube")]
public CubeState Cube { get; set; } = new();
diff --git a/ChessCubing.App/Models/Stats/UserStatsModels.cs b/ChessCubing.App/Models/Stats/UserStatsModels.cs
new file mode 100644
index 0000000..dc30d79
--- /dev/null
+++ b/ChessCubing.App/Models/Stats/UserStatsModels.cs
@@ -0,0 +1,138 @@
+namespace ChessCubing.App.Models.Stats;
+
+public sealed class UserStatsResponse
+{
+ public string Subject { get; init; } = string.Empty;
+
+ public int CurrentElo { get; init; }
+
+ public int RankedGames { get; init; }
+
+ public int CasualGames { get; init; }
+
+ public int Wins { get; init; }
+
+ public int Losses { get; init; }
+
+ public int StoppedGames { get; init; }
+
+ public int WhiteWins { get; init; }
+
+ public int BlackWins { get; init; }
+
+ public int WhiteLosses { get; init; }
+
+ public int BlackLosses { get; init; }
+
+ public int TotalMoves { get; init; }
+
+ public int TotalCubeRounds { get; init; }
+
+ public long? BestCubeTimeMs { get; init; }
+
+ public long? AverageCubeTimeMs { get; init; }
+
+ public DateTime? LastMatchUtc { get; init; }
+
+ public UserRecentMatchResponse[] RecentMatches { get; init; } = [];
+}
+
+public sealed class UserRecentMatchResponse
+{
+ public string MatchId { get; init; } = string.Empty;
+
+ public DateTime CompletedUtc { get; init; }
+
+ public string Result { get; init; } = string.Empty;
+
+ public string Mode { get; init; } = string.Empty;
+
+ public string Preset { get; init; } = string.Empty;
+
+ public string? MatchLabel { get; init; }
+
+ public string PlayerColor { get; init; } = string.Empty;
+
+ public string PlayerName { get; init; } = string.Empty;
+
+ public string OpponentName { get; init; } = string.Empty;
+
+ public string? OpponentSubject { get; init; }
+
+ public bool IsRanked { get; init; }
+
+ public bool IsWin { get; init; }
+
+ public bool IsLoss { get; init; }
+
+ public int PlayerMoves { get; init; }
+
+ public int OpponentMoves { get; init; }
+
+ public int CubeRounds { get; init; }
+
+ public long? PlayerBestCubeTimeMs { get; init; }
+
+ public long? PlayerAverageCubeTimeMs { get; init; }
+
+ public int? EloBefore { get; init; }
+
+ public int? EloAfter { get; init; }
+
+ public int? EloDelta { get; init; }
+}
+
+public sealed class ReportCompletedMatchRequest
+{
+ public string MatchId { get; init; } = string.Empty;
+
+ public string? CollaborationSessionId { get; init; }
+
+ public string? WhiteSubject { get; init; }
+
+ public string WhiteName { get; init; } = string.Empty;
+
+ public string? BlackSubject { get; init; }
+
+ public string BlackName { get; init; } = string.Empty;
+
+ public string Result { get; init; } = string.Empty;
+
+ public string Mode { get; init; } = string.Empty;
+
+ public string Preset { get; init; } = string.Empty;
+
+ public string? MatchLabel { get; init; }
+
+ public int BlockNumber { get; init; }
+
+ public int WhiteMoves { get; init; }
+
+ public int BlackMoves { get; init; }
+
+ public ReportCompletedCubeRound[] CubeRounds { get; init; } = [];
+}
+
+public sealed class ReportCompletedCubeRound
+{
+ public int BlockNumber { get; init; }
+
+ public int? Number { get; init; }
+
+ public long? White { get; init; }
+
+ public long? Black { get; init; }
+}
+
+public sealed class ReportCompletedMatchResponse
+{
+ public bool Recorded { get; init; }
+
+ public bool IsDuplicate { get; init; }
+
+ public bool IsRanked { get; init; }
+
+ public int? WhiteEloAfter { get; init; }
+
+ public int? BlackEloAfter { get; init; }
+}
diff --git a/ChessCubing.App/Pages/ApplicationPage.razor b/ChessCubing.App/Pages/ApplicationPage.razor
index c366924..5594da5 100644
--- a/ChessCubing.App/Pages/ApplicationPage.razor
+++ b/ChessCubing.App/Pages/ApplicationPage.razor
@@ -160,7 +160,7 @@
}
-
+
@if (!string.IsNullOrWhiteSpace(SetupError))
@@ -297,6 +297,7 @@
@TimingText
@TimeImpact
@QuotaText
+ @StatsEligibilityText
@@ -386,6 +387,9 @@
private long _knownCollaborativeRevision;
private string? _appliedActiveSessionId;
private string? ConnectedPlayerName;
+ private string? ConnectedPlayerSubject;
+ private string? AssignedWhiteSubject;
+ private string? AssignedBlackSubject;
private string? SetupError;
private string? SocialLoadError;
private string? InviteActionError;
@@ -395,12 +399,21 @@
private string SetupBodyClass => UsesMoveLimit ? string.Empty : "time-setup-mode";
private bool CanUseConnectedPlayerName => !string.IsNullOrWhiteSpace(ConnectedPlayerName);
+ private bool HasLockedPlaySession => Realtime.ActivePlaySession is not null;
private string WhitePlayerName
{
get => Form.WhiteName;
set
{
Form.WhiteName = value;
+ if (!HasLockedPlaySession &&
+ !string.IsNullOrWhiteSpace(AssignedWhiteSubject) &&
+ AssignedWhiteSubject == ConnectedPlayerSubject &&
+ !SamePlayerName(value, ConnectedPlayerName))
+ {
+ AssignedWhiteSubject = null;
+ }
+
SetupError = null;
}
}
@@ -411,6 +424,14 @@
set
{
Form.BlackName = value;
+ if (!HasLockedPlaySession &&
+ !string.IsNullOrWhiteSpace(AssignedBlackSubject) &&
+ AssignedBlackSubject == ConnectedPlayerSubject &&
+ !SamePlayerName(value, ConnectedPlayerName))
+ {
+ AssignedBlackSubject = null;
+ }
+
SetupError = null;
}
}
@@ -449,6 +470,13 @@
? $"Quota actif : {CurrentPreset.Quota} coups par joueur."
: $"Quota actif : {CurrentPreset.Quota} coups par joueur et par Block.";
+ private string StatsEligibilityText =>
+ Realtime.ActivePlaySession is not null
+ ? "Match classe : les deux comptes sont identifies, la partie mettra a jour l'Elo et les statistiques."
+ : !string.IsNullOrWhiteSpace(AssignedWhiteSubject) || !string.IsNullOrWhiteSpace(AssignedBlackSubject)
+ ? "Match amical : la partie sera enregistree pour le ou les comptes lies, sans impact Elo."
+ : "Match local uniquement : aucun compte joueur n'est lie des deux cotes, rien ne sera ajoute aux statistiques serveur.";
+
protected override async Task OnInitializedAsync()
{
AuthenticationStateProvider.AuthenticationStateChanged += HandleAuthenticationStateChanged;
@@ -508,6 +536,9 @@
{
IsAuthenticated = false;
ConnectedPlayerName = null;
+ ConnectedPlayerSubject = null;
+ AssignedWhiteSubject = null;
+ AssignedBlackSubject = null;
ResetSocialState();
await InvokeAsync(StateHasChanged);
return;
@@ -515,6 +546,7 @@
IsAuthenticated = true;
fallbackName = BuildConnectedPlayerFallback(user);
+ ConnectedPlayerSubject = ResolveSubject(user);
var response = await Http.GetAsync("api/users/me");
if (!response.IsSuccessStatusCode)
@@ -523,6 +555,9 @@
{
IsAuthenticated = false;
ConnectedPlayerName = null;
+ ConnectedPlayerSubject = null;
+ AssignedWhiteSubject = null;
+ AssignedBlackSubject = null;
ResetSocialState();
await InvokeAsync(StateHasChanged);
return;
@@ -607,11 +642,20 @@
await Store.EnsureLoadedAsync();
var match = MatchEngine.CreateMatch(Form.ToMatchConfig());
- if (!string.IsNullOrWhiteSpace(Realtime.ActivePlaySession?.SessionId))
+ if (Realtime.ActivePlaySession is { } session)
{
- match.CollaborationSessionId = Realtime.ActivePlaySession.SessionId;
+ match.CollaborationSessionId = session.SessionId;
+ match.WhiteSubject = NormalizeOptional(session.WhiteSubject);
+ match.BlackSubject = NormalizeOptional(session.BlackSubject);
+ match.Config.WhiteName = session.WhiteName;
+ match.Config.BlackName = session.BlackName;
await Realtime.EnsureJoinedPlaySessionAsync(match.CollaborationSessionId);
}
+ else
+ {
+ match.WhiteSubject = NormalizeOptional(AssignedWhiteSubject);
+ match.BlackSubject = NormalizeOptional(AssignedBlackSubject);
+ }
Store.SetCurrent(match);
@@ -632,6 +676,8 @@
private void LoadDemo()
{
Form = SetupFormModel.CreateDemo();
+ AssignedWhiteSubject = null;
+ AssignedBlackSubject = null;
SetupError = null;
}
@@ -758,6 +804,8 @@
Form.WhiteName = session.WhiteName;
Form.BlackName = session.BlackName;
+ AssignedWhiteSubject = NormalizeOptional(session.WhiteSubject);
+ AssignedBlackSubject = NormalizeOptional(session.BlackSubject);
SetupError = null;
_appliedActiveSessionId = session.SessionId;
}
@@ -771,10 +819,12 @@
var connectedName = ConnectedPlayerName!;
Form.WhiteName = connectedName;
+ AssignedWhiteSubject = NormalizeOptional(ConnectedPlayerSubject);
- if (SamePlayerName(Form.BlackName, connectedName))
+ if (SamePlayerName(Form.BlackName, connectedName) || string.Equals(AssignedBlackSubject, ConnectedPlayerSubject, StringComparison.Ordinal))
{
Form.BlackName = "Noir";
+ AssignedBlackSubject = null;
}
SetupError = null;
@@ -789,10 +839,12 @@
var connectedName = ConnectedPlayerName!;
Form.BlackName = connectedName;
+ AssignedBlackSubject = NormalizeOptional(ConnectedPlayerSubject);
- if (SamePlayerName(Form.WhiteName, connectedName))
+ if (SamePlayerName(Form.WhiteName, connectedName) || string.Equals(AssignedWhiteSubject, ConnectedPlayerSubject, StringComparison.Ordinal))
{
Form.WhiteName = "Blanc";
+ AssignedWhiteSubject = null;
}
SetupError = null;
@@ -824,6 +876,8 @@
{
Form.WhiteName = snapshot.Match.Config.WhiteName;
Form.BlackName = snapshot.Match.Config.BlackName;
+ AssignedWhiteSubject = NormalizeOptional(snapshot.Match.WhiteSubject);
+ AssignedBlackSubject = NormalizeOptional(snapshot.Match.BlackSubject);
SetupError = null;
}
@@ -885,9 +939,16 @@
user.FindFirst("preferred_username")?.Value,
user.FindFirst(ClaimTypes.Email)?.Value);
+ private static string? ResolveSubject(ClaimsPrincipal user)
+ => user.FindFirst("sub")?.Value
+ ?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
+
private static string? FirstNonEmpty(params string?[] candidates)
=> candidates.FirstOrDefault(candidate => !string.IsNullOrWhiteSpace(candidate));
+ private static string? NormalizeOptional(string? value)
+ => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
+
public void Dispose()
{
AuthenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged;
diff --git a/ChessCubing.App/Pages/ChronoPage.razor b/ChessCubing.App/Pages/ChronoPage.razor
index ee354b4..4742fef 100644
--- a/ChessCubing.App/Pages/ChronoPage.razor
+++ b/ChessCubing.App/Pages/ChronoPage.razor
@@ -4,6 +4,7 @@
@inject MatchStore Store
@inject NavigationManager Navigation
@inject SocialRealtimeService Realtime
+@inject MatchStatsService MatchStats
ChessCubing Arena | Phase Chrono
@@ -198,6 +199,8 @@ else if (Match is not null && summary is not null && blackZone is not null && wh
return;
}
+ await EnsureResultReportedAsync();
+
_tickerCancellation = new CancellationTokenSource();
_ = RunTickerAsync(_tickerCancellation.Token);
StateHasChanged();
@@ -221,6 +224,7 @@ else if (Match is not null && summary is not null && blackZone is not null && wh
private async Task HandleRealtimeChangedAsync()
{
await ApplyCollaborativeSyncAsync();
+ await EnsureResultReportedAsync();
StateHasChanged();
}
@@ -419,6 +423,7 @@ else if (Match is not null && summary is not null && blackZone is not null && wh
private async Task PersistAndRouteAsync()
{
+ await EnsureResultReportedAsync();
Store.MarkDirty();
await Store.SaveAsync();
@@ -437,6 +442,20 @@ else if (Match is not null && summary is not null && blackZone is not null && wh
StateHasChanged();
}
+ private async Task EnsureResultReportedAsync()
+ {
+ var match = Match;
+ if (match is null || string.IsNullOrWhiteSpace(match.Result) || match.ResultRecordedUtc is not null)
+ {
+ return;
+ }
+
+ if (await MatchStats.TryReportCompletedMatchAsync(match))
+ {
+ Store.MarkDirty();
+ }
+ }
+
private ChronoSummaryView BuildSummary()
{
var match = Match!;
diff --git a/ChessCubing.App/Pages/UserPage.razor b/ChessCubing.App/Pages/UserPage.razor
index 4ce72ca..e1a157e 100644
--- a/ChessCubing.App/Pages/UserPage.razor
+++ b/ChessCubing.App/Pages/UserPage.razor
@@ -3,6 +3,7 @@
@using System.ComponentModel.DataAnnotations
@using System.Net
@using System.Net.Http.Json
+@using ChessCubing.App.Models.Stats
@using ChessCubing.App.Models.Users
@implements IDisposable
@inject AuthenticationStateProvider AuthenticationStateProvider
@@ -150,6 +151,116 @@
+
Bio
@(Profile.Bio ?? "Aucune bio enregistree pour le moment.")
@@ -455,6 +566,7 @@
private readonly UserProfileFormModel Form = new();
private UserProfileResponse? Profile;
+ private UserStatsResponse? Stats;
private SocialOverviewResponse? SocialOverview;
private SocialSearchUserResponse[] SearchResults = [];
private bool IsAuthenticated;
@@ -464,6 +576,7 @@
private bool IsSearching;
private int _knownSocialVersion;
private string? LoadError;
+ private string? StatsLoadError;
private string? SaveError;
private string? SaveMessage;
private string? SocialLoadError;
@@ -486,6 +599,21 @@
? "Le serveur verifie la fiche utilisateur et les relations sociales associees a ce compte."
: $"Compte lie a {Profile?.Username ?? "l'utilisateur connecte"} avec synchronisation sociale en direct.";
+ private string StatsWinRateLabel
+ {
+ get
+ {
+ var totalDecisiveGames = (Stats?.Wins ?? 0) + (Stats?.Losses ?? 0);
+ if (totalDecisiveGames <= 0)
+ {
+ return "--";
+ }
+
+ var winRate = ((double)(Stats?.Wins ?? 0) / totalDecisiveGames) * 100d;
+ return $"{Math.Round(winRate, MidpointRounding.AwayFromZero):0}%";
+ }
+ }
+
private string FriendCountLabel => $"{SocialOverview?.Friends.Length ?? 0} ami(s)";
private string ReceivedCountLabel => $"{SocialOverview?.ReceivedInvitations.Length ?? 0} recue(s)";
@@ -561,6 +689,7 @@
{
LoadError = await ReadErrorAsync(response, "Le profil utilisateur n'a pas pu etre charge.");
Profile = null;
+ ResetStatsState();
ResetSocialState();
return;
}
@@ -569,23 +698,27 @@
if (Profile is null)
{
LoadError = "Le serveur a retourne une reponse vide.";
+ ResetStatsState();
ResetSocialState();
return;
}
FillForm(Profile);
+ await LoadStatsAsync();
await LoadSocialOverviewAsync();
}
catch (HttpRequestException)
{
LoadError = "Le service utilisateur est temporairement indisponible.";
Profile = null;
+ ResetStatsState();
ResetSocialState();
}
catch (TaskCanceledException)
{
LoadError = "La reponse du service utilisateur a pris trop de temps.";
Profile = null;
+ ResetStatsState();
ResetSocialState();
}
finally
@@ -595,6 +728,35 @@
}
}
+ private async Task LoadStatsAsync()
+ {
+ StatsLoadError = null;
+
+ try
+ {
+ var response = await Http.GetAsync("api/users/me/stats");
+ if (!response.IsSuccessStatusCode)
+ {
+ StatsLoadError = await ReadErrorAsync(response, "Les statistiques joueur n'ont pas pu etre chargees.");
+ Stats = null;
+ return;
+ }
+
+ Stats = await response.Content.ReadFromJsonAsync
();
+ Stats ??= new UserStatsResponse();
+ }
+ catch (HttpRequestException)
+ {
+ StatsLoadError = "Le service de statistiques est temporairement indisponible.";
+ Stats = null;
+ }
+ catch (TaskCanceledException)
+ {
+ StatsLoadError = "Le chargement des statistiques a pris trop de temps.";
+ Stats = null;
+ }
+ }
+
private async Task LoadSocialOverviewAsync()
{
SocialLoadError = null;
@@ -849,9 +1011,16 @@
{
IsAuthenticated = false;
Profile = null;
+ ResetStatsState();
Form.Reset();
}
+ private void ResetStatsState()
+ {
+ Stats = null;
+ StatsLoadError = null;
+ }
+
private void ResetSocialState()
{
SocialOverview = null;
@@ -920,6 +1089,83 @@
private static string FormatDate(DateTime value)
=> value.ToLocalTime().ToString("dd MMM yyyy 'a' HH:mm", CultureInfo.GetCultureInfo("fr-FR"));
+ private static string FormatOptionalDate(DateTime? value)
+ => value is null ? "--" : FormatDate(value.Value);
+
+ private static string FormatDurationShort(long? value)
+ {
+ if (value is null or <= 0)
+ {
+ return "--";
+ }
+
+ return MatchEngine.FormatStopwatch(value.Value);
+ }
+
+ private static string BuildRecentMatchTitle(UserRecentMatchResponse match)
+ => string.IsNullOrWhiteSpace(match.MatchLabel)
+ ? $"{match.PlayerName} vs {match.OpponentName}"
+ : match.MatchLabel;
+
+ private static string BuildRecentMatchSubtitle(UserRecentMatchResponse match)
+ => $"{BuildModeLabel(match.Mode)} • {match.OpponentName} • cote {BuildColorLabel(match.PlayerColor)}";
+
+ private static string BuildRecentMatchCaption(UserRecentMatchResponse match)
+ {
+ var parts = new List
+ {
+ $"Le {FormatDate(match.CompletedUtc)}",
+ $"{match.CubeRounds} round(s) cube",
+ };
+
+ if (match.PlayerAverageCubeTimeMs is > 0)
+ {
+ parts.Add($"moy. cube {FormatDurationShort(match.PlayerAverageCubeTimeMs)}");
+ }
+
+ return string.Join(" • ", parts);
+ }
+
+ private static string BuildRecentMatchBadge(UserRecentMatchResponse match)
+ {
+ if (match.IsWin)
+ {
+ return "Victoire";
+ }
+
+ if (match.IsLoss)
+ {
+ return "Defaite";
+ }
+
+ return "Arretee";
+ }
+
+ private static string BuildRecentMatchBadgeClass(UserRecentMatchResponse match)
+ => match.IsWin
+ ? "presence-badge online"
+ : match.IsLoss
+ ? "presence-badge recent-loss-badge"
+ : "presence-badge";
+
+ private static string BuildColorLabel(string color)
+ => string.Equals(color, MatchEngine.ColorWhite, StringComparison.OrdinalIgnoreCase)
+ ? "blanc"
+ : "noir";
+
+ private static string BuildModeLabel(string mode)
+ => MatchEngine.Modes.TryGetValue(mode, out var info)
+ ? info.Label
+ : mode;
+
+ private static string BuildSignedNumber(int value)
+ => value > 0 ? $"+{value}" : value.ToString(CultureInfo.InvariantCulture);
+
+ private static string BuildEloDeltaClass(int value)
+ => value >= 0
+ ? "mini-chip user-elo-chip-positive"
+ : "mini-chip user-elo-chip-negative";
+
public void Dispose()
{
AuthenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged;
diff --git a/ChessCubing.App/Program.cs b/ChessCubing.App/Program.cs
index c506161..5eb710e 100644
--- a/ChessCubing.App/Program.cs
+++ b/ChessCubing.App/Program.cs
@@ -16,5 +16,6 @@ builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped();
await builder.Build().RunAsync();
diff --git a/ChessCubing.App/Services/MatchEngine.cs b/ChessCubing.App/Services/MatchEngine.cs
index a81fb2b..ec50c79 100644
--- a/ChessCubing.App/Services/MatchEngine.cs
+++ b/ChessCubing.App/Services/MatchEngine.cs
@@ -54,11 +54,14 @@ public static class MatchEngine
var quota = Presets[config.Preset].Quota;
var match = new MatchState
{
- SchemaVersion = 3,
+ SchemaVersion = 4,
+ MatchId = Guid.NewGuid().ToString("N"),
Config = config,
Phase = PhaseBlock,
Running = false,
LastTickAt = null,
+ WhiteSubject = null,
+ BlackSubject = null,
BlockNumber = 1,
CurrentTurn = ColorWhite,
BlockRemainingMs = config.BlockDurationMs,
@@ -76,6 +79,7 @@ public static class MatchEngine
AwaitingBlockClosure = false,
ClosureReason = string.Empty,
Result = null,
+ ResultRecordedUtc = null,
Cube = CreateCubeState(),
DoubleCoup = new DoubleCoupState
{
@@ -115,13 +119,19 @@ public static class MatchEngine
};
storedMatch.Moves ??= new PlayerIntPair();
+ if (string.IsNullOrWhiteSpace(storedMatch.MatchId))
+ {
+ storedMatch.MatchId = Guid.NewGuid().ToString("N");
+ changed = true;
+ }
+
var blockDurationMs = GetBlockDurationMs(storedMatch);
var moveLimitMs = GetMoveLimitMs(storedMatch);
var timeInitialMs = GetTimeInitialMs(storedMatch);
- if (storedMatch.SchemaVersion != 3)
+ if (storedMatch.SchemaVersion != 4)
{
- storedMatch.SchemaVersion = 3;
+ storedMatch.SchemaVersion = 4;
changed = true;
}
@@ -826,7 +836,7 @@ public static class MatchEngine
}
public static bool IsSupportedSchemaVersion(int version)
- => version is 2 or 3;
+ => version is 2 or 3 or 4;
public static long GetBlockDurationMs(object? matchOrConfig)
{
diff --git a/ChessCubing.App/Services/MatchStatsService.cs b/ChessCubing.App/Services/MatchStatsService.cs
new file mode 100644
index 0000000..3fd1748
--- /dev/null
+++ b/ChessCubing.App/Services/MatchStatsService.cs
@@ -0,0 +1,77 @@
+using System.Net;
+using System.Net.Http.Json;
+using ChessCubing.App.Models;
+using ChessCubing.App.Models.Stats;
+
+namespace ChessCubing.App.Services;
+
+public sealed class MatchStatsService(HttpClient httpClient)
+{
+ private readonly HttpClient _httpClient = httpClient;
+
+ public async Task TryReportCompletedMatchAsync(MatchState? match, CancellationToken cancellationToken = default)
+ {
+ if (match is null || string.IsNullOrWhiteSpace(match.Result) || match.ResultRecordedUtc is not null)
+ {
+ return false;
+ }
+
+ if (string.IsNullOrWhiteSpace(match.WhiteSubject) && string.IsNullOrWhiteSpace(match.BlackSubject))
+ {
+ return false;
+ }
+
+ var request = BuildReport(match);
+
+ try
+ {
+ var response = await _httpClient.PostAsJsonAsync("api/users/me/stats/matches", request, cancellationToken);
+ if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
+ {
+ return false;
+ }
+
+ if (!response.IsSuccessStatusCode)
+ {
+ return false;
+ }
+
+ match.ResultRecordedUtc = DateTime.UtcNow;
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static ReportCompletedMatchRequest BuildReport(MatchState match)
+ => new()
+ {
+ MatchId = string.IsNullOrWhiteSpace(match.MatchId) ? Guid.NewGuid().ToString("N") : match.MatchId,
+ CollaborationSessionId = match.CollaborationSessionId,
+ WhiteSubject = NormalizeOptional(match.WhiteSubject),
+ WhiteName = MatchEngine.PlayerName(match, MatchEngine.ColorWhite),
+ BlackSubject = NormalizeOptional(match.BlackSubject),
+ BlackName = MatchEngine.PlayerName(match, MatchEngine.ColorBlack),
+ Result = match.Result ?? string.Empty,
+ Mode = match.Config.Mode,
+ Preset = match.Config.Preset,
+ MatchLabel = MatchEngine.SanitizeText(match.Config.MatchLabel),
+ BlockNumber = Math.Max(1, match.BlockNumber),
+ WhiteMoves = Math.Max(0, match.Moves.White),
+ BlackMoves = Math.Max(0, match.Moves.Black),
+ CubeRounds = match.Cube.History
+ .Select(entry => new ReportCompletedCubeRound
+ {
+ BlockNumber = Math.Max(1, entry.BlockNumber),
+ Number = entry.Number,
+ White = entry.White,
+ Black = entry.Black,
+ })
+ .ToArray(),
+ };
+
+ private static string? NormalizeOptional(string? value)
+ => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
+}
diff --git a/ChessCubing.Server/Program.cs b/ChessCubing.Server/Program.cs
index d5dd9ff..97ecdd1 100644
--- a/ChessCubing.Server/Program.cs
+++ b/ChessCubing.Server/Program.cs
@@ -4,6 +4,7 @@ using ChessCubing.Server.Admin;
using ChessCubing.Server.Auth;
using ChessCubing.Server.Data;
using ChessCubing.Server.Social;
+using ChessCubing.Server.Stats;
using ChessCubing.Server.Users;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
@@ -73,6 +74,7 @@ builder.Services.AddSignalR();
builder.Services.AddHttpClient();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
@@ -83,8 +85,10 @@ await using (var scope = app.Services.CreateAsyncScope())
{
var profileStore = scope.ServiceProvider.GetRequiredService();
var socialStore = scope.ServiceProvider.GetRequiredService();
+ var statsStore = scope.ServiceProvider.GetRequiredService();
await profileStore.InitializeAsync(CancellationToken.None);
await socialStore.InitializeAsync(CancellationToken.None);
+ await statsStore.InitializeAsync(CancellationToken.None);
}
app.UseAuthentication();
@@ -133,6 +137,44 @@ app.MapPut("/api/users/me", async Task (
}
}).RequireAuthorization();
+app.MapGet("/api/users/me/stats", async Task (
+ ClaimsPrincipal user,
+ MySqlPlayerStatsStore statsStore,
+ CancellationToken cancellationToken) =>
+{
+ var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
+ if (siteUser is null)
+ {
+ return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
+ }
+
+ var stats = await statsStore.GetUserStatsAsync(siteUser.Subject, cancellationToken);
+ return TypedResults.Ok(stats);
+}).RequireAuthorization();
+
+app.MapPost("/api/users/me/stats/matches", async Task (
+ ReportCompletedMatchRequest request,
+ ClaimsPrincipal user,
+ MySqlPlayerStatsStore statsStore,
+ CancellationToken cancellationToken) =>
+{
+ var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
+ if (siteUser is null)
+ {
+ return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
+ }
+
+ try
+ {
+ var result = await statsStore.RecordCompletedMatchAsync(siteUser, request, cancellationToken);
+ return TypedResults.Ok(result);
+ }
+ catch (PlayerStatsValidationException exception)
+ {
+ return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
+ }
+}).RequireAuthorization();
+
var socialGroup = app.MapGroup("/api/social")
.RequireAuthorization();
diff --git a/ChessCubing.Server/Stats/MySqlPlayerStatsStore.cs b/ChessCubing.Server/Stats/MySqlPlayerStatsStore.cs
new file mode 100644
index 0000000..1de4dc5
--- /dev/null
+++ b/ChessCubing.Server/Stats/MySqlPlayerStatsStore.cs
@@ -0,0 +1,992 @@
+using ChessCubing.Server.Data;
+using ChessCubing.Server.Users;
+using Microsoft.Extensions.Options;
+using MySqlConnector;
+
+namespace ChessCubing.Server.Stats;
+
+public sealed class MySqlPlayerStatsStore(
+ IOptions options,
+ ILogger logger)
+{
+ private const string MatchResultWhite = "white";
+ private const string MatchResultBlack = "black";
+ private const string MatchResultStopped = "stopped";
+ private const int DefaultElo = 1200;
+ private const int EloKFactor = 32;
+ private const int RecentMatchLimit = 12;
+
+ private const string CreatePlayerStatsTableSql = """
+ CREATE TABLE IF NOT EXISTS site_player_stats (
+ subject VARCHAR(190) NOT NULL,
+ current_elo INT NOT NULL,
+ ranked_games INT NOT NULL,
+ casual_games INT NOT NULL,
+ wins INT NOT NULL,
+ losses INT NOT NULL,
+ stopped_games INT NOT NULL,
+ white_wins INT NOT NULL,
+ black_wins INT NOT NULL,
+ white_losses INT NOT NULL,
+ black_losses INT NOT NULL,
+ total_moves INT NOT NULL,
+ total_cube_rounds INT NOT NULL,
+ total_cube_entries INT NOT NULL,
+ total_cube_time_ms BIGINT NOT NULL,
+ best_cube_time_ms BIGINT NULL,
+ last_match_utc DATETIME(6) NULL,
+ updated_utc DATETIME(6) NOT NULL,
+ PRIMARY KEY (subject)
+ ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+ """;
+
+ private const string CreateMatchResultsTableSql = """
+ CREATE TABLE IF NOT EXISTS site_match_results (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ match_id VARCHAR(80) NOT NULL,
+ collaboration_session_id VARCHAR(80) NULL,
+ recorded_by_subject VARCHAR(190) NOT NULL,
+ white_subject VARCHAR(190) NULL,
+ white_name VARCHAR(120) NOT NULL,
+ black_subject VARCHAR(190) NULL,
+ black_name VARCHAR(120) NOT NULL,
+ winner_subject VARCHAR(190) NULL,
+ result VARCHAR(20) NOT NULL,
+ mode VARCHAR(40) NOT NULL,
+ preset VARCHAR(40) NOT NULL,
+ match_label VARCHAR(120) NULL,
+ block_number INT NOT NULL,
+ white_moves INT NOT NULL,
+ black_moves INT NOT NULL,
+ cube_rounds INT NOT NULL,
+ white_best_cube_ms BIGINT NULL,
+ black_best_cube_ms BIGINT NULL,
+ white_average_cube_ms BIGINT NULL,
+ black_average_cube_ms BIGINT NULL,
+ is_ranked TINYINT(1) NOT NULL,
+ white_elo_before INT NULL,
+ white_elo_after INT NULL,
+ black_elo_before INT NULL,
+ black_elo_after INT NULL,
+ completed_utc DATETIME(6) NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY uq_site_match_results_match_id (match_id),
+ KEY idx_site_match_results_white_subject (white_subject, completed_utc),
+ KEY idx_site_match_results_black_subject (black_subject, completed_utc)
+ ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+ """;
+
+ private const string EnsurePlayerStatsRowSql = """
+ INSERT INTO site_player_stats (
+ subject,
+ current_elo,
+ ranked_games,
+ casual_games,
+ wins,
+ losses,
+ stopped_games,
+ white_wins,
+ black_wins,
+ white_losses,
+ black_losses,
+ total_moves,
+ total_cube_rounds,
+ total_cube_entries,
+ total_cube_time_ms,
+ best_cube_time_ms,
+ last_match_utc,
+ updated_utc
+ )
+ VALUES (
+ @subject,
+ @currentElo,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ NULL,
+ NULL,
+ @updatedUtc
+ )
+ ON DUPLICATE KEY UPDATE
+ subject = VALUES(subject);
+ """;
+
+ private const string SelectPlayerStatsForUpdateSql = """
+ SELECT
+ subject,
+ current_elo,
+ ranked_games,
+ casual_games,
+ wins,
+ losses,
+ stopped_games,
+ white_wins,
+ black_wins,
+ white_losses,
+ black_losses,
+ total_moves,
+ total_cube_rounds,
+ total_cube_entries,
+ total_cube_time_ms,
+ best_cube_time_ms,
+ last_match_utc,
+ updated_utc
+ FROM site_player_stats
+ WHERE subject = @subject
+ LIMIT 1
+ FOR UPDATE;
+ """;
+
+ private const string SelectPlayerStatsSql = """
+ SELECT
+ subject,
+ current_elo,
+ ranked_games,
+ casual_games,
+ wins,
+ losses,
+ stopped_games,
+ white_wins,
+ black_wins,
+ white_losses,
+ black_losses,
+ total_moves,
+ total_cube_rounds,
+ total_cube_entries,
+ total_cube_time_ms,
+ best_cube_time_ms,
+ last_match_utc,
+ updated_utc
+ FROM site_player_stats
+ WHERE subject = @subject
+ LIMIT 1;
+ """;
+
+ private const string UpdatePlayerStatsSql = """
+ UPDATE site_player_stats
+ SET
+ current_elo = @currentElo,
+ ranked_games = @rankedGames,
+ casual_games = @casualGames,
+ wins = @wins,
+ losses = @losses,
+ stopped_games = @stoppedGames,
+ white_wins = @whiteWins,
+ black_wins = @blackWins,
+ white_losses = @whiteLosses,
+ black_losses = @blackLosses,
+ total_moves = @totalMoves,
+ total_cube_rounds = @totalCubeRounds,
+ total_cube_entries = @totalCubeEntries,
+ total_cube_time_ms = @totalCubeTimeMs,
+ best_cube_time_ms = @bestCubeTimeMs,
+ last_match_utc = @lastMatchUtc,
+ updated_utc = @updatedUtc
+ WHERE subject = @subject;
+ """;
+
+ private const string InsertMatchResultSql = """
+ INSERT INTO site_match_results (
+ match_id,
+ collaboration_session_id,
+ recorded_by_subject,
+ white_subject,
+ white_name,
+ black_subject,
+ black_name,
+ winner_subject,
+ result,
+ mode,
+ preset,
+ match_label,
+ block_number,
+ white_moves,
+ black_moves,
+ cube_rounds,
+ white_best_cube_ms,
+ black_best_cube_ms,
+ white_average_cube_ms,
+ black_average_cube_ms,
+ is_ranked,
+ white_elo_before,
+ white_elo_after,
+ black_elo_before,
+ black_elo_after,
+ completed_utc
+ )
+ VALUES (
+ @matchId,
+ @collaborationSessionId,
+ @recordedBySubject,
+ @whiteSubject,
+ @whiteName,
+ @blackSubject,
+ @blackName,
+ @winnerSubject,
+ @result,
+ @mode,
+ @preset,
+ @matchLabel,
+ @blockNumber,
+ @whiteMoves,
+ @blackMoves,
+ @cubeRounds,
+ @whiteBestCubeMs,
+ @blackBestCubeMs,
+ @whiteAverageCubeMs,
+ @blackAverageCubeMs,
+ @isRanked,
+ NULL,
+ NULL,
+ NULL,
+ NULL,
+ @completedUtc
+ );
+ """;
+
+ private const string UpdateMatchResultEloSql = """
+ UPDATE site_match_results
+ SET
+ white_elo_before = @whiteEloBefore,
+ white_elo_after = @whiteEloAfter,
+ black_elo_before = @blackEloBefore,
+ black_elo_after = @blackEloAfter
+ WHERE match_id = @matchId;
+ """;
+
+ private const string SelectRecentMatchesSql = """
+ SELECT
+ match_id,
+ white_subject,
+ white_name,
+ black_subject,
+ black_name,
+ result,
+ mode,
+ preset,
+ match_label,
+ white_moves,
+ black_moves,
+ cube_rounds,
+ white_best_cube_ms,
+ black_best_cube_ms,
+ white_average_cube_ms,
+ black_average_cube_ms,
+ is_ranked,
+ white_elo_before,
+ white_elo_after,
+ black_elo_before,
+ black_elo_after,
+ completed_utc
+ FROM site_match_results
+ WHERE white_subject = @subject OR black_subject = @subject
+ ORDER BY completed_utc DESC, id DESC
+ LIMIT @limit;
+ """;
+
+ private readonly SiteDataOptions _options = options.Value;
+ private readonly ILogger _logger = logger;
+
+ public async Task InitializeAsync(CancellationToken cancellationToken)
+ {
+ for (var attempt = 1; attempt <= _options.InitializationRetries; attempt++)
+ {
+ try
+ {
+ await using var connection = new MySqlConnection(_options.BuildConnectionString());
+ await connection.OpenAsync(cancellationToken);
+ await CreateSchemaAsync(connection, cancellationToken);
+ return;
+ }
+ catch (Exception exception) when (attempt < _options.InitializationRetries)
+ {
+ _logger.LogWarning(
+ exception,
+ "Initialisation MySQL impossible pour les statistiques joueurs (tentative {Attempt}/{MaxAttempts}).",
+ attempt,
+ _options.InitializationRetries);
+
+ await Task.Delay(TimeSpan.FromSeconds(_options.InitializationDelaySeconds), cancellationToken);
+ }
+ }
+
+ await using var finalConnection = new MySqlConnection(_options.BuildConnectionString());
+ await finalConnection.OpenAsync(cancellationToken);
+ await CreateSchemaAsync(finalConnection, cancellationToken);
+ }
+
+ public async Task RecordCompletedMatchAsync(
+ AuthenticatedSiteUser reporter,
+ ReportCompletedMatchRequest request,
+ CancellationToken cancellationToken)
+ {
+ var normalized = NormalizeRequest(reporter, request);
+
+ await using var connection = new MySqlConnection(_options.BuildConnectionString());
+ await connection.OpenAsync(cancellationToken);
+ await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
+
+ try
+ {
+ if (!await TryInsertMatchReservationAsync(connection, transaction, normalized, cancellationToken))
+ {
+ await transaction.RollbackAsync(cancellationToken);
+ return new ReportCompletedMatchResponse
+ {
+ Recorded = true,
+ IsDuplicate = true,
+ IsRanked = normalized.IsRanked,
+ };
+ }
+
+ PlayerStatsRow? whiteStats = null;
+ PlayerStatsRow? blackStats = null;
+ EloSnapshot? elo = null;
+
+ if (normalized.WhiteSubject is not null)
+ {
+ await EnsurePlayerStatsRowAsync(connection, transaction, normalized.WhiteSubject, normalized.CompletedUtc, cancellationToken);
+ whiteStats = await ReadPlayerStatsForUpdateAsync(connection, transaction, normalized.WhiteSubject, cancellationToken);
+ }
+
+ if (normalized.BlackSubject is not null)
+ {
+ await EnsurePlayerStatsRowAsync(connection, transaction, normalized.BlackSubject, normalized.CompletedUtc, cancellationToken);
+ blackStats = await ReadPlayerStatsForUpdateAsync(connection, transaction, normalized.BlackSubject, cancellationToken);
+ }
+
+ if (normalized.IsRanked && whiteStats is not null && blackStats is not null)
+ {
+ elo = ComputeElo(whiteStats.CurrentElo, blackStats.CurrentElo, normalized.Result);
+ whiteStats = whiteStats with { CurrentElo = elo.WhiteAfter };
+ blackStats = blackStats with { CurrentElo = elo.BlackAfter };
+ }
+
+ if (whiteStats is not null)
+ {
+ whiteStats = ApplyMatchToStats(
+ whiteStats,
+ normalized,
+ MatchResultWhite,
+ normalized.WhiteMoves,
+ normalized.WhiteCubeTimes,
+ normalized.IsRanked,
+ elo?.WhiteAfter);
+
+ await UpdatePlayerStatsAsync(connection, transaction, whiteStats, cancellationToken);
+ }
+
+ if (blackStats is not null)
+ {
+ blackStats = ApplyMatchToStats(
+ blackStats,
+ normalized,
+ MatchResultBlack,
+ normalized.BlackMoves,
+ normalized.BlackCubeTimes,
+ normalized.IsRanked,
+ elo?.BlackAfter);
+
+ await UpdatePlayerStatsAsync(connection, transaction, blackStats, cancellationToken);
+ }
+
+ await UpdateMatchEloAsync(connection, transaction, normalized.MatchId, elo, cancellationToken);
+ await transaction.CommitAsync(cancellationToken);
+
+ return new ReportCompletedMatchResponse
+ {
+ Recorded = true,
+ IsDuplicate = false,
+ IsRanked = normalized.IsRanked,
+ WhiteEloAfter = elo?.WhiteAfter,
+ BlackEloAfter = elo?.BlackAfter,
+ };
+ }
+ catch
+ {
+ await transaction.RollbackAsync(cancellationToken);
+ throw;
+ }
+ }
+
+ public async Task GetUserStatsAsync(string subject, CancellationToken cancellationToken)
+ {
+ var normalizedSubject = NormalizeRequiredValue(subject, "subject", 190);
+
+ await using var connection = new MySqlConnection(_options.BuildConnectionString());
+ await connection.OpenAsync(cancellationToken);
+
+ var stats = await ReadPlayerStatsAsync(connection, normalizedSubject, cancellationToken);
+ var recentMatches = await ReadRecentMatchesAsync(connection, normalizedSubject, cancellationToken);
+
+ if (stats is null)
+ {
+ return new UserStatsResponse
+ {
+ Subject = normalizedSubject,
+ CurrentElo = DefaultElo,
+ RecentMatches = recentMatches,
+ };
+ }
+
+ return new UserStatsResponse
+ {
+ Subject = stats.Subject,
+ CurrentElo = stats.CurrentElo,
+ RankedGames = stats.RankedGames,
+ CasualGames = stats.CasualGames,
+ Wins = stats.Wins,
+ Losses = stats.Losses,
+ StoppedGames = stats.StoppedGames,
+ WhiteWins = stats.WhiteWins,
+ BlackWins = stats.BlackWins,
+ WhiteLosses = stats.WhiteLosses,
+ BlackLosses = stats.BlackLosses,
+ TotalMoves = stats.TotalMoves,
+ TotalCubeRounds = stats.TotalCubeRounds,
+ BestCubeTimeMs = stats.BestCubeTimeMs,
+ AverageCubeTimeMs = stats.TotalCubeEntries <= 0
+ ? null
+ : (long?)Math.Round((double)stats.TotalCubeTimeMs / stats.TotalCubeEntries, MidpointRounding.AwayFromZero),
+ LastMatchUtc = stats.LastMatchUtc,
+ RecentMatches = recentMatches,
+ };
+ }
+
+ private static async Task CreateSchemaAsync(MySqlConnection connection, CancellationToken cancellationToken)
+ {
+ await using (var command = connection.CreateCommand())
+ {
+ command.CommandText = CreatePlayerStatsTableSql;
+ await command.ExecuteNonQueryAsync(cancellationToken);
+ }
+
+ await using (var command = connection.CreateCommand())
+ {
+ command.CommandText = CreateMatchResultsTableSql;
+ await command.ExecuteNonQueryAsync(cancellationToken);
+ }
+ }
+
+ private static async Task EnsurePlayerStatsRowAsync(
+ MySqlConnection connection,
+ MySqlTransaction transaction,
+ string subject,
+ DateTime nowUtc,
+ CancellationToken cancellationToken)
+ {
+ await using var command = connection.CreateCommand();
+ command.Transaction = transaction;
+ command.CommandText = EnsurePlayerStatsRowSql;
+ command.Parameters.AddWithValue("@subject", subject);
+ command.Parameters.AddWithValue("@currentElo", DefaultElo);
+ command.Parameters.AddWithValue("@updatedUtc", nowUtc);
+ await command.ExecuteNonQueryAsync(cancellationToken);
+ }
+
+ private static async Task TryInsertMatchReservationAsync(
+ MySqlConnection connection,
+ MySqlTransaction transaction,
+ NormalizedCompletedMatch normalized,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ await using var command = connection.CreateCommand();
+ command.Transaction = transaction;
+ command.CommandText = InsertMatchResultSql;
+ command.Parameters.AddWithValue("@matchId", normalized.MatchId);
+ command.Parameters.AddWithValue("@collaborationSessionId", (object?)normalized.CollaborationSessionId ?? DBNull.Value);
+ command.Parameters.AddWithValue("@recordedBySubject", normalized.RecordedBySubject);
+ command.Parameters.AddWithValue("@whiteSubject", (object?)normalized.WhiteSubject ?? DBNull.Value);
+ command.Parameters.AddWithValue("@whiteName", normalized.WhiteName);
+ command.Parameters.AddWithValue("@blackSubject", (object?)normalized.BlackSubject ?? DBNull.Value);
+ command.Parameters.AddWithValue("@blackName", normalized.BlackName);
+ command.Parameters.AddWithValue("@winnerSubject", (object?)normalized.WinnerSubject ?? DBNull.Value);
+ command.Parameters.AddWithValue("@result", normalized.Result);
+ command.Parameters.AddWithValue("@mode", normalized.Mode);
+ command.Parameters.AddWithValue("@preset", normalized.Preset);
+ command.Parameters.AddWithValue("@matchLabel", (object?)normalized.MatchLabel ?? DBNull.Value);
+ command.Parameters.AddWithValue("@blockNumber", normalized.BlockNumber);
+ command.Parameters.AddWithValue("@whiteMoves", normalized.WhiteMoves);
+ command.Parameters.AddWithValue("@blackMoves", normalized.BlackMoves);
+ command.Parameters.AddWithValue("@cubeRounds", normalized.CubeRounds.Length);
+ command.Parameters.AddWithValue("@whiteBestCubeMs", (object?)normalized.WhiteCubeTimes.BestMs ?? DBNull.Value);
+ command.Parameters.AddWithValue("@blackBestCubeMs", (object?)normalized.BlackCubeTimes.BestMs ?? DBNull.Value);
+ command.Parameters.AddWithValue("@whiteAverageCubeMs", (object?)normalized.WhiteCubeTimes.AverageMs ?? DBNull.Value);
+ command.Parameters.AddWithValue("@blackAverageCubeMs", (object?)normalized.BlackCubeTimes.AverageMs ?? DBNull.Value);
+ command.Parameters.AddWithValue("@isRanked", normalized.IsRanked);
+ command.Parameters.AddWithValue("@completedUtc", normalized.CompletedUtc);
+ await command.ExecuteNonQueryAsync(cancellationToken);
+ return true;
+ }
+ catch (MySqlException exception) when (exception.Number == 1062)
+ {
+ return false;
+ }
+ }
+
+ private static async Task ReadPlayerStatsAsync(
+ MySqlConnection connection,
+ string subject,
+ CancellationToken cancellationToken)
+ {
+ await using var command = connection.CreateCommand();
+ command.CommandText = SelectPlayerStatsSql;
+ command.Parameters.AddWithValue("@subject", subject);
+
+ await using var reader = await command.ExecuteReaderAsync(cancellationToken);
+ return await reader.ReadAsync(cancellationToken)
+ ? MapPlayerStats(reader)
+ : null;
+ }
+
+ private static async Task ReadPlayerStatsForUpdateAsync(
+ MySqlConnection connection,
+ MySqlTransaction transaction,
+ string subject,
+ CancellationToken cancellationToken)
+ {
+ await using var command = connection.CreateCommand();
+ command.Transaction = transaction;
+ command.CommandText = SelectPlayerStatsForUpdateSql;
+ command.Parameters.AddWithValue("@subject", subject);
+
+ await using var reader = await command.ExecuteReaderAsync(cancellationToken);
+ if (!await reader.ReadAsync(cancellationToken))
+ {
+ throw new InvalidOperationException("La ligne de statistiques joueur est introuvable.");
+ }
+
+ return MapPlayerStats(reader);
+ }
+
+ private static async Task UpdatePlayerStatsAsync(
+ MySqlConnection connection,
+ MySqlTransaction transaction,
+ PlayerStatsRow stats,
+ CancellationToken cancellationToken)
+ {
+ await using var command = connection.CreateCommand();
+ command.Transaction = transaction;
+ command.CommandText = UpdatePlayerStatsSql;
+ command.Parameters.AddWithValue("@subject", stats.Subject);
+ command.Parameters.AddWithValue("@currentElo", stats.CurrentElo);
+ command.Parameters.AddWithValue("@rankedGames", stats.RankedGames);
+ command.Parameters.AddWithValue("@casualGames", stats.CasualGames);
+ command.Parameters.AddWithValue("@wins", stats.Wins);
+ command.Parameters.AddWithValue("@losses", stats.Losses);
+ command.Parameters.AddWithValue("@stoppedGames", stats.StoppedGames);
+ command.Parameters.AddWithValue("@whiteWins", stats.WhiteWins);
+ command.Parameters.AddWithValue("@blackWins", stats.BlackWins);
+ command.Parameters.AddWithValue("@whiteLosses", stats.WhiteLosses);
+ command.Parameters.AddWithValue("@blackLosses", stats.BlackLosses);
+ command.Parameters.AddWithValue("@totalMoves", stats.TotalMoves);
+ command.Parameters.AddWithValue("@totalCubeRounds", stats.TotalCubeRounds);
+ command.Parameters.AddWithValue("@totalCubeEntries", stats.TotalCubeEntries);
+ command.Parameters.AddWithValue("@totalCubeTimeMs", stats.TotalCubeTimeMs);
+ command.Parameters.AddWithValue("@bestCubeTimeMs", (object?)stats.BestCubeTimeMs ?? DBNull.Value);
+ command.Parameters.AddWithValue("@lastMatchUtc", (object?)stats.LastMatchUtc ?? DBNull.Value);
+ command.Parameters.AddWithValue("@updatedUtc", stats.UpdatedUtc);
+ await command.ExecuteNonQueryAsync(cancellationToken);
+ }
+
+ private static async Task UpdateMatchEloAsync(
+ MySqlConnection connection,
+ MySqlTransaction transaction,
+ string matchId,
+ EloSnapshot? elo,
+ CancellationToken cancellationToken)
+ {
+ await using var command = connection.CreateCommand();
+ command.Transaction = transaction;
+ command.CommandText = UpdateMatchResultEloSql;
+ command.Parameters.AddWithValue("@matchId", matchId);
+ command.Parameters.AddWithValue("@whiteEloBefore", (object?)elo?.WhiteBefore ?? DBNull.Value);
+ command.Parameters.AddWithValue("@whiteEloAfter", (object?)elo?.WhiteAfter ?? DBNull.Value);
+ command.Parameters.AddWithValue("@blackEloBefore", (object?)elo?.BlackBefore ?? DBNull.Value);
+ command.Parameters.AddWithValue("@blackEloAfter", (object?)elo?.BlackAfter ?? DBNull.Value);
+ await command.ExecuteNonQueryAsync(cancellationToken);
+ }
+
+ private static async Task ReadRecentMatchesAsync(
+ MySqlConnection connection,
+ string subject,
+ CancellationToken cancellationToken)
+ {
+ await using var command = connection.CreateCommand();
+ command.CommandText = SelectRecentMatchesSql;
+ command.Parameters.AddWithValue("@subject", subject);
+ command.Parameters.AddWithValue("@limit", RecentMatchLimit);
+
+ var matches = new List();
+ await using var reader = await command.ExecuteReaderAsync(cancellationToken);
+ while (await reader.ReadAsync(cancellationToken))
+ {
+ matches.Add(MapRecentMatch(reader, subject));
+ }
+
+ return matches.ToArray();
+ }
+
+ private static PlayerStatsRow ApplyMatchToStats(
+ PlayerStatsRow current,
+ NormalizedCompletedMatch normalized,
+ string playerColor,
+ int playerMoves,
+ CubeTimeSummary cubeTimes,
+ bool isRanked,
+ int? eloAfter)
+ {
+ var isWhite = playerColor == MatchResultWhite;
+ var isWin = normalized.Result == playerColor;
+ var isLoss = normalized.Result is MatchResultWhite or MatchResultBlack && normalized.Result != playerColor;
+ var isStopped = normalized.Result == MatchResultStopped;
+
+ return current with
+ {
+ RankedGames = current.RankedGames + (isRanked ? 1 : 0),
+ CasualGames = current.CasualGames + (isRanked ? 0 : 1),
+ Wins = current.Wins + (isWin ? 1 : 0),
+ Losses = current.Losses + (isLoss ? 1 : 0),
+ StoppedGames = current.StoppedGames + (isStopped ? 1 : 0),
+ WhiteWins = current.WhiteWins + (isWhite && isWin ? 1 : 0),
+ BlackWins = current.BlackWins + (!isWhite && isWin ? 1 : 0),
+ WhiteLosses = current.WhiteLosses + (isWhite && isLoss ? 1 : 0),
+ BlackLosses = current.BlackLosses + (!isWhite && isLoss ? 1 : 0),
+ TotalMoves = current.TotalMoves + playerMoves,
+ TotalCubeRounds = current.TotalCubeRounds + normalized.CubeRounds.Length,
+ TotalCubeEntries = current.TotalCubeEntries + cubeTimes.Count,
+ TotalCubeTimeMs = current.TotalCubeTimeMs + cubeTimes.TotalMs,
+ BestCubeTimeMs = MinNullable(current.BestCubeTimeMs, cubeTimes.BestMs),
+ LastMatchUtc = normalized.CompletedUtc,
+ UpdatedUtc = normalized.CompletedUtc,
+ CurrentElo = eloAfter ?? current.CurrentElo,
+ };
+ }
+
+ private static EloSnapshot ComputeElo(int whiteRating, int blackRating, string result)
+ {
+ var whiteScore = result == MatchResultWhite ? 1d : 0d;
+ var expectedWhite = 1d / (1d + Math.Pow(10d, (blackRating - whiteRating) / 400d));
+ var whiteDelta = (int)Math.Round(EloKFactor * (whiteScore - expectedWhite), MidpointRounding.AwayFromZero);
+
+ return new EloSnapshot(
+ whiteRating,
+ whiteRating + whiteDelta,
+ blackRating,
+ blackRating - whiteDelta);
+ }
+
+ private static UserRecentMatchResponse MapRecentMatch(MySqlDataReader reader, string subject)
+ {
+ var whiteSubject = ReadNullableString(reader, "white_subject");
+ var blackSubject = ReadNullableString(reader, "black_subject");
+ var playerColor = string.Equals(whiteSubject, subject, StringComparison.Ordinal) ? MatchResultWhite : MatchResultBlack;
+ var isWhite = playerColor == MatchResultWhite;
+ var result = ReadString(reader, "result");
+ var isWin = result == playerColor;
+ var isLoss = result is MatchResultWhite or MatchResultBlack && result != playerColor;
+ var eloBefore = isWhite ? ReadNullableInt(reader, "white_elo_before") : ReadNullableInt(reader, "black_elo_before");
+ var eloAfter = isWhite ? ReadNullableInt(reader, "white_elo_after") : ReadNullableInt(reader, "black_elo_after");
+
+ return new UserRecentMatchResponse
+ {
+ MatchId = ReadString(reader, "match_id"),
+ CompletedUtc = ReadDateTime(reader, "completed_utc"),
+ Result = result,
+ Mode = ReadString(reader, "mode"),
+ Preset = ReadString(reader, "preset"),
+ MatchLabel = ReadNullableString(reader, "match_label"),
+ PlayerColor = playerColor,
+ PlayerName = isWhite ? ReadString(reader, "white_name") : ReadString(reader, "black_name"),
+ OpponentName = isWhite ? ReadString(reader, "black_name") : ReadString(reader, "white_name"),
+ OpponentSubject = isWhite ? blackSubject : whiteSubject,
+ IsRanked = ReadBoolean(reader, "is_ranked"),
+ IsWin = isWin,
+ IsLoss = isLoss,
+ PlayerMoves = isWhite ? ReadInt(reader, "white_moves") : ReadInt(reader, "black_moves"),
+ OpponentMoves = isWhite ? ReadInt(reader, "black_moves") : ReadInt(reader, "white_moves"),
+ CubeRounds = ReadInt(reader, "cube_rounds"),
+ PlayerBestCubeTimeMs = isWhite ? ReadNullableLong(reader, "white_best_cube_ms") : ReadNullableLong(reader, "black_best_cube_ms"),
+ PlayerAverageCubeTimeMs = isWhite ? ReadNullableLong(reader, "white_average_cube_ms") : ReadNullableLong(reader, "black_average_cube_ms"),
+ EloBefore = eloBefore,
+ EloAfter = eloAfter,
+ EloDelta = eloBefore is not null && eloAfter is not null ? eloAfter.Value - eloBefore.Value : null,
+ };
+ }
+
+ private static PlayerStatsRow MapPlayerStats(MySqlDataReader reader)
+ => new(
+ ReadString(reader, "subject"),
+ ReadInt(reader, "current_elo"),
+ ReadInt(reader, "ranked_games"),
+ ReadInt(reader, "casual_games"),
+ ReadInt(reader, "wins"),
+ ReadInt(reader, "losses"),
+ ReadInt(reader, "stopped_games"),
+ ReadInt(reader, "white_wins"),
+ ReadInt(reader, "black_wins"),
+ ReadInt(reader, "white_losses"),
+ ReadInt(reader, "black_losses"),
+ ReadInt(reader, "total_moves"),
+ ReadInt(reader, "total_cube_rounds"),
+ ReadInt(reader, "total_cube_entries"),
+ ReadLong(reader, "total_cube_time_ms"),
+ ReadNullableLong(reader, "best_cube_time_ms"),
+ ReadNullableDateTime(reader, "last_match_utc"),
+ ReadDateTime(reader, "updated_utc"));
+
+ private static NormalizedCompletedMatch NormalizeRequest(AuthenticatedSiteUser reporter, ReportCompletedMatchRequest request)
+ {
+ var matchId = NormalizeRequiredValue(request.MatchId, "identifiant de match", 80);
+ var collaborationSessionId = NormalizeOptionalValue(request.CollaborationSessionId, "session collaborative", 80);
+ var whiteSubject = NormalizeOptionalValue(request.WhiteSubject, "subject blanc", 190);
+ var blackSubject = NormalizeOptionalValue(request.BlackSubject, "subject noir", 190);
+ var whiteName = NormalizeRequiredValue(request.WhiteName, "joueur blanc", 120);
+ var blackName = NormalizeRequiredValue(request.BlackName, "joueur noir", 120);
+ var mode = NormalizeRequiredValue(request.Mode, "mode", 40);
+ var preset = NormalizeRequiredValue(request.Preset, "preset", 40);
+ var matchLabel = NormalizeOptionalValue(request.MatchLabel, "nom de rencontre", 120);
+ var result = NormalizeResult(request.Result);
+ var blockNumber = Math.Clamp(request.BlockNumber, 1, 999);
+ var whiteMoves = Math.Clamp(request.WhiteMoves, 0, 9999);
+ var blackMoves = Math.Clamp(request.BlackMoves, 0, 9999);
+
+ if (whiteSubject is null && blackSubject is null)
+ {
+ throw new PlayerStatsValidationException("Impossible d'enregistrer une partie sans joueur identifie.");
+ }
+
+ if (whiteSubject is not null &&
+ blackSubject is not null &&
+ string.Equals(whiteSubject, blackSubject, StringComparison.Ordinal))
+ {
+ throw new PlayerStatsValidationException("Les deux cotes ne peuvent pas pointer vers le meme compte.");
+ }
+
+ if (!string.Equals(reporter.Subject, whiteSubject, StringComparison.Ordinal) &&
+ !string.Equals(reporter.Subject, blackSubject, StringComparison.Ordinal))
+ {
+ throw new PlayerStatsValidationException("Le compte connecte doit correspondre a l'un des deux joueurs pour enregistrer la partie.");
+ }
+
+ var cubeRounds = (request.CubeRounds ?? [])
+ .Take(64)
+ .Select(round => new NormalizedCubeRound(
+ Math.Clamp(round.BlockNumber, 1, 999),
+ round.Number is null ? null : Math.Clamp(round.Number.Value, 1, 999),
+ NormalizeCubeDuration(round.White),
+ NormalizeCubeDuration(round.Black)))
+ .ToArray();
+
+ var whiteCubeTimes = SummarizeCubeTimes(cubeRounds.Select(round => round.White));
+ var blackCubeTimes = SummarizeCubeTimes(cubeRounds.Select(round => round.Black));
+ var isRanked = whiteSubject is not null &&
+ blackSubject is not null &&
+ result is MatchResultWhite or MatchResultBlack;
+
+ return new NormalizedCompletedMatch(
+ matchId,
+ collaborationSessionId,
+ reporter.Subject,
+ whiteSubject,
+ whiteName,
+ blackSubject,
+ blackName,
+ result,
+ mode,
+ preset,
+ matchLabel,
+ blockNumber,
+ whiteMoves,
+ blackMoves,
+ cubeRounds,
+ whiteCubeTimes,
+ blackCubeTimes,
+ isRanked,
+ result == MatchResultWhite
+ ? whiteSubject
+ : result == MatchResultBlack
+ ? blackSubject
+ : null,
+ DateTime.UtcNow);
+ }
+
+ private static CubeTimeSummary SummarizeCubeTimes(IEnumerable values)
+ {
+ var normalized = values
+ .Where(value => value is > 0)
+ .Select(value => value!.Value)
+ .ToArray();
+
+ if (normalized.Length == 0)
+ {
+ return new CubeTimeSummary(0, 0, null, null);
+ }
+
+ return new CubeTimeSummary(
+ normalized.Length,
+ normalized.Sum(),
+ normalized.Min(),
+ (long)Math.Round(normalized.Average(), MidpointRounding.AwayFromZero));
+ }
+
+ private static long? NormalizeCubeDuration(long? value)
+ => value is > 0
+ ? Math.Clamp(value.Value, 1, 3_600_000)
+ : null;
+
+ private static string NormalizeResult(string? value)
+ {
+ var normalized = NormalizeRequiredValue(value, "resultat", 20).ToLowerInvariant();
+ return normalized switch
+ {
+ MatchResultWhite => MatchResultWhite,
+ MatchResultBlack => MatchResultBlack,
+ MatchResultStopped => MatchResultStopped,
+ _ => throw new PlayerStatsValidationException("Le resultat doit etre white, black ou stopped."),
+ };
+ }
+
+ private static string NormalizeRequiredValue(string? value, string fieldName, int maxLength)
+ => NormalizeOptionalValue(value, fieldName, maxLength)
+ ?? throw new PlayerStatsValidationException($"Le champ {fieldName} est obligatoire.");
+
+ private static string? NormalizeOptionalValue(string? value, string fieldName, int maxLength)
+ {
+ var trimmed = value?.Trim();
+ if (string.IsNullOrWhiteSpace(trimmed))
+ {
+ return null;
+ }
+
+ if (trimmed.Length > maxLength)
+ {
+ throw new PlayerStatsValidationException($"Le champ {fieldName} depasse {maxLength} caracteres.");
+ }
+
+ return trimmed;
+ }
+
+ private static long? MinNullable(long? current, long? candidate)
+ {
+ if (candidate is null)
+ {
+ return current;
+ }
+
+ return current is null
+ ? candidate
+ : Math.Min(current.Value, candidate.Value);
+ }
+
+ private static string ReadString(MySqlDataReader reader, string column)
+ => reader.GetString(reader.GetOrdinal(column));
+
+ private static string? ReadNullableString(MySqlDataReader reader, string column)
+ {
+ var ordinal = reader.GetOrdinal(column);
+ return reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal);
+ }
+
+ private static int ReadInt(MySqlDataReader reader, string column)
+ => reader.GetInt32(reader.GetOrdinal(column));
+
+ private static int? ReadNullableInt(MySqlDataReader reader, string column)
+ {
+ var ordinal = reader.GetOrdinal(column);
+ return reader.IsDBNull(ordinal) ? null : reader.GetInt32(ordinal);
+ }
+
+ private static long ReadLong(MySqlDataReader reader, string column)
+ => reader.GetInt64(reader.GetOrdinal(column));
+
+ private static long? ReadNullableLong(MySqlDataReader reader, string column)
+ {
+ var ordinal = reader.GetOrdinal(column);
+ return reader.IsDBNull(ordinal) ? null : reader.GetInt64(ordinal);
+ }
+
+ private static bool ReadBoolean(MySqlDataReader reader, string column)
+ => reader.GetBoolean(reader.GetOrdinal(column));
+
+ private static DateTime ReadDateTime(MySqlDataReader reader, string column)
+ => reader.GetDateTime(reader.GetOrdinal(column));
+
+ private static DateTime? ReadNullableDateTime(MySqlDataReader reader, string column)
+ {
+ var ordinal = reader.GetOrdinal(column);
+ return reader.IsDBNull(ordinal) ? null : reader.GetDateTime(ordinal);
+ }
+
+ private sealed record PlayerStatsRow(
+ string Subject,
+ int CurrentElo,
+ int RankedGames,
+ int CasualGames,
+ int Wins,
+ int Losses,
+ int StoppedGames,
+ int WhiteWins,
+ int BlackWins,
+ int WhiteLosses,
+ int BlackLosses,
+ int TotalMoves,
+ int TotalCubeRounds,
+ int TotalCubeEntries,
+ long TotalCubeTimeMs,
+ long? BestCubeTimeMs,
+ DateTime? LastMatchUtc,
+ DateTime UpdatedUtc);
+
+ private sealed record EloSnapshot(
+ int WhiteBefore,
+ int WhiteAfter,
+ int BlackBefore,
+ int BlackAfter);
+
+ private sealed record NormalizedCubeRound(
+ int BlockNumber,
+ int? Number,
+ long? White,
+ long? Black);
+
+ private sealed record CubeTimeSummary(
+ int Count,
+ long TotalMs,
+ long? BestMs,
+ long? AverageMs);
+
+ private sealed record NormalizedCompletedMatch(
+ string MatchId,
+ string? CollaborationSessionId,
+ string RecordedBySubject,
+ string? WhiteSubject,
+ string WhiteName,
+ string? BlackSubject,
+ string BlackName,
+ string Result,
+ string Mode,
+ string Preset,
+ string? MatchLabel,
+ int BlockNumber,
+ int WhiteMoves,
+ int BlackMoves,
+ NormalizedCubeRound[] CubeRounds,
+ CubeTimeSummary WhiteCubeTimes,
+ CubeTimeSummary BlackCubeTimes,
+ bool IsRanked,
+ string? WinnerSubject,
+ DateTime CompletedUtc);
+}
diff --git a/ChessCubing.Server/Stats/PlayerStatsContracts.cs b/ChessCubing.Server/Stats/PlayerStatsContracts.cs
new file mode 100644
index 0000000..b648ce7
--- /dev/null
+++ b/ChessCubing.Server/Stats/PlayerStatsContracts.cs
@@ -0,0 +1,140 @@
+namespace ChessCubing.Server.Stats;
+
+public sealed class ReportCompletedMatchRequest
+{
+ public string MatchId { get; init; } = string.Empty;
+
+ public string? CollaborationSessionId { get; init; }
+
+ public string? WhiteSubject { get; init; }
+
+ public string WhiteName { get; init; } = string.Empty;
+
+ public string? BlackSubject { get; init; }
+
+ public string BlackName { get; init; } = string.Empty;
+
+ public string Result { get; init; } = string.Empty;
+
+ public string Mode { get; init; } = string.Empty;
+
+ public string Preset { get; init; } = string.Empty;
+
+ public string? MatchLabel { get; init; }
+
+ public int BlockNumber { get; init; }
+
+ public int WhiteMoves { get; init; }
+
+ public int BlackMoves { get; init; }
+
+ public ReportCompletedCubeRound[] CubeRounds { get; init; } = [];
+}
+
+public sealed class ReportCompletedCubeRound
+{
+ public int BlockNumber { get; init; }
+
+ public int? Number { get; init; }
+
+ public long? White { get; init; }
+
+ public long? Black { get; init; }
+}
+
+public sealed class ReportCompletedMatchResponse
+{
+ public bool Recorded { get; init; }
+
+ public bool IsDuplicate { get; init; }
+
+ public bool IsRanked { get; init; }
+
+ public int? WhiteEloAfter { get; init; }
+
+ public int? BlackEloAfter { get; init; }
+}
+
+public sealed class UserStatsResponse
+{
+ public string Subject { get; init; } = string.Empty;
+
+ public int CurrentElo { get; init; }
+
+ public int RankedGames { get; init; }
+
+ public int CasualGames { get; init; }
+
+ public int Wins { get; init; }
+
+ public int Losses { get; init; }
+
+ public int StoppedGames { get; init; }
+
+ public int WhiteWins { get; init; }
+
+ public int BlackWins { get; init; }
+
+ public int WhiteLosses { get; init; }
+
+ public int BlackLosses { get; init; }
+
+ public int TotalMoves { get; init; }
+
+ public int TotalCubeRounds { get; init; }
+
+ public long? BestCubeTimeMs { get; init; }
+
+ public long? AverageCubeTimeMs { get; init; }
+
+ public DateTime? LastMatchUtc { get; init; }
+
+ public UserRecentMatchResponse[] RecentMatches { get; init; } = [];
+}
+
+public sealed class UserRecentMatchResponse
+{
+ public string MatchId { get; init; } = string.Empty;
+
+ public DateTime CompletedUtc { get; init; }
+
+ public string Result { get; init; } = string.Empty;
+
+ public string Mode { get; init; } = string.Empty;
+
+ public string Preset { get; init; } = string.Empty;
+
+ public string? MatchLabel { get; init; }
+
+ public string PlayerColor { get; init; } = string.Empty;
+
+ public string PlayerName { get; init; } = string.Empty;
+
+ public string OpponentName { get; init; } = string.Empty;
+
+ public string? OpponentSubject { get; init; }
+
+ public bool IsRanked { get; init; }
+
+ public bool IsWin { get; init; }
+
+ public bool IsLoss { get; init; }
+
+ public int PlayerMoves { get; init; }
+
+ public int OpponentMoves { get; init; }
+
+ public int CubeRounds { get; init; }
+
+ public long? PlayerBestCubeTimeMs { get; init; }
+
+ public long? PlayerAverageCubeTimeMs { get; init; }
+
+ public int? EloBefore { get; init; }
+
+ public int? EloAfter { get; init; }
+
+ public int? EloDelta { get; init; }
+}
+
+public sealed class PlayerStatsValidationException(string message) : Exception(message);
diff --git a/doc/api-utilisateurs.md b/doc/api-utilisateurs.md
index 918cd96..9c82626 100644
--- a/doc/api-utilisateurs.md
+++ b/doc/api-utilisateurs.md
@@ -217,6 +217,108 @@ Erreurs frequentes :
- `400 Bad Request` si une longueur maximale est depassee.
- `401 Unauthorized` sans session.
+### GET /api/users/me/stats
+
+Retourne les statistiques du compte courant et ses derniers matchs enregistres.
+
+Reponse `200 OK` :
+
+```json
+{
+ "subject": "2f7d0f1d-3ef6-4b5f-aab5-4cf6b61c0a28",
+ "currentElo": 1216,
+ "rankedGames": 1,
+ "casualGames": 2,
+ "wins": 2,
+ "losses": 1,
+ "stoppedGames": 0,
+ "whiteWins": 1,
+ "blackWins": 1,
+ "whiteLosses": 1,
+ "blackLosses": 0,
+ "totalMoves": 34,
+ "totalCubeRounds": 6,
+ "bestCubeTimeMs": 8420,
+ "averageCubeTimeMs": 11530,
+ "lastMatchUtc": "2026-04-15T11:00:00.0000000Z",
+ "recentMatches": [
+ {
+ "matchId": "c1f4e44f5f5f4fe4b8f2bf1f8a634f9e",
+ "completedUtc": "2026-04-15T11:00:00.0000000Z",
+ "result": "white",
+ "mode": "twice",
+ "preset": "fast",
+ "matchLabel": "Rencontre ChessCubing",
+ "playerColor": "white",
+ "playerName": "Christophe JEANNEROT",
+ "opponentName": "Alex Martin",
+ "opponentSubject": "sub-ami-1",
+ "isRanked": true,
+ "isWin": true,
+ "isLoss": false,
+ "playerMoves": 6,
+ "opponentMoves": 6,
+ "cubeRounds": 2,
+ "playerBestCubeTimeMs": 8420,
+ "playerAverageCubeTimeMs": 10120,
+ "eloBefore": 1200,
+ "eloAfter": 1216,
+ "eloDelta": 16
+ }
+ ]
+}
+```
+
+### POST /api/users/me/stats/matches
+
+Enregistre la fin d'une partie pour les statistiques joueur.
+
+Important :
+
+- le compte connecte doit correspondre a l'un des deux joueurs identifies dans le payload
+- l'Elo n'est mis a jour que si `whiteSubject` et `blackSubject` sont tous les deux renseignes, distincts, et que le resultat est `white` ou `black`
+- si une meme partie est envoyee deux fois avec le meme `matchId`, l'enregistrement reste idempotent
+
+Requete :
+
+```json
+{
+ "matchId": "c1f4e44f5f5f4fe4b8f2bf1f8a634f9e",
+ "collaborationSessionId": "play-session-123",
+ "whiteSubject": "2f7d0f1d-3ef6-4b5f-aab5-4cf6b61c0a28",
+ "whiteName": "Christophe JEANNEROT",
+ "blackSubject": "sub-ami-1",
+ "blackName": "Alex Martin",
+ "result": "white",
+ "mode": "twice",
+ "preset": "fast",
+ "matchLabel": "Rencontre ChessCubing",
+ "blockNumber": 2,
+ "whiteMoves": 6,
+ "blackMoves": 6,
+ "cubeRounds": [
+ {
+ "blockNumber": 1,
+ "number": 4,
+ "white": 8420,
+ "black": 11030
+ }
+ ]
+}
+```
+
+Reponse `200 OK` :
+
+```json
+{
+ "recorded": true,
+ "isDuplicate": false,
+ "isRanked": true,
+ "whiteEloAfter": 1216,
+ "blackEloAfter": 1184
+}
+```
+
## Recherche de joueurs et relations sociales
Ces routes necessitent toutes une session authentifiee.
diff --git a/styles.css b/styles.css
index decf240..186f9c3 100644
--- a/styles.css
+++ b/styles.css
@@ -1732,6 +1732,62 @@ body.site-menu-hidden .site-menu-shell {
grid-column: 1 / -1;
}
+.user-stats-block {
+ display: grid;
+ gap: 0.7rem;
+ margin-top: 0.2rem;
+}
+
+.user-stats-grid {
+ display: grid;
+ grid-template-columns: repeat(6, minmax(0, 1fr));
+ gap: 0.55rem;
+}
+
+.user-stat-emphasis {
+ background: linear-gradient(135deg, rgba(255, 168, 62, 0.18), rgba(255, 255, 255, 0.05));
+ border-color: rgba(255, 168, 62, 0.35);
+}
+
+.user-stat-emphasis strong {
+ color: var(--accent);
+ font-size: 1.2rem;
+}
+
+.user-recent-matches {
+ display: grid;
+ gap: 0.55rem;
+}
+
+.recent-match-item {
+ align-items: center;
+}
+
+.recent-match-meta {
+ display: flex;
+ align-items: center;
+ gap: 0.45rem;
+ flex-wrap: wrap;
+}
+
+.recent-loss-badge {
+ border-color: rgba(255, 100, 127, 0.28);
+ background: rgba(255, 100, 127, 0.12);
+ color: #ffd8de;
+}
+
+.user-elo-chip-positive {
+ border-color: rgba(69, 185, 127, 0.32);
+ background: rgba(69, 185, 127, 0.16);
+ color: #dff7ea;
+}
+
+.user-elo-chip-negative {
+ border-color: rgba(255, 100, 127, 0.28);
+ background: rgba(255, 100, 127, 0.12);
+ color: #ffd8de;
+}
+
.admin-hero-stats {
margin-top: 0.8rem;
}
@@ -2066,6 +2122,7 @@ body.site-menu-hidden .site-menu-shell {
.admin-edit-summary-grid,
.user-profile-summary-grid,
+ .user-stats-grid,
.profile-meta-grid,
.profile-form-grid,
.admin-toggle-grid,
@@ -2103,6 +2160,12 @@ body.site-menu-hidden .site-menu-shell {
}
}
+@media (max-width: 720px) {
+ .recent-match-item {
+ align-items: start;
+ }
+}
+
@media (max-width: 900px) {
:root {
--site-menu-offset: calc(6.8rem + var(--safe-top));