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

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