Ajoute l Elo et les statistiques de parties

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

View File

@@ -45,11 +45,20 @@ public sealed class MatchConfig
public sealed class MatchState
{
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; set; } = 3;
public int SchemaVersion { get; set; } = 4;
[JsonPropertyName("matchId")]
public string MatchId { get; set; } = Guid.NewGuid().ToString("N");
[JsonPropertyName("collaborationSessionId")]
public string? CollaborationSessionId { get; set; }
[JsonPropertyName("whiteSubject")]
public string? WhiteSubject { get; set; }
[JsonPropertyName("blackSubject")]
public string? BlackSubject { get; set; }
[JsonPropertyName("config")]
public MatchConfig Config { get; set; } = new();
@@ -95,6 +104,9 @@ public sealed class MatchState
[JsonPropertyName("result")]
public string? Result { get; set; }
[JsonPropertyName("resultRecordedUtc")]
public DateTime? ResultRecordedUtc { get; set; }
[JsonPropertyName("cube")]
public CubeState Cube { get; set; } = new();

View File

@@ -0,0 +1,138 @@
namespace ChessCubing.App.Models.Stats;
public sealed class UserStatsResponse
{
public string Subject { get; init; } = string.Empty;
public int CurrentElo { get; init; }
public int RankedGames { get; init; }
public int CasualGames { get; init; }
public int Wins { get; init; }
public int Losses { get; init; }
public int StoppedGames { get; init; }
public int WhiteWins { get; init; }
public int BlackWins { get; init; }
public int WhiteLosses { get; init; }
public int BlackLosses { get; init; }
public int TotalMoves { get; init; }
public int TotalCubeRounds { get; init; }
public long? BestCubeTimeMs { get; init; }
public long? AverageCubeTimeMs { get; init; }
public DateTime? LastMatchUtc { get; init; }
public UserRecentMatchResponse[] RecentMatches { get; init; } = [];
}
public sealed class UserRecentMatchResponse
{
public string MatchId { get; init; } = string.Empty;
public DateTime CompletedUtc { get; init; }
public string Result { get; init; } = string.Empty;
public string Mode { get; init; } = string.Empty;
public string Preset { get; init; } = string.Empty;
public string? MatchLabel { get; init; }
public string PlayerColor { get; init; } = string.Empty;
public string PlayerName { get; init; } = string.Empty;
public string OpponentName { get; init; } = string.Empty;
public string? OpponentSubject { get; init; }
public bool IsRanked { get; init; }
public bool IsWin { get; init; }
public bool IsLoss { get; init; }
public int PlayerMoves { get; init; }
public int OpponentMoves { get; init; }
public int CubeRounds { get; init; }
public long? PlayerBestCubeTimeMs { get; init; }
public long? PlayerAverageCubeTimeMs { get; init; }
public int? EloBefore { get; init; }
public int? EloAfter { get; init; }
public int? EloDelta { get; init; }
}
public sealed class ReportCompletedMatchRequest
{
public string MatchId { get; init; } = string.Empty;
public string? CollaborationSessionId { get; init; }
public string? WhiteSubject { get; init; }
public string WhiteName { get; init; } = string.Empty;
public string? BlackSubject { get; init; }
public string BlackName { get; init; } = string.Empty;
public string Result { get; init; } = string.Empty;
public string Mode { get; init; } = string.Empty;
public string Preset { get; init; } = string.Empty;
public string? MatchLabel { get; init; }
public int BlockNumber { get; init; }
public int WhiteMoves { get; init; }
public int BlackMoves { get; init; }
public ReportCompletedCubeRound[] CubeRounds { get; init; } = [];
}
public sealed class ReportCompletedCubeRound
{
public int BlockNumber { get; init; }
public int? Number { get; init; }
public long? White { get; init; }
public long? Black { get; init; }
}
public sealed class ReportCompletedMatchResponse
{
public bool Recorded { get; init; }
public bool IsDuplicate { get; init; }
public bool IsRanked { get; init; }
public int? WhiteEloAfter { get; init; }
public int? BlackEloAfter { get; init; }
}

View File

@@ -160,7 +160,7 @@
</button>
}
</div>
<input @bind="WhitePlayerName" @bind:event="oninput" name="whiteName" type="text" maxlength="40" placeholder="Blanc" />
<input @bind="WhitePlayerName" @bind:event="oninput" name="whiteName" type="text" maxlength="40" placeholder="Blanc" disabled="@HasLockedPlaySession" />
</label>
<label class="field player-name-field">
@@ -178,7 +178,7 @@
</button>
}
</div>
<input @bind="BlackPlayerName" @bind:event="oninput" name="blackName" type="text" maxlength="40" placeholder="Noir" />
<input @bind="BlackPlayerName" @bind:event="oninput" name="blackName" type="text" maxlength="40" placeholder="Noir" disabled="@HasLockedPlaySession" />
</label>
@if (!string.IsNullOrWhiteSpace(SetupError))
@@ -297,6 +297,7 @@
<span>@TimingText</span>
<span>@TimeImpact</span>
<span>@QuotaText</span>
<span>@StatsEligibilityText</span>
</div>
<div class="setup-actions span-2">
@@ -386,6 +387,9 @@
private long _knownCollaborativeRevision;
private string? _appliedActiveSessionId;
private string? ConnectedPlayerName;
private string? ConnectedPlayerSubject;
private string? AssignedWhiteSubject;
private string? AssignedBlackSubject;
private string? SetupError;
private string? SocialLoadError;
private string? InviteActionError;
@@ -395,12 +399,21 @@
private string SetupBodyClass => UsesMoveLimit ? string.Empty : "time-setup-mode";
private bool CanUseConnectedPlayerName => !string.IsNullOrWhiteSpace(ConnectedPlayerName);
private bool HasLockedPlaySession => Realtime.ActivePlaySession is not null;
private string WhitePlayerName
{
get => Form.WhiteName;
set
{
Form.WhiteName = value;
if (!HasLockedPlaySession &&
!string.IsNullOrWhiteSpace(AssignedWhiteSubject) &&
AssignedWhiteSubject == ConnectedPlayerSubject &&
!SamePlayerName(value, ConnectedPlayerName))
{
AssignedWhiteSubject = null;
}
SetupError = null;
}
}
@@ -411,6 +424,14 @@
set
{
Form.BlackName = value;
if (!HasLockedPlaySession &&
!string.IsNullOrWhiteSpace(AssignedBlackSubject) &&
AssignedBlackSubject == ConnectedPlayerSubject &&
!SamePlayerName(value, ConnectedPlayerName))
{
AssignedBlackSubject = null;
}
SetupError = null;
}
}
@@ -449,6 +470,13 @@
? $"Quota actif : {CurrentPreset.Quota} coups par joueur."
: $"Quota actif : {CurrentPreset.Quota} coups par joueur et par Block.";
private string StatsEligibilityText =>
Realtime.ActivePlaySession is not null
? "Match classe : les deux comptes sont identifies, la partie mettra a jour l'Elo et les statistiques."
: !string.IsNullOrWhiteSpace(AssignedWhiteSubject) || !string.IsNullOrWhiteSpace(AssignedBlackSubject)
? "Match amical : la partie sera enregistree pour le ou les comptes lies, sans impact Elo."
: "Match local uniquement : aucun compte joueur n'est lie des deux cotes, rien ne sera ajoute aux statistiques serveur.";
protected override async Task OnInitializedAsync()
{
AuthenticationStateProvider.AuthenticationStateChanged += HandleAuthenticationStateChanged;
@@ -508,6 +536,9 @@
{
IsAuthenticated = false;
ConnectedPlayerName = null;
ConnectedPlayerSubject = null;
AssignedWhiteSubject = null;
AssignedBlackSubject = null;
ResetSocialState();
await InvokeAsync(StateHasChanged);
return;
@@ -515,6 +546,7 @@
IsAuthenticated = true;
fallbackName = BuildConnectedPlayerFallback(user);
ConnectedPlayerSubject = ResolveSubject(user);
var response = await Http.GetAsync("api/users/me");
if (!response.IsSuccessStatusCode)
@@ -523,6 +555,9 @@
{
IsAuthenticated = false;
ConnectedPlayerName = null;
ConnectedPlayerSubject = null;
AssignedWhiteSubject = null;
AssignedBlackSubject = null;
ResetSocialState();
await InvokeAsync(StateHasChanged);
return;
@@ -607,11 +642,20 @@
await Store.EnsureLoadedAsync();
var match = MatchEngine.CreateMatch(Form.ToMatchConfig());
if (!string.IsNullOrWhiteSpace(Realtime.ActivePlaySession?.SessionId))
if (Realtime.ActivePlaySession is { } session)
{
match.CollaborationSessionId = Realtime.ActivePlaySession.SessionId;
match.CollaborationSessionId = session.SessionId;
match.WhiteSubject = NormalizeOptional(session.WhiteSubject);
match.BlackSubject = NormalizeOptional(session.BlackSubject);
match.Config.WhiteName = session.WhiteName;
match.Config.BlackName = session.BlackName;
await Realtime.EnsureJoinedPlaySessionAsync(match.CollaborationSessionId);
}
else
{
match.WhiteSubject = NormalizeOptional(AssignedWhiteSubject);
match.BlackSubject = NormalizeOptional(AssignedBlackSubject);
}
Store.SetCurrent(match);
@@ -632,6 +676,8 @@
private void LoadDemo()
{
Form = SetupFormModel.CreateDemo();
AssignedWhiteSubject = null;
AssignedBlackSubject = null;
SetupError = null;
}
@@ -758,6 +804,8 @@
Form.WhiteName = session.WhiteName;
Form.BlackName = session.BlackName;
AssignedWhiteSubject = NormalizeOptional(session.WhiteSubject);
AssignedBlackSubject = NormalizeOptional(session.BlackSubject);
SetupError = null;
_appliedActiveSessionId = session.SessionId;
}
@@ -771,10 +819,12 @@
var connectedName = ConnectedPlayerName!;
Form.WhiteName = connectedName;
AssignedWhiteSubject = NormalizeOptional(ConnectedPlayerSubject);
if (SamePlayerName(Form.BlackName, connectedName))
if (SamePlayerName(Form.BlackName, connectedName) || string.Equals(AssignedBlackSubject, ConnectedPlayerSubject, StringComparison.Ordinal))
{
Form.BlackName = "Noir";
AssignedBlackSubject = null;
}
SetupError = null;
@@ -789,10 +839,12 @@
var connectedName = ConnectedPlayerName!;
Form.BlackName = connectedName;
AssignedBlackSubject = NormalizeOptional(ConnectedPlayerSubject);
if (SamePlayerName(Form.WhiteName, connectedName))
if (SamePlayerName(Form.WhiteName, connectedName) || string.Equals(AssignedWhiteSubject, ConnectedPlayerSubject, StringComparison.Ordinal))
{
Form.WhiteName = "Blanc";
AssignedWhiteSubject = null;
}
SetupError = null;
@@ -824,6 +876,8 @@
{
Form.WhiteName = snapshot.Match.Config.WhiteName;
Form.BlackName = snapshot.Match.Config.BlackName;
AssignedWhiteSubject = NormalizeOptional(snapshot.Match.WhiteSubject);
AssignedBlackSubject = NormalizeOptional(snapshot.Match.BlackSubject);
SetupError = null;
}
@@ -885,9 +939,16 @@
user.FindFirst("preferred_username")?.Value,
user.FindFirst(ClaimTypes.Email)?.Value);
private static string? ResolveSubject(ClaimsPrincipal user)
=> user.FindFirst("sub")?.Value
?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
private static string? FirstNonEmpty(params string?[] candidates)
=> candidates.FirstOrDefault(candidate => !string.IsNullOrWhiteSpace(candidate));
private static string? NormalizeOptional(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
public void Dispose()
{
AuthenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged;

View File

@@ -4,6 +4,7 @@
@inject MatchStore Store
@inject NavigationManager Navigation
@inject SocialRealtimeService Realtime
@inject MatchStatsService MatchStats
<PageTitle>ChessCubing Arena | Phase Chrono</PageTitle>
<PageBody Page="chrono" BodyClass="@ChronoBodyClass" />
@@ -198,6 +199,8 @@ else if (Match is not null && summary is not null && blackZone is not null && wh
return;
}
await EnsureResultReportedAsync();
_tickerCancellation = new CancellationTokenSource();
_ = RunTickerAsync(_tickerCancellation.Token);
StateHasChanged();
@@ -221,6 +224,7 @@ else if (Match is not null && summary is not null && blackZone is not null && wh
private async Task HandleRealtimeChangedAsync()
{
await ApplyCollaborativeSyncAsync();
await EnsureResultReportedAsync();
StateHasChanged();
}
@@ -419,6 +423,7 @@ else if (Match is not null && summary is not null && blackZone is not null && wh
private async Task PersistAndRouteAsync()
{
await EnsureResultReportedAsync();
Store.MarkDirty();
await Store.SaveAsync();
@@ -437,6 +442,20 @@ else if (Match is not null && summary is not null && blackZone is not null && wh
StateHasChanged();
}
private async Task EnsureResultReportedAsync()
{
var match = Match;
if (match is null || string.IsNullOrWhiteSpace(match.Result) || match.ResultRecordedUtc is not null)
{
return;
}
if (await MatchStats.TryReportCompletedMatchAsync(match))
{
Store.MarkDirty();
}
}
private ChronoSummaryView BuildSummary()
{
var match = Match!;

View File

@@ -3,6 +3,7 @@
@using System.ComponentModel.DataAnnotations
@using System.Net
@using System.Net.Http.Json
@using ChessCubing.App.Models.Stats
@using ChessCubing.App.Models.Users
@implements IDisposable
@inject AuthenticationStateProvider AuthenticationStateProvider
@@ -150,6 +151,116 @@
</article>
</div>
<div class="user-stats-block">
<div class="section-heading user-profile-heading">
<div>
<p class="eyebrow">Classement joueur</p>
<h3>Elo et statistiques de partie</h3>
</div>
</div>
@if (!string.IsNullOrWhiteSpace(StatsLoadError))
{
<p class="profile-feedback error">@StatsLoadError</p>
}
else if (Stats is not null)
{
<div class="user-stats-grid">
<article class="user-summary-card user-stat-emphasis">
<span class="micro-label">Elo actuel</span>
<strong>@Stats.CurrentElo</strong>
</article>
<article class="user-summary-card">
<span class="micro-label">Parties classees</span>
<strong>@Stats.RankedGames</strong>
</article>
<article class="user-summary-card">
<span class="micro-label">Parties amicales</span>
<strong>@Stats.CasualGames</strong>
</article>
<article class="user-summary-card">
<span class="micro-label">Victoires</span>
<strong>@Stats.Wins</strong>
</article>
<article class="user-summary-card">
<span class="micro-label">Defaites</span>
<strong>@Stats.Losses</strong>
</article>
<article class="user-summary-card">
<span class="micro-label">Parties stoppees</span>
<strong>@Stats.StoppedGames</strong>
</article>
<article class="user-summary-card">
<span class="micro-label">Taux de victoire</span>
<strong>@StatsWinRateLabel</strong>
</article>
<article class="user-summary-card">
<span class="micro-label">Coups joues</span>
<strong>@Stats.TotalMoves</strong>
</article>
<article class="user-summary-card">
<span class="micro-label">Rounds cube</span>
<strong>@Stats.TotalCubeRounds</strong>
</article>
<article class="user-summary-card">
<span class="micro-label">Meilleur cube</span>
<strong>@FormatDurationShort(Stats.BestCubeTimeMs)</strong>
</article>
<article class="user-summary-card">
<span class="micro-label">Moyenne cube</span>
<strong>@FormatDurationShort(Stats.AverageCubeTimeMs)</strong>
</article>
<article class="user-summary-card">
<span class="micro-label">Derniere partie</span>
<strong>@FormatOptionalDate(Stats.LastMatchUtc)</strong>
</article>
</div>
<div class="user-recent-matches">
<div class="social-card-head">
<strong>Dernieres parties</strong>
<span class="mini-chip admin-chip-outline">@Stats.RecentMatches.Length match(s)</span>
</div>
@if (Stats.RecentMatches.Length > 0)
{
<div class="social-list">
@foreach (var match in Stats.RecentMatches)
{
<article class="social-item recent-match-item">
<div class="social-item-meta">
<div class="social-item-title-row">
<strong>@BuildRecentMatchTitle(match)</strong>
<span class="@BuildRecentMatchBadgeClass(match)">@BuildRecentMatchBadge(match)</span>
</div>
<span class="social-item-subtitle">@BuildRecentMatchSubtitle(match)</span>
<span class="social-item-caption">@BuildRecentMatchCaption(match)</span>
</div>
<div class="social-item-actions recent-match-meta">
@if (match.EloDelta is not null)
{
<span class="@BuildEloDeltaClass(match.EloDelta.Value)">
Elo @BuildSignedNumber(match.EloDelta.Value)
</span>
}
<span class="mini-chip admin-chip-neutral">
@match.PlayerMoves coup(s)
</span>
</div>
</article>
}
</div>
}
else
{
<p class="social-empty">Aucune partie enregistree pour le moment.</p>
}
</div>
}
</div>
<div class="callout user-profile-bio">
<span class="micro-label">Bio</span>
<p>@(Profile.Bio ?? "Aucune bio enregistree pour le moment.")</p>
@@ -455,6 +566,7 @@
private readonly UserProfileFormModel Form = new();
private UserProfileResponse? Profile;
private UserStatsResponse? Stats;
private SocialOverviewResponse? SocialOverview;
private SocialSearchUserResponse[] SearchResults = [];
private bool IsAuthenticated;
@@ -464,6 +576,7 @@
private bool IsSearching;
private int _knownSocialVersion;
private string? LoadError;
private string? StatsLoadError;
private string? SaveError;
private string? SaveMessage;
private string? SocialLoadError;
@@ -486,6 +599,21 @@
? "Le serveur verifie la fiche utilisateur et les relations sociales associees a ce compte."
: $"Compte lie a {Profile?.Username ?? "l'utilisateur connecte"} avec synchronisation sociale en direct.";
private string StatsWinRateLabel
{
get
{
var totalDecisiveGames = (Stats?.Wins ?? 0) + (Stats?.Losses ?? 0);
if (totalDecisiveGames <= 0)
{
return "--";
}
var winRate = ((double)(Stats?.Wins ?? 0) / totalDecisiveGames) * 100d;
return $"{Math.Round(winRate, MidpointRounding.AwayFromZero):0}%";
}
}
private string FriendCountLabel => $"{SocialOverview?.Friends.Length ?? 0} ami(s)";
private string ReceivedCountLabel => $"{SocialOverview?.ReceivedInvitations.Length ?? 0} recue(s)";
@@ -561,6 +689,7 @@
{
LoadError = await ReadErrorAsync(response, "Le profil utilisateur n'a pas pu etre charge.");
Profile = null;
ResetStatsState();
ResetSocialState();
return;
}
@@ -569,23 +698,27 @@
if (Profile is null)
{
LoadError = "Le serveur a retourne une reponse vide.";
ResetStatsState();
ResetSocialState();
return;
}
FillForm(Profile);
await LoadStatsAsync();
await LoadSocialOverviewAsync();
}
catch (HttpRequestException)
{
LoadError = "Le service utilisateur est temporairement indisponible.";
Profile = null;
ResetStatsState();
ResetSocialState();
}
catch (TaskCanceledException)
{
LoadError = "La reponse du service utilisateur a pris trop de temps.";
Profile = null;
ResetStatsState();
ResetSocialState();
}
finally
@@ -595,6 +728,35 @@
}
}
private async Task LoadStatsAsync()
{
StatsLoadError = null;
try
{
var response = await Http.GetAsync("api/users/me/stats");
if (!response.IsSuccessStatusCode)
{
StatsLoadError = await ReadErrorAsync(response, "Les statistiques joueur n'ont pas pu etre chargees.");
Stats = null;
return;
}
Stats = await response.Content.ReadFromJsonAsync<UserStatsResponse>();
Stats ??= new UserStatsResponse();
}
catch (HttpRequestException)
{
StatsLoadError = "Le service de statistiques est temporairement indisponible.";
Stats = null;
}
catch (TaskCanceledException)
{
StatsLoadError = "Le chargement des statistiques a pris trop de temps.";
Stats = null;
}
}
private async Task LoadSocialOverviewAsync()
{
SocialLoadError = null;
@@ -849,9 +1011,16 @@
{
IsAuthenticated = false;
Profile = null;
ResetStatsState();
Form.Reset();
}
private void ResetStatsState()
{
Stats = null;
StatsLoadError = null;
}
private void ResetSocialState()
{
SocialOverview = null;
@@ -920,6 +1089,83 @@
private static string FormatDate(DateTime value)
=> value.ToLocalTime().ToString("dd MMM yyyy 'a' HH:mm", CultureInfo.GetCultureInfo("fr-FR"));
private static string FormatOptionalDate(DateTime? value)
=> value is null ? "--" : FormatDate(value.Value);
private static string FormatDurationShort(long? value)
{
if (value is null or <= 0)
{
return "--";
}
return MatchEngine.FormatStopwatch(value.Value);
}
private static string BuildRecentMatchTitle(UserRecentMatchResponse match)
=> string.IsNullOrWhiteSpace(match.MatchLabel)
? $"{match.PlayerName} vs {match.OpponentName}"
: match.MatchLabel;
private static string BuildRecentMatchSubtitle(UserRecentMatchResponse match)
=> $"{BuildModeLabel(match.Mode)} • {match.OpponentName} • cote {BuildColorLabel(match.PlayerColor)}";
private static string BuildRecentMatchCaption(UserRecentMatchResponse match)
{
var parts = new List<string>
{
$"Le {FormatDate(match.CompletedUtc)}",
$"{match.CubeRounds} round(s) cube",
};
if (match.PlayerAverageCubeTimeMs is > 0)
{
parts.Add($"moy. cube {FormatDurationShort(match.PlayerAverageCubeTimeMs)}");
}
return string.Join(" • ", parts);
}
private static string BuildRecentMatchBadge(UserRecentMatchResponse match)
{
if (match.IsWin)
{
return "Victoire";
}
if (match.IsLoss)
{
return "Defaite";
}
return "Arretee";
}
private static string BuildRecentMatchBadgeClass(UserRecentMatchResponse match)
=> match.IsWin
? "presence-badge online"
: match.IsLoss
? "presence-badge recent-loss-badge"
: "presence-badge";
private static string BuildColorLabel(string color)
=> string.Equals(color, MatchEngine.ColorWhite, StringComparison.OrdinalIgnoreCase)
? "blanc"
: "noir";
private static string BuildModeLabel(string mode)
=> MatchEngine.Modes.TryGetValue(mode, out var info)
? info.Label
: mode;
private static string BuildSignedNumber(int value)
=> value > 0 ? $"+{value}" : value.ToString(CultureInfo.InvariantCulture);
private static string BuildEloDeltaClass(int value)
=> value >= 0
? "mini-chip user-elo-chip-positive"
: "mini-chip user-elo-chip-negative";
public void Dispose()
{
AuthenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged;

View File

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

View File

@@ -54,11 +54,14 @@ public static class MatchEngine
var quota = Presets[config.Preset].Quota;
var match = new MatchState
{
SchemaVersion = 3,
SchemaVersion = 4,
MatchId = Guid.NewGuid().ToString("N"),
Config = config,
Phase = PhaseBlock,
Running = false,
LastTickAt = null,
WhiteSubject = null,
BlackSubject = null,
BlockNumber = 1,
CurrentTurn = ColorWhite,
BlockRemainingMs = config.BlockDurationMs,
@@ -76,6 +79,7 @@ public static class MatchEngine
AwaitingBlockClosure = false,
ClosureReason = string.Empty,
Result = null,
ResultRecordedUtc = null,
Cube = CreateCubeState(),
DoubleCoup = new DoubleCoupState
{
@@ -115,13 +119,19 @@ public static class MatchEngine
};
storedMatch.Moves ??= new PlayerIntPair();
if (string.IsNullOrWhiteSpace(storedMatch.MatchId))
{
storedMatch.MatchId = Guid.NewGuid().ToString("N");
changed = true;
}
var blockDurationMs = GetBlockDurationMs(storedMatch);
var moveLimitMs = GetMoveLimitMs(storedMatch);
var timeInitialMs = GetTimeInitialMs(storedMatch);
if (storedMatch.SchemaVersion != 3)
if (storedMatch.SchemaVersion != 4)
{
storedMatch.SchemaVersion = 3;
storedMatch.SchemaVersion = 4;
changed = true;
}
@@ -826,7 +836,7 @@ public static class MatchEngine
}
public static bool IsSupportedSchemaVersion(int version)
=> version is 2 or 3;
=> version is 2 or 3 or 4;
public static long GetBlockDurationMs(object? matchOrConfig)
{

View File

@@ -0,0 +1,77 @@
using System.Net;
using System.Net.Http.Json;
using ChessCubing.App.Models;
using ChessCubing.App.Models.Stats;
namespace ChessCubing.App.Services;
public sealed class MatchStatsService(HttpClient httpClient)
{
private readonly HttpClient _httpClient = httpClient;
public async Task<bool> TryReportCompletedMatchAsync(MatchState? match, CancellationToken cancellationToken = default)
{
if (match is null || string.IsNullOrWhiteSpace(match.Result) || match.ResultRecordedUtc is not null)
{
return false;
}
if (string.IsNullOrWhiteSpace(match.WhiteSubject) && string.IsNullOrWhiteSpace(match.BlackSubject))
{
return false;
}
var request = BuildReport(match);
try
{
var response = await _httpClient.PostAsJsonAsync("api/users/me/stats/matches", request, cancellationToken);
if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
return false;
}
if (!response.IsSuccessStatusCode)
{
return false;
}
match.ResultRecordedUtc = DateTime.UtcNow;
return true;
}
catch
{
return false;
}
}
private static ReportCompletedMatchRequest BuildReport(MatchState match)
=> new()
{
MatchId = string.IsNullOrWhiteSpace(match.MatchId) ? Guid.NewGuid().ToString("N") : match.MatchId,
CollaborationSessionId = match.CollaborationSessionId,
WhiteSubject = NormalizeOptional(match.WhiteSubject),
WhiteName = MatchEngine.PlayerName(match, MatchEngine.ColorWhite),
BlackSubject = NormalizeOptional(match.BlackSubject),
BlackName = MatchEngine.PlayerName(match, MatchEngine.ColorBlack),
Result = match.Result ?? string.Empty,
Mode = match.Config.Mode,
Preset = match.Config.Preset,
MatchLabel = MatchEngine.SanitizeText(match.Config.MatchLabel),
BlockNumber = Math.Max(1, match.BlockNumber),
WhiteMoves = Math.Max(0, match.Moves.White),
BlackMoves = Math.Max(0, match.Moves.Black),
CubeRounds = match.Cube.History
.Select(entry => new ReportCompletedCubeRound
{
BlockNumber = Math.Max(1, entry.BlockNumber),
Number = entry.Number,
White = entry.White,
Black = entry.Black,
})
.ToArray(),
};
private static string? NormalizeOptional(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}