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
|
public sealed class MatchState
|
||||||
{
|
{
|
||||||
[JsonPropertyName("schemaVersion")]
|
[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")]
|
[JsonPropertyName("collaborationSessionId")]
|
||||||
public string? CollaborationSessionId { get; set; }
|
public string? CollaborationSessionId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("whiteSubject")]
|
||||||
|
public string? WhiteSubject { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("blackSubject")]
|
||||||
|
public string? BlackSubject { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("config")]
|
[JsonPropertyName("config")]
|
||||||
public MatchConfig Config { get; set; } = new();
|
public MatchConfig Config { get; set; } = new();
|
||||||
|
|
||||||
@@ -95,6 +104,9 @@ public sealed class MatchState
|
|||||||
[JsonPropertyName("result")]
|
[JsonPropertyName("result")]
|
||||||
public string? Result { get; set; }
|
public string? Result { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("resultRecordedUtc")]
|
||||||
|
public DateTime? ResultRecordedUtc { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("cube")]
|
[JsonPropertyName("cube")]
|
||||||
public CubeState Cube { get; set; } = new();
|
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>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<label class="field player-name-field">
|
<label class="field player-name-field">
|
||||||
@@ -178,7 +178,7 @@
|
|||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</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>
|
</label>
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(SetupError))
|
@if (!string.IsNullOrWhiteSpace(SetupError))
|
||||||
@@ -297,6 +297,7 @@
|
|||||||
<span>@TimingText</span>
|
<span>@TimingText</span>
|
||||||
<span>@TimeImpact</span>
|
<span>@TimeImpact</span>
|
||||||
<span>@QuotaText</span>
|
<span>@QuotaText</span>
|
||||||
|
<span>@StatsEligibilityText</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="setup-actions span-2">
|
<div class="setup-actions span-2">
|
||||||
@@ -386,6 +387,9 @@
|
|||||||
private long _knownCollaborativeRevision;
|
private long _knownCollaborativeRevision;
|
||||||
private string? _appliedActiveSessionId;
|
private string? _appliedActiveSessionId;
|
||||||
private string? ConnectedPlayerName;
|
private string? ConnectedPlayerName;
|
||||||
|
private string? ConnectedPlayerSubject;
|
||||||
|
private string? AssignedWhiteSubject;
|
||||||
|
private string? AssignedBlackSubject;
|
||||||
private string? SetupError;
|
private string? SetupError;
|
||||||
private string? SocialLoadError;
|
private string? SocialLoadError;
|
||||||
private string? InviteActionError;
|
private string? InviteActionError;
|
||||||
@@ -395,12 +399,21 @@
|
|||||||
|
|
||||||
private string SetupBodyClass => UsesMoveLimit ? string.Empty : "time-setup-mode";
|
private string SetupBodyClass => UsesMoveLimit ? string.Empty : "time-setup-mode";
|
||||||
private bool CanUseConnectedPlayerName => !string.IsNullOrWhiteSpace(ConnectedPlayerName);
|
private bool CanUseConnectedPlayerName => !string.IsNullOrWhiteSpace(ConnectedPlayerName);
|
||||||
|
private bool HasLockedPlaySession => Realtime.ActivePlaySession is not null;
|
||||||
private string WhitePlayerName
|
private string WhitePlayerName
|
||||||
{
|
{
|
||||||
get => Form.WhiteName;
|
get => Form.WhiteName;
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
Form.WhiteName = value;
|
Form.WhiteName = value;
|
||||||
|
if (!HasLockedPlaySession &&
|
||||||
|
!string.IsNullOrWhiteSpace(AssignedWhiteSubject) &&
|
||||||
|
AssignedWhiteSubject == ConnectedPlayerSubject &&
|
||||||
|
!SamePlayerName(value, ConnectedPlayerName))
|
||||||
|
{
|
||||||
|
AssignedWhiteSubject = null;
|
||||||
|
}
|
||||||
|
|
||||||
SetupError = null;
|
SetupError = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -411,6 +424,14 @@
|
|||||||
set
|
set
|
||||||
{
|
{
|
||||||
Form.BlackName = value;
|
Form.BlackName = value;
|
||||||
|
if (!HasLockedPlaySession &&
|
||||||
|
!string.IsNullOrWhiteSpace(AssignedBlackSubject) &&
|
||||||
|
AssignedBlackSubject == ConnectedPlayerSubject &&
|
||||||
|
!SamePlayerName(value, ConnectedPlayerName))
|
||||||
|
{
|
||||||
|
AssignedBlackSubject = null;
|
||||||
|
}
|
||||||
|
|
||||||
SetupError = null;
|
SetupError = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -449,6 +470,13 @@
|
|||||||
? $"Quota actif : {CurrentPreset.Quota} coups par joueur."
|
? $"Quota actif : {CurrentPreset.Quota} coups par joueur."
|
||||||
: $"Quota actif : {CurrentPreset.Quota} coups par joueur et par Block.";
|
: $"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()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
AuthenticationStateProvider.AuthenticationStateChanged += HandleAuthenticationStateChanged;
|
AuthenticationStateProvider.AuthenticationStateChanged += HandleAuthenticationStateChanged;
|
||||||
@@ -508,6 +536,9 @@
|
|||||||
{
|
{
|
||||||
IsAuthenticated = false;
|
IsAuthenticated = false;
|
||||||
ConnectedPlayerName = null;
|
ConnectedPlayerName = null;
|
||||||
|
ConnectedPlayerSubject = null;
|
||||||
|
AssignedWhiteSubject = null;
|
||||||
|
AssignedBlackSubject = null;
|
||||||
ResetSocialState();
|
ResetSocialState();
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
return;
|
return;
|
||||||
@@ -515,6 +546,7 @@
|
|||||||
|
|
||||||
IsAuthenticated = true;
|
IsAuthenticated = true;
|
||||||
fallbackName = BuildConnectedPlayerFallback(user);
|
fallbackName = BuildConnectedPlayerFallback(user);
|
||||||
|
ConnectedPlayerSubject = ResolveSubject(user);
|
||||||
|
|
||||||
var response = await Http.GetAsync("api/users/me");
|
var response = await Http.GetAsync("api/users/me");
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
@@ -523,6 +555,9 @@
|
|||||||
{
|
{
|
||||||
IsAuthenticated = false;
|
IsAuthenticated = false;
|
||||||
ConnectedPlayerName = null;
|
ConnectedPlayerName = null;
|
||||||
|
ConnectedPlayerSubject = null;
|
||||||
|
AssignedWhiteSubject = null;
|
||||||
|
AssignedBlackSubject = null;
|
||||||
ResetSocialState();
|
ResetSocialState();
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
return;
|
return;
|
||||||
@@ -607,11 +642,20 @@
|
|||||||
|
|
||||||
await Store.EnsureLoadedAsync();
|
await Store.EnsureLoadedAsync();
|
||||||
var match = MatchEngine.CreateMatch(Form.ToMatchConfig());
|
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);
|
await Realtime.EnsureJoinedPlaySessionAsync(match.CollaborationSessionId);
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
match.WhiteSubject = NormalizeOptional(AssignedWhiteSubject);
|
||||||
|
match.BlackSubject = NormalizeOptional(AssignedBlackSubject);
|
||||||
|
}
|
||||||
|
|
||||||
Store.SetCurrent(match);
|
Store.SetCurrent(match);
|
||||||
|
|
||||||
@@ -632,6 +676,8 @@
|
|||||||
private void LoadDemo()
|
private void LoadDemo()
|
||||||
{
|
{
|
||||||
Form = SetupFormModel.CreateDemo();
|
Form = SetupFormModel.CreateDemo();
|
||||||
|
AssignedWhiteSubject = null;
|
||||||
|
AssignedBlackSubject = null;
|
||||||
SetupError = null;
|
SetupError = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -758,6 +804,8 @@
|
|||||||
|
|
||||||
Form.WhiteName = session.WhiteName;
|
Form.WhiteName = session.WhiteName;
|
||||||
Form.BlackName = session.BlackName;
|
Form.BlackName = session.BlackName;
|
||||||
|
AssignedWhiteSubject = NormalizeOptional(session.WhiteSubject);
|
||||||
|
AssignedBlackSubject = NormalizeOptional(session.BlackSubject);
|
||||||
SetupError = null;
|
SetupError = null;
|
||||||
_appliedActiveSessionId = session.SessionId;
|
_appliedActiveSessionId = session.SessionId;
|
||||||
}
|
}
|
||||||
@@ -771,10 +819,12 @@
|
|||||||
|
|
||||||
var connectedName = ConnectedPlayerName!;
|
var connectedName = ConnectedPlayerName!;
|
||||||
Form.WhiteName = connectedName;
|
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";
|
Form.BlackName = "Noir";
|
||||||
|
AssignedBlackSubject = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
SetupError = null;
|
SetupError = null;
|
||||||
@@ -789,10 +839,12 @@
|
|||||||
|
|
||||||
var connectedName = ConnectedPlayerName!;
|
var connectedName = ConnectedPlayerName!;
|
||||||
Form.BlackName = connectedName;
|
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";
|
Form.WhiteName = "Blanc";
|
||||||
|
AssignedWhiteSubject = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
SetupError = null;
|
SetupError = null;
|
||||||
@@ -824,6 +876,8 @@
|
|||||||
{
|
{
|
||||||
Form.WhiteName = snapshot.Match.Config.WhiteName;
|
Form.WhiteName = snapshot.Match.Config.WhiteName;
|
||||||
Form.BlackName = snapshot.Match.Config.BlackName;
|
Form.BlackName = snapshot.Match.Config.BlackName;
|
||||||
|
AssignedWhiteSubject = NormalizeOptional(snapshot.Match.WhiteSubject);
|
||||||
|
AssignedBlackSubject = NormalizeOptional(snapshot.Match.BlackSubject);
|
||||||
SetupError = null;
|
SetupError = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -885,9 +939,16 @@
|
|||||||
user.FindFirst("preferred_username")?.Value,
|
user.FindFirst("preferred_username")?.Value,
|
||||||
user.FindFirst(ClaimTypes.Email)?.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)
|
private static string? FirstNonEmpty(params string?[] candidates)
|
||||||
=> candidates.FirstOrDefault(candidate => !string.IsNullOrWhiteSpace(candidate));
|
=> candidates.FirstOrDefault(candidate => !string.IsNullOrWhiteSpace(candidate));
|
||||||
|
|
||||||
|
private static string? NormalizeOptional(string? value)
|
||||||
|
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
AuthenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged;
|
AuthenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
@inject MatchStore Store
|
@inject MatchStore Store
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject SocialRealtimeService Realtime
|
@inject SocialRealtimeService Realtime
|
||||||
|
@inject MatchStatsService MatchStats
|
||||||
|
|
||||||
<PageTitle>ChessCubing Arena | Phase Chrono</PageTitle>
|
<PageTitle>ChessCubing Arena | Phase Chrono</PageTitle>
|
||||||
<PageBody Page="chrono" BodyClass="@ChronoBodyClass" />
|
<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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await EnsureResultReportedAsync();
|
||||||
|
|
||||||
_tickerCancellation = new CancellationTokenSource();
|
_tickerCancellation = new CancellationTokenSource();
|
||||||
_ = RunTickerAsync(_tickerCancellation.Token);
|
_ = RunTickerAsync(_tickerCancellation.Token);
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
@@ -221,6 +224,7 @@ else if (Match is not null && summary is not null && blackZone is not null && wh
|
|||||||
private async Task HandleRealtimeChangedAsync()
|
private async Task HandleRealtimeChangedAsync()
|
||||||
{
|
{
|
||||||
await ApplyCollaborativeSyncAsync();
|
await ApplyCollaborativeSyncAsync();
|
||||||
|
await EnsureResultReportedAsync();
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,6 +423,7 @@ else if (Match is not null && summary is not null && blackZone is not null && wh
|
|||||||
|
|
||||||
private async Task PersistAndRouteAsync()
|
private async Task PersistAndRouteAsync()
|
||||||
{
|
{
|
||||||
|
await EnsureResultReportedAsync();
|
||||||
Store.MarkDirty();
|
Store.MarkDirty();
|
||||||
await Store.SaveAsync();
|
await Store.SaveAsync();
|
||||||
|
|
||||||
@@ -437,6 +442,20 @@ else if (Match is not null && summary is not null && blackZone is not null && wh
|
|||||||
StateHasChanged();
|
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()
|
private ChronoSummaryView BuildSummary()
|
||||||
{
|
{
|
||||||
var match = Match!;
|
var match = Match!;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
@using System.ComponentModel.DataAnnotations
|
@using System.ComponentModel.DataAnnotations
|
||||||
@using System.Net
|
@using System.Net
|
||||||
@using System.Net.Http.Json
|
@using System.Net.Http.Json
|
||||||
|
@using ChessCubing.App.Models.Stats
|
||||||
@using ChessCubing.App.Models.Users
|
@using ChessCubing.App.Models.Users
|
||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||||
@@ -150,6 +151,116 @@
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</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">
|
<div class="callout user-profile-bio">
|
||||||
<span class="micro-label">Bio</span>
|
<span class="micro-label">Bio</span>
|
||||||
<p>@(Profile.Bio ?? "Aucune bio enregistree pour le moment.")</p>
|
<p>@(Profile.Bio ?? "Aucune bio enregistree pour le moment.")</p>
|
||||||
@@ -455,6 +566,7 @@
|
|||||||
private readonly UserProfileFormModel Form = new();
|
private readonly UserProfileFormModel Form = new();
|
||||||
|
|
||||||
private UserProfileResponse? Profile;
|
private UserProfileResponse? Profile;
|
||||||
|
private UserStatsResponse? Stats;
|
||||||
private SocialOverviewResponse? SocialOverview;
|
private SocialOverviewResponse? SocialOverview;
|
||||||
private SocialSearchUserResponse[] SearchResults = [];
|
private SocialSearchUserResponse[] SearchResults = [];
|
||||||
private bool IsAuthenticated;
|
private bool IsAuthenticated;
|
||||||
@@ -464,6 +576,7 @@
|
|||||||
private bool IsSearching;
|
private bool IsSearching;
|
||||||
private int _knownSocialVersion;
|
private int _knownSocialVersion;
|
||||||
private string? LoadError;
|
private string? LoadError;
|
||||||
|
private string? StatsLoadError;
|
||||||
private string? SaveError;
|
private string? SaveError;
|
||||||
private string? SaveMessage;
|
private string? SaveMessage;
|
||||||
private string? SocialLoadError;
|
private string? SocialLoadError;
|
||||||
@@ -486,6 +599,21 @@
|
|||||||
? "Le serveur verifie la fiche utilisateur et les relations sociales associees a ce compte."
|
? "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.";
|
: $"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 FriendCountLabel => $"{SocialOverview?.Friends.Length ?? 0} ami(s)";
|
||||||
|
|
||||||
private string ReceivedCountLabel => $"{SocialOverview?.ReceivedInvitations.Length ?? 0} recue(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.");
|
LoadError = await ReadErrorAsync(response, "Le profil utilisateur n'a pas pu etre charge.");
|
||||||
Profile = null;
|
Profile = null;
|
||||||
|
ResetStatsState();
|
||||||
ResetSocialState();
|
ResetSocialState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -569,23 +698,27 @@
|
|||||||
if (Profile is null)
|
if (Profile is null)
|
||||||
{
|
{
|
||||||
LoadError = "Le serveur a retourne une reponse vide.";
|
LoadError = "Le serveur a retourne une reponse vide.";
|
||||||
|
ResetStatsState();
|
||||||
ResetSocialState();
|
ResetSocialState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
FillForm(Profile);
|
FillForm(Profile);
|
||||||
|
await LoadStatsAsync();
|
||||||
await LoadSocialOverviewAsync();
|
await LoadSocialOverviewAsync();
|
||||||
}
|
}
|
||||||
catch (HttpRequestException)
|
catch (HttpRequestException)
|
||||||
{
|
{
|
||||||
LoadError = "Le service utilisateur est temporairement indisponible.";
|
LoadError = "Le service utilisateur est temporairement indisponible.";
|
||||||
Profile = null;
|
Profile = null;
|
||||||
|
ResetStatsState();
|
||||||
ResetSocialState();
|
ResetSocialState();
|
||||||
}
|
}
|
||||||
catch (TaskCanceledException)
|
catch (TaskCanceledException)
|
||||||
{
|
{
|
||||||
LoadError = "La reponse du service utilisateur a pris trop de temps.";
|
LoadError = "La reponse du service utilisateur a pris trop de temps.";
|
||||||
Profile = null;
|
Profile = null;
|
||||||
|
ResetStatsState();
|
||||||
ResetSocialState();
|
ResetSocialState();
|
||||||
}
|
}
|
||||||
finally
|
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()
|
private async Task LoadSocialOverviewAsync()
|
||||||
{
|
{
|
||||||
SocialLoadError = null;
|
SocialLoadError = null;
|
||||||
@@ -849,9 +1011,16 @@
|
|||||||
{
|
{
|
||||||
IsAuthenticated = false;
|
IsAuthenticated = false;
|
||||||
Profile = null;
|
Profile = null;
|
||||||
|
ResetStatsState();
|
||||||
Form.Reset();
|
Form.Reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ResetStatsState()
|
||||||
|
{
|
||||||
|
Stats = null;
|
||||||
|
StatsLoadError = null;
|
||||||
|
}
|
||||||
|
|
||||||
private void ResetSocialState()
|
private void ResetSocialState()
|
||||||
{
|
{
|
||||||
SocialOverview = null;
|
SocialOverview = null;
|
||||||
@@ -920,6 +1089,83 @@
|
|||||||
private static string FormatDate(DateTime value)
|
private static string FormatDate(DateTime value)
|
||||||
=> value.ToLocalTime().ToString("dd MMM yyyy 'a' HH:mm", CultureInfo.GetCultureInfo("fr-FR"));
|
=> 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()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
AuthenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged;
|
AuthenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged;
|
||||||
|
|||||||
@@ -16,5 +16,6 @@ builder.Services.AddScoped<BrowserBridge>();
|
|||||||
builder.Services.AddScoped<UserSession>();
|
builder.Services.AddScoped<UserSession>();
|
||||||
builder.Services.AddScoped<MatchStore>();
|
builder.Services.AddScoped<MatchStore>();
|
||||||
builder.Services.AddScoped<SocialRealtimeService>();
|
builder.Services.AddScoped<SocialRealtimeService>();
|
||||||
|
builder.Services.AddScoped<MatchStatsService>();
|
||||||
|
|
||||||
await builder.Build().RunAsync();
|
await builder.Build().RunAsync();
|
||||||
|
|||||||
@@ -54,11 +54,14 @@ public static class MatchEngine
|
|||||||
var quota = Presets[config.Preset].Quota;
|
var quota = Presets[config.Preset].Quota;
|
||||||
var match = new MatchState
|
var match = new MatchState
|
||||||
{
|
{
|
||||||
SchemaVersion = 3,
|
SchemaVersion = 4,
|
||||||
|
MatchId = Guid.NewGuid().ToString("N"),
|
||||||
Config = config,
|
Config = config,
|
||||||
Phase = PhaseBlock,
|
Phase = PhaseBlock,
|
||||||
Running = false,
|
Running = false,
|
||||||
LastTickAt = null,
|
LastTickAt = null,
|
||||||
|
WhiteSubject = null,
|
||||||
|
BlackSubject = null,
|
||||||
BlockNumber = 1,
|
BlockNumber = 1,
|
||||||
CurrentTurn = ColorWhite,
|
CurrentTurn = ColorWhite,
|
||||||
BlockRemainingMs = config.BlockDurationMs,
|
BlockRemainingMs = config.BlockDurationMs,
|
||||||
@@ -76,6 +79,7 @@ public static class MatchEngine
|
|||||||
AwaitingBlockClosure = false,
|
AwaitingBlockClosure = false,
|
||||||
ClosureReason = string.Empty,
|
ClosureReason = string.Empty,
|
||||||
Result = null,
|
Result = null,
|
||||||
|
ResultRecordedUtc = null,
|
||||||
Cube = CreateCubeState(),
|
Cube = CreateCubeState(),
|
||||||
DoubleCoup = new DoubleCoupState
|
DoubleCoup = new DoubleCoupState
|
||||||
{
|
{
|
||||||
@@ -115,13 +119,19 @@ public static class MatchEngine
|
|||||||
};
|
};
|
||||||
storedMatch.Moves ??= new PlayerIntPair();
|
storedMatch.Moves ??= new PlayerIntPair();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(storedMatch.MatchId))
|
||||||
|
{
|
||||||
|
storedMatch.MatchId = Guid.NewGuid().ToString("N");
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
var blockDurationMs = GetBlockDurationMs(storedMatch);
|
var blockDurationMs = GetBlockDurationMs(storedMatch);
|
||||||
var moveLimitMs = GetMoveLimitMs(storedMatch);
|
var moveLimitMs = GetMoveLimitMs(storedMatch);
|
||||||
var timeInitialMs = GetTimeInitialMs(storedMatch);
|
var timeInitialMs = GetTimeInitialMs(storedMatch);
|
||||||
|
|
||||||
if (storedMatch.SchemaVersion != 3)
|
if (storedMatch.SchemaVersion != 4)
|
||||||
{
|
{
|
||||||
storedMatch.SchemaVersion = 3;
|
storedMatch.SchemaVersion = 4;
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -826,7 +836,7 @@ public static class MatchEngine
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static bool IsSupportedSchemaVersion(int version)
|
public static bool IsSupportedSchemaVersion(int version)
|
||||||
=> version is 2 or 3;
|
=> version is 2 or 3 or 4;
|
||||||
|
|
||||||
public static long GetBlockDurationMs(object? matchOrConfig)
|
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.Auth;
|
||||||
using ChessCubing.Server.Data;
|
using ChessCubing.Server.Data;
|
||||||
using ChessCubing.Server.Social;
|
using ChessCubing.Server.Social;
|
||||||
|
using ChessCubing.Server.Stats;
|
||||||
using ChessCubing.Server.Users;
|
using ChessCubing.Server.Users;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
@@ -73,6 +74,7 @@ builder.Services.AddSignalR();
|
|||||||
builder.Services.AddHttpClient<KeycloakAuthService>();
|
builder.Services.AddHttpClient<KeycloakAuthService>();
|
||||||
builder.Services.AddSingleton<MySqlUserProfileStore>();
|
builder.Services.AddSingleton<MySqlUserProfileStore>();
|
||||||
builder.Services.AddSingleton<MySqlSocialStore>();
|
builder.Services.AddSingleton<MySqlSocialStore>();
|
||||||
|
builder.Services.AddSingleton<MySqlPlayerStatsStore>();
|
||||||
builder.Services.AddSingleton<ConnectedUserTracker>();
|
builder.Services.AddSingleton<ConnectedUserTracker>();
|
||||||
builder.Services.AddSingleton<PlayInviteCoordinator>();
|
builder.Services.AddSingleton<PlayInviteCoordinator>();
|
||||||
builder.Services.AddSingleton<CollaborativeMatchCoordinator>();
|
builder.Services.AddSingleton<CollaborativeMatchCoordinator>();
|
||||||
@@ -83,8 +85,10 @@ await using (var scope = app.Services.CreateAsyncScope())
|
|||||||
{
|
{
|
||||||
var profileStore = scope.ServiceProvider.GetRequiredService<MySqlUserProfileStore>();
|
var profileStore = scope.ServiceProvider.GetRequiredService<MySqlUserProfileStore>();
|
||||||
var socialStore = scope.ServiceProvider.GetRequiredService<MySqlSocialStore>();
|
var socialStore = scope.ServiceProvider.GetRequiredService<MySqlSocialStore>();
|
||||||
|
var statsStore = scope.ServiceProvider.GetRequiredService<MySqlPlayerStatsStore>();
|
||||||
await profileStore.InitializeAsync(CancellationToken.None);
|
await profileStore.InitializeAsync(CancellationToken.None);
|
||||||
await socialStore.InitializeAsync(CancellationToken.None);
|
await socialStore.InitializeAsync(CancellationToken.None);
|
||||||
|
await statsStore.InitializeAsync(CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
@@ -133,6 +137,44 @@ app.MapPut("/api/users/me", async Task<IResult> (
|
|||||||
}
|
}
|
||||||
}).RequireAuthorization();
|
}).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")
|
var socialGroup = app.MapGroup("/api/social")
|
||||||
.RequireAuthorization();
|
.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.
|
- `400 Bad Request` si une longueur maximale est depassee.
|
||||||
- `401 Unauthorized` sans session.
|
- `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
|
## Recherche de joueurs et relations sociales
|
||||||
|
|
||||||
Ces routes necessitent toutes une session authentifiee.
|
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;
|
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 {
|
.admin-hero-stats {
|
||||||
margin-top: 0.8rem;
|
margin-top: 0.8rem;
|
||||||
}
|
}
|
||||||
@@ -2066,6 +2122,7 @@ body.site-menu-hidden .site-menu-shell {
|
|||||||
|
|
||||||
.admin-edit-summary-grid,
|
.admin-edit-summary-grid,
|
||||||
.user-profile-summary-grid,
|
.user-profile-summary-grid,
|
||||||
|
.user-stats-grid,
|
||||||
.profile-meta-grid,
|
.profile-meta-grid,
|
||||||
.profile-form-grid,
|
.profile-form-grid,
|
||||||
.admin-toggle-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) {
|
@media (max-width: 900px) {
|
||||||
:root {
|
:root {
|
||||||
--site-menu-offset: calc(6.8rem + var(--safe-top));
|
--site-menu-offset: calc(6.8rem + var(--safe-top));
|
||||||
|
|||||||
Reference in New Issue
Block a user