Ajoute l Elo et les statistiques de parties

This commit is contained in:
2026-04-16 00:17:52 +02:00
parent db233e7110
commit 407e5e8ed5
13 changed files with 1914 additions and 11 deletions

View File

@@ -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();

View File

@@ -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; }
}

View File

@@ -160,7 +160,7 @@
</button>
}
</div>
<input @bind="WhitePlayerName" @bind:event="oninput" name="whiteName" type="text" maxlength="40" placeholder="Blanc" />
<input @bind="WhitePlayerName" @bind:event="oninput" name="whiteName" type="text" maxlength="40" placeholder="Blanc" disabled="@HasLockedPlaySession" />
</label>
<label class="field player-name-field">
@@ -178,7 +178,7 @@
</button>
}
</div>
<input @bind="BlackPlayerName" @bind:event="oninput" name="blackName" type="text" maxlength="40" placeholder="Noir" />
<input @bind="BlackPlayerName" @bind:event="oninput" name="blackName" type="text" maxlength="40" placeholder="Noir" disabled="@HasLockedPlaySession" />
</label>
@if (!string.IsNullOrWhiteSpace(SetupError))
@@ -297,6 +297,7 @@
<span>@TimingText</span>
<span>@TimeImpact</span>
<span>@QuotaText</span>
<span>@StatsEligibilityText</span>
</div>
<div class="setup-actions span-2">
@@ -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;

View File

@@ -4,6 +4,7 @@
@inject MatchStore Store
@inject NavigationManager Navigation
@inject SocialRealtimeService Realtime
@inject MatchStatsService MatchStats
<PageTitle>ChessCubing Arena | Phase Chrono</PageTitle>
<PageBody Page="chrono" BodyClass="@ChronoBodyClass" />
@@ -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!;

View File

@@ -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 @@
</article>
</div>
<div class="user-stats-block">
<div class="section-heading user-profile-heading">
<div>
<p class="eyebrow">Classement joueur</p>
<h3>Elo et statistiques de partie</h3>
</div>
</div>
@if (!string.IsNullOrWhiteSpace(StatsLoadError))
{
<p class="profile-feedback error">@StatsLoadError</p>
}
else if (Stats is not null)
{
<div class="user-stats-grid">
<article class="user-summary-card user-stat-emphasis">
<span class="micro-label">Elo actuel</span>
<strong>@Stats.CurrentElo</strong>
</article>
<article class="user-summary-card">
<span class="micro-label">Parties classees</span>
<strong>@Stats.RankedGames</strong>
</article>
<article class="user-summary-card">
<span class="micro-label">Parties amicales</span>
<strong>@Stats.CasualGames</strong>
</article>
<article class="user-summary-card">
<span class="micro-label">Victoires</span>
<strong>@Stats.Wins</strong>
</article>
<article class="user-summary-card">
<span class="micro-label">Defaites</span>
<strong>@Stats.Losses</strong>
</article>
<article class="user-summary-card">
<span class="micro-label">Parties stoppees</span>
<strong>@Stats.StoppedGames</strong>
</article>
<article class="user-summary-card">
<span class="micro-label">Taux de victoire</span>
<strong>@StatsWinRateLabel</strong>
</article>
<article class="user-summary-card">
<span class="micro-label">Coups joues</span>
<strong>@Stats.TotalMoves</strong>
</article>
<article class="user-summary-card">
<span class="micro-label">Rounds cube</span>
<strong>@Stats.TotalCubeRounds</strong>
</article>
<article class="user-summary-card">
<span class="micro-label">Meilleur cube</span>
<strong>@FormatDurationShort(Stats.BestCubeTimeMs)</strong>
</article>
<article class="user-summary-card">
<span class="micro-label">Moyenne cube</span>
<strong>@FormatDurationShort(Stats.AverageCubeTimeMs)</strong>
</article>
<article class="user-summary-card">
<span class="micro-label">Derniere partie</span>
<strong>@FormatOptionalDate(Stats.LastMatchUtc)</strong>
</article>
</div>
<div class="user-recent-matches">
<div class="social-card-head">
<strong>Dernieres parties</strong>
<span class="mini-chip admin-chip-outline">@Stats.RecentMatches.Length match(s)</span>
</div>
@if (Stats.RecentMatches.Length > 0)
{
<div class="social-list">
@foreach (var match in Stats.RecentMatches)
{
<article class="social-item recent-match-item">
<div class="social-item-meta">
<div class="social-item-title-row">
<strong>@BuildRecentMatchTitle(match)</strong>
<span class="@BuildRecentMatchBadgeClass(match)">@BuildRecentMatchBadge(match)</span>
</div>
<span class="social-item-subtitle">@BuildRecentMatchSubtitle(match)</span>
<span class="social-item-caption">@BuildRecentMatchCaption(match)</span>
</div>
<div class="social-item-actions recent-match-meta">
@if (match.EloDelta is not null)
{
<span class="@BuildEloDeltaClass(match.EloDelta.Value)">
Elo @BuildSignedNumber(match.EloDelta.Value)
</span>
}
<span class="mini-chip admin-chip-neutral">
@match.PlayerMoves coup(s)
</span>
</div>
</article>
}
</div>
}
else
{
<p class="social-empty">Aucune partie enregistree pour le moment.</p>
}
</div>
}
</div>
<div class="callout user-profile-bio">
<span class="micro-label">Bio</span>
<p>@(Profile.Bio ?? "Aucune bio enregistree pour le moment.")</p>
@@ -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<UserStatsResponse>();
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<string>
{
$"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;

View File

@@ -16,5 +16,6 @@ builder.Services.AddScoped<BrowserBridge>();
builder.Services.AddScoped<UserSession>();
builder.Services.AddScoped<MatchStore>();
builder.Services.AddScoped<SocialRealtimeService>();
builder.Services.AddScoped<MatchStatsService>();
await builder.Build().RunAsync();

View File

@@ -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)
{

View File

@@ -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<bool> 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();
}

View File

@@ -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<KeycloakAuthService>();
builder.Services.AddSingleton<MySqlUserProfileStore>();
builder.Services.AddSingleton<MySqlSocialStore>();
builder.Services.AddSingleton<MySqlPlayerStatsStore>();
builder.Services.AddSingleton<ConnectedUserTracker>();
builder.Services.AddSingleton<PlayInviteCoordinator>();
builder.Services.AddSingleton<CollaborativeMatchCoordinator>();
@@ -83,8 +85,10 @@ await using (var scope = app.Services.CreateAsyncScope())
{
var profileStore = scope.ServiceProvider.GetRequiredService<MySqlUserProfileStore>();
var socialStore = scope.ServiceProvider.GetRequiredService<MySqlSocialStore>();
var statsStore = scope.ServiceProvider.GetRequiredService<MySqlPlayerStatsStore>();
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<IResult> (
}
}).RequireAuthorization();
app.MapGet("/api/users/me/stats", async Task<IResult> (
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<IResult> (
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();

View File

@@ -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<SiteDataOptions> options,
ILogger<MySqlPlayerStatsStore> 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<MySqlPlayerStatsStore> _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<ReportCompletedMatchResponse> 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<UserStatsResponse> 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<bool> 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<PlayerStatsRow?> 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<PlayerStatsRow> 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<UserRecentMatchResponse[]> 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<UserRecentMatchResponse>();
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<long?> 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);
}

View File

@@ -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);

View File

@@ -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.

View File

@@ -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));