From 407e5e8ed5605cd99b361601f02ce613826ad2b3 Mon Sep 17 00:00:00 2001 From: Christophe Date: Thu, 16 Apr 2026 00:17:52 +0200 Subject: [PATCH] Ajoute l Elo et les statistiques de parties --- ChessCubing.App/Models/MatchModels.cs | 14 +- .../Models/Stats/UserStatsModels.cs | 138 +++ ChessCubing.App/Pages/ApplicationPage.razor | 73 +- ChessCubing.App/Pages/ChronoPage.razor | 19 + ChessCubing.App/Pages/UserPage.razor | 246 +++++ ChessCubing.App/Program.cs | 1 + ChessCubing.App/Services/MatchEngine.cs | 18 +- ChessCubing.App/Services/MatchStatsService.cs | 77 ++ ChessCubing.Server/Program.cs | 42 + .../Stats/MySqlPlayerStatsStore.cs | 992 ++++++++++++++++++ .../Stats/PlayerStatsContracts.cs | 140 +++ doc/api-utilisateurs.md | 102 ++ styles.css | 63 ++ 13 files changed, 1914 insertions(+), 11 deletions(-) create mode 100644 ChessCubing.App/Models/Stats/UserStatsModels.cs create mode 100644 ChessCubing.App/Services/MatchStatsService.cs create mode 100644 ChessCubing.Server/Stats/MySqlPlayerStatsStore.cs create mode 100644 ChessCubing.Server/Stats/PlayerStatsContracts.cs 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 @@
+
+ + + @if (!string.IsNullOrWhiteSpace(StatsLoadError)) + { +

@StatsLoadError

+ } + else if (Stats is not null) + { +
+
+ Elo actuel + @Stats.CurrentElo +
+
+ Parties classees + @Stats.RankedGames +
+
+ Parties amicales + @Stats.CasualGames +
+
+ Victoires + @Stats.Wins +
+
+ Defaites + @Stats.Losses +
+
+ Parties stoppees + @Stats.StoppedGames +
+
+ Taux de victoire + @StatsWinRateLabel +
+
+ Coups joues + @Stats.TotalMoves +
+
+ Rounds cube + @Stats.TotalCubeRounds +
+
+ Meilleur cube + @FormatDurationShort(Stats.BestCubeTimeMs) +
+
+ Moyenne cube + @FormatDurationShort(Stats.AverageCubeTimeMs) +
+
+ Derniere partie + @FormatOptionalDate(Stats.LastMatchUtc) +
+
+ +
+ + + @if (Stats.RecentMatches.Length > 0) + { + + } + else + { + + } +
+ } +
+
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));