Ajoute l Elo et les statistiques de parties
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
138
ChessCubing.App/Models/Stats/UserStatsModels.cs
Normal file
138
ChessCubing.App/Models/Stats/UserStatsModels.cs
Normal 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; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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!;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
77
ChessCubing.App/Services/MatchStatsService.cs
Normal file
77
ChessCubing.App/Services/MatchStatsService.cs
Normal 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();
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
992
ChessCubing.Server/Stats/MySqlPlayerStatsStore.cs
Normal file
992
ChessCubing.Server/Stats/MySqlPlayerStatsStore.cs
Normal 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);
|
||||
}
|
||||
140
ChessCubing.Server/Stats/PlayerStatsContracts.cs
Normal file
140
ChessCubing.Server/Stats/PlayerStatsContracts.cs
Normal 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);
|
||||
@@ -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.
|
||||
|
||||
63
styles.css
63
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));
|
||||
|
||||
Reference in New Issue
Block a user