Ajoute l Elo et les statistiques de parties
This commit is contained in:
@@ -45,11 +45,20 @@ public sealed class MatchConfig
|
||||
public sealed class MatchState
|
||||
{
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public int SchemaVersion { get; set; } = 3;
|
||||
public int SchemaVersion { get; set; } = 4;
|
||||
|
||||
[JsonPropertyName("matchId")]
|
||||
public string MatchId { get; set; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
[JsonPropertyName("collaborationSessionId")]
|
||||
public string? CollaborationSessionId { get; set; }
|
||||
|
||||
[JsonPropertyName("whiteSubject")]
|
||||
public string? WhiteSubject { get; set; }
|
||||
|
||||
[JsonPropertyName("blackSubject")]
|
||||
public string? BlackSubject { get; set; }
|
||||
|
||||
[JsonPropertyName("config")]
|
||||
public MatchConfig Config { get; set; } = new();
|
||||
|
||||
@@ -95,6 +104,9 @@ public sealed class MatchState
|
||||
[JsonPropertyName("result")]
|
||||
public string? Result { get; set; }
|
||||
|
||||
[JsonPropertyName("resultRecordedUtc")]
|
||||
public DateTime? ResultRecordedUtc { get; set; }
|
||||
|
||||
[JsonPropertyName("cube")]
|
||||
public CubeState Cube { get; set; } = new();
|
||||
|
||||
|
||||
138
ChessCubing.App/Models/Stats/UserStatsModels.cs
Normal file
138
ChessCubing.App/Models/Stats/UserStatsModels.cs
Normal file
@@ -0,0 +1,138 @@
|
||||
namespace ChessCubing.App.Models.Stats;
|
||||
|
||||
public sealed class UserStatsResponse
|
||||
{
|
||||
public string Subject { get; init; } = string.Empty;
|
||||
|
||||
public int CurrentElo { get; init; }
|
||||
|
||||
public int RankedGames { get; init; }
|
||||
|
||||
public int CasualGames { get; init; }
|
||||
|
||||
public int Wins { get; init; }
|
||||
|
||||
public int Losses { get; init; }
|
||||
|
||||
public int StoppedGames { get; init; }
|
||||
|
||||
public int WhiteWins { get; init; }
|
||||
|
||||
public int BlackWins { get; init; }
|
||||
|
||||
public int WhiteLosses { get; init; }
|
||||
|
||||
public int BlackLosses { get; init; }
|
||||
|
||||
public int TotalMoves { get; init; }
|
||||
|
||||
public int TotalCubeRounds { get; init; }
|
||||
|
||||
public long? BestCubeTimeMs { get; init; }
|
||||
|
||||
public long? AverageCubeTimeMs { get; init; }
|
||||
|
||||
public DateTime? LastMatchUtc { get; init; }
|
||||
|
||||
public UserRecentMatchResponse[] RecentMatches { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class UserRecentMatchResponse
|
||||
{
|
||||
public string MatchId { get; init; } = string.Empty;
|
||||
|
||||
public DateTime CompletedUtc { get; init; }
|
||||
|
||||
public string Result { get; init; } = string.Empty;
|
||||
|
||||
public string Mode { get; init; } = string.Empty;
|
||||
|
||||
public string Preset { get; init; } = string.Empty;
|
||||
|
||||
public string? MatchLabel { get; init; }
|
||||
|
||||
public string PlayerColor { get; init; } = string.Empty;
|
||||
|
||||
public string PlayerName { get; init; } = string.Empty;
|
||||
|
||||
public string OpponentName { get; init; } = string.Empty;
|
||||
|
||||
public string? OpponentSubject { get; init; }
|
||||
|
||||
public bool IsRanked { get; init; }
|
||||
|
||||
public bool IsWin { get; init; }
|
||||
|
||||
public bool IsLoss { get; init; }
|
||||
|
||||
public int PlayerMoves { get; init; }
|
||||
|
||||
public int OpponentMoves { get; init; }
|
||||
|
||||
public int CubeRounds { get; init; }
|
||||
|
||||
public long? PlayerBestCubeTimeMs { get; init; }
|
||||
|
||||
public long? PlayerAverageCubeTimeMs { get; init; }
|
||||
|
||||
public int? EloBefore { get; init; }
|
||||
|
||||
public int? EloAfter { get; init; }
|
||||
|
||||
public int? EloDelta { get; init; }
|
||||
}
|
||||
|
||||
public sealed class ReportCompletedMatchRequest
|
||||
{
|
||||
public string MatchId { get; init; } = string.Empty;
|
||||
|
||||
public string? CollaborationSessionId { get; init; }
|
||||
|
||||
public string? WhiteSubject { get; init; }
|
||||
|
||||
public string WhiteName { get; init; } = string.Empty;
|
||||
|
||||
public string? BlackSubject { get; init; }
|
||||
|
||||
public string BlackName { get; init; } = string.Empty;
|
||||
|
||||
public string Result { get; init; } = string.Empty;
|
||||
|
||||
public string Mode { get; init; } = string.Empty;
|
||||
|
||||
public string Preset { get; init; } = string.Empty;
|
||||
|
||||
public string? MatchLabel { get; init; }
|
||||
|
||||
public int BlockNumber { get; init; }
|
||||
|
||||
public int WhiteMoves { get; init; }
|
||||
|
||||
public int BlackMoves { get; init; }
|
||||
|
||||
public ReportCompletedCubeRound[] CubeRounds { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class ReportCompletedCubeRound
|
||||
{
|
||||
public int BlockNumber { get; init; }
|
||||
|
||||
public int? Number { get; init; }
|
||||
|
||||
public long? White { get; init; }
|
||||
|
||||
public long? Black { get; init; }
|
||||
}
|
||||
|
||||
public sealed class ReportCompletedMatchResponse
|
||||
{
|
||||
public bool Recorded { get; init; }
|
||||
|
||||
public bool IsDuplicate { get; init; }
|
||||
|
||||
public bool IsRanked { get; init; }
|
||||
|
||||
public int? WhiteEloAfter { get; init; }
|
||||
|
||||
public int? BlackEloAfter { get; init; }
|
||||
}
|
||||
@@ -160,7 +160,7 @@
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<input @bind="WhitePlayerName" @bind:event="oninput" name="whiteName" type="text" maxlength="40" placeholder="Blanc" />
|
||||
<input @bind="WhitePlayerName" @bind:event="oninput" name="whiteName" type="text" maxlength="40" placeholder="Blanc" disabled="@HasLockedPlaySession" />
|
||||
</label>
|
||||
|
||||
<label class="field player-name-field">
|
||||
@@ -178,7 +178,7 @@
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<input @bind="BlackPlayerName" @bind:event="oninput" name="blackName" type="text" maxlength="40" placeholder="Noir" />
|
||||
<input @bind="BlackPlayerName" @bind:event="oninput" name="blackName" type="text" maxlength="40" placeholder="Noir" disabled="@HasLockedPlaySession" />
|
||||
</label>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(SetupError))
|
||||
@@ -297,6 +297,7 @@
|
||||
<span>@TimingText</span>
|
||||
<span>@TimeImpact</span>
|
||||
<span>@QuotaText</span>
|
||||
<span>@StatsEligibilityText</span>
|
||||
</div>
|
||||
|
||||
<div class="setup-actions span-2">
|
||||
@@ -386,6 +387,9 @@
|
||||
private long _knownCollaborativeRevision;
|
||||
private string? _appliedActiveSessionId;
|
||||
private string? ConnectedPlayerName;
|
||||
private string? ConnectedPlayerSubject;
|
||||
private string? AssignedWhiteSubject;
|
||||
private string? AssignedBlackSubject;
|
||||
private string? SetupError;
|
||||
private string? SocialLoadError;
|
||||
private string? InviteActionError;
|
||||
@@ -395,12 +399,21 @@
|
||||
|
||||
private string SetupBodyClass => UsesMoveLimit ? string.Empty : "time-setup-mode";
|
||||
private bool CanUseConnectedPlayerName => !string.IsNullOrWhiteSpace(ConnectedPlayerName);
|
||||
private bool HasLockedPlaySession => Realtime.ActivePlaySession is not null;
|
||||
private string WhitePlayerName
|
||||
{
|
||||
get => Form.WhiteName;
|
||||
set
|
||||
{
|
||||
Form.WhiteName = value;
|
||||
if (!HasLockedPlaySession &&
|
||||
!string.IsNullOrWhiteSpace(AssignedWhiteSubject) &&
|
||||
AssignedWhiteSubject == ConnectedPlayerSubject &&
|
||||
!SamePlayerName(value, ConnectedPlayerName))
|
||||
{
|
||||
AssignedWhiteSubject = null;
|
||||
}
|
||||
|
||||
SetupError = null;
|
||||
}
|
||||
}
|
||||
@@ -411,6 +424,14 @@
|
||||
set
|
||||
{
|
||||
Form.BlackName = value;
|
||||
if (!HasLockedPlaySession &&
|
||||
!string.IsNullOrWhiteSpace(AssignedBlackSubject) &&
|
||||
AssignedBlackSubject == ConnectedPlayerSubject &&
|
||||
!SamePlayerName(value, ConnectedPlayerName))
|
||||
{
|
||||
AssignedBlackSubject = null;
|
||||
}
|
||||
|
||||
SetupError = null;
|
||||
}
|
||||
}
|
||||
@@ -449,6 +470,13 @@
|
||||
? $"Quota actif : {CurrentPreset.Quota} coups par joueur."
|
||||
: $"Quota actif : {CurrentPreset.Quota} coups par joueur et par Block.";
|
||||
|
||||
private string StatsEligibilityText =>
|
||||
Realtime.ActivePlaySession is not null
|
||||
? "Match classe : les deux comptes sont identifies, la partie mettra a jour l'Elo et les statistiques."
|
||||
: !string.IsNullOrWhiteSpace(AssignedWhiteSubject) || !string.IsNullOrWhiteSpace(AssignedBlackSubject)
|
||||
? "Match amical : la partie sera enregistree pour le ou les comptes lies, sans impact Elo."
|
||||
: "Match local uniquement : aucun compte joueur n'est lie des deux cotes, rien ne sera ajoute aux statistiques serveur.";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
AuthenticationStateProvider.AuthenticationStateChanged += HandleAuthenticationStateChanged;
|
||||
@@ -508,6 +536,9 @@
|
||||
{
|
||||
IsAuthenticated = false;
|
||||
ConnectedPlayerName = null;
|
||||
ConnectedPlayerSubject = null;
|
||||
AssignedWhiteSubject = null;
|
||||
AssignedBlackSubject = null;
|
||||
ResetSocialState();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
return;
|
||||
@@ -515,6 +546,7 @@
|
||||
|
||||
IsAuthenticated = true;
|
||||
fallbackName = BuildConnectedPlayerFallback(user);
|
||||
ConnectedPlayerSubject = ResolveSubject(user);
|
||||
|
||||
var response = await Http.GetAsync("api/users/me");
|
||||
if (!response.IsSuccessStatusCode)
|
||||
@@ -523,6 +555,9 @@
|
||||
{
|
||||
IsAuthenticated = false;
|
||||
ConnectedPlayerName = null;
|
||||
ConnectedPlayerSubject = null;
|
||||
AssignedWhiteSubject = null;
|
||||
AssignedBlackSubject = null;
|
||||
ResetSocialState();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
return;
|
||||
@@ -607,11 +642,20 @@
|
||||
|
||||
await Store.EnsureLoadedAsync();
|
||||
var match = MatchEngine.CreateMatch(Form.ToMatchConfig());
|
||||
if (!string.IsNullOrWhiteSpace(Realtime.ActivePlaySession?.SessionId))
|
||||
if (Realtime.ActivePlaySession is { } session)
|
||||
{
|
||||
match.CollaborationSessionId = Realtime.ActivePlaySession.SessionId;
|
||||
match.CollaborationSessionId = session.SessionId;
|
||||
match.WhiteSubject = NormalizeOptional(session.WhiteSubject);
|
||||
match.BlackSubject = NormalizeOptional(session.BlackSubject);
|
||||
match.Config.WhiteName = session.WhiteName;
|
||||
match.Config.BlackName = session.BlackName;
|
||||
await Realtime.EnsureJoinedPlaySessionAsync(match.CollaborationSessionId);
|
||||
}
|
||||
else
|
||||
{
|
||||
match.WhiteSubject = NormalizeOptional(AssignedWhiteSubject);
|
||||
match.BlackSubject = NormalizeOptional(AssignedBlackSubject);
|
||||
}
|
||||
|
||||
Store.SetCurrent(match);
|
||||
|
||||
@@ -632,6 +676,8 @@
|
||||
private void LoadDemo()
|
||||
{
|
||||
Form = SetupFormModel.CreateDemo();
|
||||
AssignedWhiteSubject = null;
|
||||
AssignedBlackSubject = null;
|
||||
SetupError = null;
|
||||
}
|
||||
|
||||
@@ -758,6 +804,8 @@
|
||||
|
||||
Form.WhiteName = session.WhiteName;
|
||||
Form.BlackName = session.BlackName;
|
||||
AssignedWhiteSubject = NormalizeOptional(session.WhiteSubject);
|
||||
AssignedBlackSubject = NormalizeOptional(session.BlackSubject);
|
||||
SetupError = null;
|
||||
_appliedActiveSessionId = session.SessionId;
|
||||
}
|
||||
@@ -771,10 +819,12 @@
|
||||
|
||||
var connectedName = ConnectedPlayerName!;
|
||||
Form.WhiteName = connectedName;
|
||||
AssignedWhiteSubject = NormalizeOptional(ConnectedPlayerSubject);
|
||||
|
||||
if (SamePlayerName(Form.BlackName, connectedName))
|
||||
if (SamePlayerName(Form.BlackName, connectedName) || string.Equals(AssignedBlackSubject, ConnectedPlayerSubject, StringComparison.Ordinal))
|
||||
{
|
||||
Form.BlackName = "Noir";
|
||||
AssignedBlackSubject = null;
|
||||
}
|
||||
|
||||
SetupError = null;
|
||||
@@ -789,10 +839,12 @@
|
||||
|
||||
var connectedName = ConnectedPlayerName!;
|
||||
Form.BlackName = connectedName;
|
||||
AssignedBlackSubject = NormalizeOptional(ConnectedPlayerSubject);
|
||||
|
||||
if (SamePlayerName(Form.WhiteName, connectedName))
|
||||
if (SamePlayerName(Form.WhiteName, connectedName) || string.Equals(AssignedWhiteSubject, ConnectedPlayerSubject, StringComparison.Ordinal))
|
||||
{
|
||||
Form.WhiteName = "Blanc";
|
||||
AssignedWhiteSubject = null;
|
||||
}
|
||||
|
||||
SetupError = null;
|
||||
@@ -824,6 +876,8 @@
|
||||
{
|
||||
Form.WhiteName = snapshot.Match.Config.WhiteName;
|
||||
Form.BlackName = snapshot.Match.Config.BlackName;
|
||||
AssignedWhiteSubject = NormalizeOptional(snapshot.Match.WhiteSubject);
|
||||
AssignedBlackSubject = NormalizeOptional(snapshot.Match.BlackSubject);
|
||||
SetupError = null;
|
||||
}
|
||||
|
||||
@@ -885,9 +939,16 @@
|
||||
user.FindFirst("preferred_username")?.Value,
|
||||
user.FindFirst(ClaimTypes.Email)?.Value);
|
||||
|
||||
private static string? ResolveSubject(ClaimsPrincipal user)
|
||||
=> user.FindFirst("sub")?.Value
|
||||
?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
|
||||
private static string? FirstNonEmpty(params string?[] candidates)
|
||||
=> candidates.FirstOrDefault(candidate => !string.IsNullOrWhiteSpace(candidate));
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
AuthenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
@inject MatchStore Store
|
||||
@inject NavigationManager Navigation
|
||||
@inject SocialRealtimeService Realtime
|
||||
@inject MatchStatsService MatchStats
|
||||
|
||||
<PageTitle>ChessCubing Arena | Phase Chrono</PageTitle>
|
||||
<PageBody Page="chrono" BodyClass="@ChronoBodyClass" />
|
||||
@@ -198,6 +199,8 @@ else if (Match is not null && summary is not null && blackZone is not null && wh
|
||||
return;
|
||||
}
|
||||
|
||||
await EnsureResultReportedAsync();
|
||||
|
||||
_tickerCancellation = new CancellationTokenSource();
|
||||
_ = RunTickerAsync(_tickerCancellation.Token);
|
||||
StateHasChanged();
|
||||
@@ -221,6 +224,7 @@ else if (Match is not null && summary is not null && blackZone is not null && wh
|
||||
private async Task HandleRealtimeChangedAsync()
|
||||
{
|
||||
await ApplyCollaborativeSyncAsync();
|
||||
await EnsureResultReportedAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
@@ -419,6 +423,7 @@ else if (Match is not null && summary is not null && blackZone is not null && wh
|
||||
|
||||
private async Task PersistAndRouteAsync()
|
||||
{
|
||||
await EnsureResultReportedAsync();
|
||||
Store.MarkDirty();
|
||||
await Store.SaveAsync();
|
||||
|
||||
@@ -437,6 +442,20 @@ else if (Match is not null && summary is not null && blackZone is not null && wh
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task EnsureResultReportedAsync()
|
||||
{
|
||||
var match = Match;
|
||||
if (match is null || string.IsNullOrWhiteSpace(match.Result) || match.ResultRecordedUtc is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (await MatchStats.TryReportCompletedMatchAsync(match))
|
||||
{
|
||||
Store.MarkDirty();
|
||||
}
|
||||
}
|
||||
|
||||
private ChronoSummaryView BuildSummary()
|
||||
{
|
||||
var match = Match!;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using System.Net
|
||||
@using System.Net.Http.Json
|
||||
@using ChessCubing.App.Models.Stats
|
||||
@using ChessCubing.App.Models.Users
|
||||
@implements IDisposable
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
@@ -150,6 +151,116 @@
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="user-stats-block">
|
||||
<div class="section-heading user-profile-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Classement joueur</p>
|
||||
<h3>Elo et statistiques de partie</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(StatsLoadError))
|
||||
{
|
||||
<p class="profile-feedback error">@StatsLoadError</p>
|
||||
}
|
||||
else if (Stats is not null)
|
||||
{
|
||||
<div class="user-stats-grid">
|
||||
<article class="user-summary-card user-stat-emphasis">
|
||||
<span class="micro-label">Elo actuel</span>
|
||||
<strong>@Stats.CurrentElo</strong>
|
||||
</article>
|
||||
<article class="user-summary-card">
|
||||
<span class="micro-label">Parties classees</span>
|
||||
<strong>@Stats.RankedGames</strong>
|
||||
</article>
|
||||
<article class="user-summary-card">
|
||||
<span class="micro-label">Parties amicales</span>
|
||||
<strong>@Stats.CasualGames</strong>
|
||||
</article>
|
||||
<article class="user-summary-card">
|
||||
<span class="micro-label">Victoires</span>
|
||||
<strong>@Stats.Wins</strong>
|
||||
</article>
|
||||
<article class="user-summary-card">
|
||||
<span class="micro-label">Defaites</span>
|
||||
<strong>@Stats.Losses</strong>
|
||||
</article>
|
||||
<article class="user-summary-card">
|
||||
<span class="micro-label">Parties stoppees</span>
|
||||
<strong>@Stats.StoppedGames</strong>
|
||||
</article>
|
||||
<article class="user-summary-card">
|
||||
<span class="micro-label">Taux de victoire</span>
|
||||
<strong>@StatsWinRateLabel</strong>
|
||||
</article>
|
||||
<article class="user-summary-card">
|
||||
<span class="micro-label">Coups joues</span>
|
||||
<strong>@Stats.TotalMoves</strong>
|
||||
</article>
|
||||
<article class="user-summary-card">
|
||||
<span class="micro-label">Rounds cube</span>
|
||||
<strong>@Stats.TotalCubeRounds</strong>
|
||||
</article>
|
||||
<article class="user-summary-card">
|
||||
<span class="micro-label">Meilleur cube</span>
|
||||
<strong>@FormatDurationShort(Stats.BestCubeTimeMs)</strong>
|
||||
</article>
|
||||
<article class="user-summary-card">
|
||||
<span class="micro-label">Moyenne cube</span>
|
||||
<strong>@FormatDurationShort(Stats.AverageCubeTimeMs)</strong>
|
||||
</article>
|
||||
<article class="user-summary-card">
|
||||
<span class="micro-label">Derniere partie</span>
|
||||
<strong>@FormatOptionalDate(Stats.LastMatchUtc)</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="user-recent-matches">
|
||||
<div class="social-card-head">
|
||||
<strong>Dernieres parties</strong>
|
||||
<span class="mini-chip admin-chip-outline">@Stats.RecentMatches.Length match(s)</span>
|
||||
</div>
|
||||
|
||||
@if (Stats.RecentMatches.Length > 0)
|
||||
{
|
||||
<div class="social-list">
|
||||
@foreach (var match in Stats.RecentMatches)
|
||||
{
|
||||
<article class="social-item recent-match-item">
|
||||
<div class="social-item-meta">
|
||||
<div class="social-item-title-row">
|
||||
<strong>@BuildRecentMatchTitle(match)</strong>
|
||||
<span class="@BuildRecentMatchBadgeClass(match)">@BuildRecentMatchBadge(match)</span>
|
||||
</div>
|
||||
<span class="social-item-subtitle">@BuildRecentMatchSubtitle(match)</span>
|
||||
<span class="social-item-caption">@BuildRecentMatchCaption(match)</span>
|
||||
</div>
|
||||
|
||||
<div class="social-item-actions recent-match-meta">
|
||||
@if (match.EloDelta is not null)
|
||||
{
|
||||
<span class="@BuildEloDeltaClass(match.EloDelta.Value)">
|
||||
Elo @BuildSignedNumber(match.EloDelta.Value)
|
||||
</span>
|
||||
}
|
||||
|
||||
<span class="mini-chip admin-chip-neutral">
|
||||
@match.PlayerMoves coup(s)
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="social-empty">Aucune partie enregistree pour le moment.</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="callout user-profile-bio">
|
||||
<span class="micro-label">Bio</span>
|
||||
<p>@(Profile.Bio ?? "Aucune bio enregistree pour le moment.")</p>
|
||||
@@ -455,6 +566,7 @@
|
||||
private readonly UserProfileFormModel Form = new();
|
||||
|
||||
private UserProfileResponse? Profile;
|
||||
private UserStatsResponse? Stats;
|
||||
private SocialOverviewResponse? SocialOverview;
|
||||
private SocialSearchUserResponse[] SearchResults = [];
|
||||
private bool IsAuthenticated;
|
||||
@@ -464,6 +576,7 @@
|
||||
private bool IsSearching;
|
||||
private int _knownSocialVersion;
|
||||
private string? LoadError;
|
||||
private string? StatsLoadError;
|
||||
private string? SaveError;
|
||||
private string? SaveMessage;
|
||||
private string? SocialLoadError;
|
||||
@@ -486,6 +599,21 @@
|
||||
? "Le serveur verifie la fiche utilisateur et les relations sociales associees a ce compte."
|
||||
: $"Compte lie a {Profile?.Username ?? "l'utilisateur connecte"} avec synchronisation sociale en direct.";
|
||||
|
||||
private string StatsWinRateLabel
|
||||
{
|
||||
get
|
||||
{
|
||||
var totalDecisiveGames = (Stats?.Wins ?? 0) + (Stats?.Losses ?? 0);
|
||||
if (totalDecisiveGames <= 0)
|
||||
{
|
||||
return "--";
|
||||
}
|
||||
|
||||
var winRate = ((double)(Stats?.Wins ?? 0) / totalDecisiveGames) * 100d;
|
||||
return $"{Math.Round(winRate, MidpointRounding.AwayFromZero):0}%";
|
||||
}
|
||||
}
|
||||
|
||||
private string FriendCountLabel => $"{SocialOverview?.Friends.Length ?? 0} ami(s)";
|
||||
|
||||
private string ReceivedCountLabel => $"{SocialOverview?.ReceivedInvitations.Length ?? 0} recue(s)";
|
||||
@@ -561,6 +689,7 @@
|
||||
{
|
||||
LoadError = await ReadErrorAsync(response, "Le profil utilisateur n'a pas pu etre charge.");
|
||||
Profile = null;
|
||||
ResetStatsState();
|
||||
ResetSocialState();
|
||||
return;
|
||||
}
|
||||
@@ -569,23 +698,27 @@
|
||||
if (Profile is null)
|
||||
{
|
||||
LoadError = "Le serveur a retourne une reponse vide.";
|
||||
ResetStatsState();
|
||||
ResetSocialState();
|
||||
return;
|
||||
}
|
||||
|
||||
FillForm(Profile);
|
||||
await LoadStatsAsync();
|
||||
await LoadSocialOverviewAsync();
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
LoadError = "Le service utilisateur est temporairement indisponible.";
|
||||
Profile = null;
|
||||
ResetStatsState();
|
||||
ResetSocialState();
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
LoadError = "La reponse du service utilisateur a pris trop de temps.";
|
||||
Profile = null;
|
||||
ResetStatsState();
|
||||
ResetSocialState();
|
||||
}
|
||||
finally
|
||||
@@ -595,6 +728,35 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadStatsAsync()
|
||||
{
|
||||
StatsLoadError = null;
|
||||
|
||||
try
|
||||
{
|
||||
var response = await Http.GetAsync("api/users/me/stats");
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
StatsLoadError = await ReadErrorAsync(response, "Les statistiques joueur n'ont pas pu etre chargees.");
|
||||
Stats = null;
|
||||
return;
|
||||
}
|
||||
|
||||
Stats = await response.Content.ReadFromJsonAsync<UserStatsResponse>();
|
||||
Stats ??= new UserStatsResponse();
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
StatsLoadError = "Le service de statistiques est temporairement indisponible.";
|
||||
Stats = null;
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
StatsLoadError = "Le chargement des statistiques a pris trop de temps.";
|
||||
Stats = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadSocialOverviewAsync()
|
||||
{
|
||||
SocialLoadError = null;
|
||||
@@ -849,9 +1011,16 @@
|
||||
{
|
||||
IsAuthenticated = false;
|
||||
Profile = null;
|
||||
ResetStatsState();
|
||||
Form.Reset();
|
||||
}
|
||||
|
||||
private void ResetStatsState()
|
||||
{
|
||||
Stats = null;
|
||||
StatsLoadError = null;
|
||||
}
|
||||
|
||||
private void ResetSocialState()
|
||||
{
|
||||
SocialOverview = null;
|
||||
@@ -920,6 +1089,83 @@
|
||||
private static string FormatDate(DateTime value)
|
||||
=> value.ToLocalTime().ToString("dd MMM yyyy 'a' HH:mm", CultureInfo.GetCultureInfo("fr-FR"));
|
||||
|
||||
private static string FormatOptionalDate(DateTime? value)
|
||||
=> value is null ? "--" : FormatDate(value.Value);
|
||||
|
||||
private static string FormatDurationShort(long? value)
|
||||
{
|
||||
if (value is null or <= 0)
|
||||
{
|
||||
return "--";
|
||||
}
|
||||
|
||||
return MatchEngine.FormatStopwatch(value.Value);
|
||||
}
|
||||
|
||||
private static string BuildRecentMatchTitle(UserRecentMatchResponse match)
|
||||
=> string.IsNullOrWhiteSpace(match.MatchLabel)
|
||||
? $"{match.PlayerName} vs {match.OpponentName}"
|
||||
: match.MatchLabel;
|
||||
|
||||
private static string BuildRecentMatchSubtitle(UserRecentMatchResponse match)
|
||||
=> $"{BuildModeLabel(match.Mode)} • {match.OpponentName} • cote {BuildColorLabel(match.PlayerColor)}";
|
||||
|
||||
private static string BuildRecentMatchCaption(UserRecentMatchResponse match)
|
||||
{
|
||||
var parts = new List<string>
|
||||
{
|
||||
$"Le {FormatDate(match.CompletedUtc)}",
|
||||
$"{match.CubeRounds} round(s) cube",
|
||||
};
|
||||
|
||||
if (match.PlayerAverageCubeTimeMs is > 0)
|
||||
{
|
||||
parts.Add($"moy. cube {FormatDurationShort(match.PlayerAverageCubeTimeMs)}");
|
||||
}
|
||||
|
||||
return string.Join(" • ", parts);
|
||||
}
|
||||
|
||||
private static string BuildRecentMatchBadge(UserRecentMatchResponse match)
|
||||
{
|
||||
if (match.IsWin)
|
||||
{
|
||||
return "Victoire";
|
||||
}
|
||||
|
||||
if (match.IsLoss)
|
||||
{
|
||||
return "Defaite";
|
||||
}
|
||||
|
||||
return "Arretee";
|
||||
}
|
||||
|
||||
private static string BuildRecentMatchBadgeClass(UserRecentMatchResponse match)
|
||||
=> match.IsWin
|
||||
? "presence-badge online"
|
||||
: match.IsLoss
|
||||
? "presence-badge recent-loss-badge"
|
||||
: "presence-badge";
|
||||
|
||||
private static string BuildColorLabel(string color)
|
||||
=> string.Equals(color, MatchEngine.ColorWhite, StringComparison.OrdinalIgnoreCase)
|
||||
? "blanc"
|
||||
: "noir";
|
||||
|
||||
private static string BuildModeLabel(string mode)
|
||||
=> MatchEngine.Modes.TryGetValue(mode, out var info)
|
||||
? info.Label
|
||||
: mode;
|
||||
|
||||
private static string BuildSignedNumber(int value)
|
||||
=> value > 0 ? $"+{value}" : value.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
private static string BuildEloDeltaClass(int value)
|
||||
=> value >= 0
|
||||
? "mini-chip user-elo-chip-positive"
|
||||
: "mini-chip user-elo-chip-negative";
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
AuthenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged;
|
||||
|
||||
@@ -16,5 +16,6 @@ builder.Services.AddScoped<BrowserBridge>();
|
||||
builder.Services.AddScoped<UserSession>();
|
||||
builder.Services.AddScoped<MatchStore>();
|
||||
builder.Services.AddScoped<SocialRealtimeService>();
|
||||
builder.Services.AddScoped<MatchStatsService>();
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
|
||||
@@ -54,11 +54,14 @@ public static class MatchEngine
|
||||
var quota = Presets[config.Preset].Quota;
|
||||
var match = new MatchState
|
||||
{
|
||||
SchemaVersion = 3,
|
||||
SchemaVersion = 4,
|
||||
MatchId = Guid.NewGuid().ToString("N"),
|
||||
Config = config,
|
||||
Phase = PhaseBlock,
|
||||
Running = false,
|
||||
LastTickAt = null,
|
||||
WhiteSubject = null,
|
||||
BlackSubject = null,
|
||||
BlockNumber = 1,
|
||||
CurrentTurn = ColorWhite,
|
||||
BlockRemainingMs = config.BlockDurationMs,
|
||||
@@ -76,6 +79,7 @@ public static class MatchEngine
|
||||
AwaitingBlockClosure = false,
|
||||
ClosureReason = string.Empty,
|
||||
Result = null,
|
||||
ResultRecordedUtc = null,
|
||||
Cube = CreateCubeState(),
|
||||
DoubleCoup = new DoubleCoupState
|
||||
{
|
||||
@@ -115,13 +119,19 @@ public static class MatchEngine
|
||||
};
|
||||
storedMatch.Moves ??= new PlayerIntPair();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(storedMatch.MatchId))
|
||||
{
|
||||
storedMatch.MatchId = Guid.NewGuid().ToString("N");
|
||||
changed = true;
|
||||
}
|
||||
|
||||
var blockDurationMs = GetBlockDurationMs(storedMatch);
|
||||
var moveLimitMs = GetMoveLimitMs(storedMatch);
|
||||
var timeInitialMs = GetTimeInitialMs(storedMatch);
|
||||
|
||||
if (storedMatch.SchemaVersion != 3)
|
||||
if (storedMatch.SchemaVersion != 4)
|
||||
{
|
||||
storedMatch.SchemaVersion = 3;
|
||||
storedMatch.SchemaVersion = 4;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
@@ -826,7 +836,7 @@ public static class MatchEngine
|
||||
}
|
||||
|
||||
public static bool IsSupportedSchemaVersion(int version)
|
||||
=> version is 2 or 3;
|
||||
=> version is 2 or 3 or 4;
|
||||
|
||||
public static long GetBlockDurationMs(object? matchOrConfig)
|
||||
{
|
||||
|
||||
77
ChessCubing.App/Services/MatchStatsService.cs
Normal file
77
ChessCubing.App/Services/MatchStatsService.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using ChessCubing.App.Models;
|
||||
using ChessCubing.App.Models.Stats;
|
||||
|
||||
namespace ChessCubing.App.Services;
|
||||
|
||||
public sealed class MatchStatsService(HttpClient httpClient)
|
||||
{
|
||||
private readonly HttpClient _httpClient = httpClient;
|
||||
|
||||
public async Task<bool> TryReportCompletedMatchAsync(MatchState? match, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (match is null || string.IsNullOrWhiteSpace(match.Result) || match.ResultRecordedUtc is not null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(match.WhiteSubject) && string.IsNullOrWhiteSpace(match.BlackSubject))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var request = BuildReport(match);
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.PostAsJsonAsync("api/users/me/stats/matches", request, cancellationToken);
|
||||
if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
match.ResultRecordedUtc = DateTime.UtcNow;
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static ReportCompletedMatchRequest BuildReport(MatchState match)
|
||||
=> new()
|
||||
{
|
||||
MatchId = string.IsNullOrWhiteSpace(match.MatchId) ? Guid.NewGuid().ToString("N") : match.MatchId,
|
||||
CollaborationSessionId = match.CollaborationSessionId,
|
||||
WhiteSubject = NormalizeOptional(match.WhiteSubject),
|
||||
WhiteName = MatchEngine.PlayerName(match, MatchEngine.ColorWhite),
|
||||
BlackSubject = NormalizeOptional(match.BlackSubject),
|
||||
BlackName = MatchEngine.PlayerName(match, MatchEngine.ColorBlack),
|
||||
Result = match.Result ?? string.Empty,
|
||||
Mode = match.Config.Mode,
|
||||
Preset = match.Config.Preset,
|
||||
MatchLabel = MatchEngine.SanitizeText(match.Config.MatchLabel),
|
||||
BlockNumber = Math.Max(1, match.BlockNumber),
|
||||
WhiteMoves = Math.Max(0, match.Moves.White),
|
||||
BlackMoves = Math.Max(0, match.Moves.Black),
|
||||
CubeRounds = match.Cube.History
|
||||
.Select(entry => new ReportCompletedCubeRound
|
||||
{
|
||||
BlockNumber = Math.Max(1, entry.BlockNumber),
|
||||
Number = entry.Number,
|
||||
White = entry.White,
|
||||
Black = entry.Black,
|
||||
})
|
||||
.ToArray(),
|
||||
};
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
Reference in New Issue
Block a user