@page "/application" @page "/application.html" @using System.Net @using System.Net.Http.Json @using System.Security.Claims @using ChessCubing.App.Models.Users @implements IDisposable @inject BrowserBridge Browser @inject MatchStore Store @inject NavigationManager Navigation @inject AuthenticationStateProvider AuthenticationStateProvider @inject HttpClient Http @inject SocialRealtimeService Realtime ChessCubing Arena | Application
Icone ChessCubing

Application officielle de match

ChessCubing Arena

Nouvelle rencontre

Configuration

Les reglages ci-dessous preparent les pages chrono et cube.

@if (Form.CompetitionMode) { }
Mode officiel
Cadence du match
Temps personnalises
@if (!UsesMoveLimit) { } @if (UsesMoveLimit) { }
@if (!string.IsNullOrWhiteSpace(SetupError)) {

@SetupError

} @if (IsAuthenticated) {
@if (!string.IsNullOrWhiteSpace(SocialLoadError)) {

@SocialLoadError

} @if (!string.IsNullOrWhiteSpace(InviteActionError)) {

@InviteActionError

} @if (!string.IsNullOrWhiteSpace(Realtime.LastInviteNotice)) { } @if (Realtime.OutgoingPlayInvite is not null) {
En attente @Realtime.OutgoingPlayInvite.RecipientDisplayName

Invitation envoyee pour jouer cote @(BuildColorLabel(Realtime.OutgoingPlayInvite.RecipientColor)).

} @if (IsSocialLoading) { } else if (OnlineFriends.Length > 0) {
@foreach (var friend in OnlineFriends) {
}
} else { }
} @if (Form.CompetitionMode) { }
@CurrentMode.Label @CurrentPreset.Description @TimingText @TimeImpact @QuotaText @StatsEligibilityText
Consulter le reglement
@code { private SetupFormModel Form { get; set; } = new(); private bool _ready; private bool IsAuthenticated; private bool IsSocialLoading; private int _knownSocialVersion; 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; private SocialOverviewResponse? SocialOverview; private MatchState? CurrentMatch => Store.Current; 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; } } private string BlackPlayerName { get => Form.BlackName; set { Form.BlackName = value; if (!HasLockedPlaySession && !string.IsNullOrWhiteSpace(AssignedBlackSubject) && AssignedBlackSubject == ConnectedPlayerSubject && !SamePlayerName(value, ConnectedPlayerName)) { AssignedBlackSubject = null; } SetupError = null; } } private SocialFriendResponse[] OnlineFriends => SocialOverview?.Friends .Where(friend => ResolveOnlineStatus(friend.Subject, friend.IsOnline)) .OrderBy(friend => friend.DisplayName, StringComparer.OrdinalIgnoreCase) .ToArray() ?? []; private bool UsesMoveLimit => MatchEngine.UsesMoveLimit(Form.Mode); private MatchPresetInfo CurrentPreset => MatchEngine.Presets.TryGetValue(Form.Preset, out var preset) ? preset : MatchEngine.Presets[MatchEngine.PresetFast]; private MatchModeInfo CurrentMode => MatchEngine.Modes.TryGetValue(Form.Mode, out var mode) ? mode : MatchEngine.Modes[MatchEngine.ModeTwice]; private string TimingText => UsesMoveLimit ? $"Temps configures : Block {MatchEngine.FormatClock(Form.BlockMinutes * 60_000L)}, coup {MatchEngine.FormatClock(Form.MoveSeconds * 1_000L)}." : $"Temps configures : Block {MatchEngine.FormatClock(Form.BlockMinutes * 60_000L)}, temps de chaque joueur {MatchEngine.FormatClock(Form.TimeInitialMinutes * 60_000L)}."; private string TimeImpact => Form.Mode == MatchEngine.ModeTime ? $"Chronos cumules de {MatchEngine.FormatClock(Form.TimeInitialMinutes * 60_000L)} par joueur, ajustes apres chaque phase cube avec plafond de 120 s pris en compte. Aucun temps par coup en mode Time." : "Le gagnant du cube commence le Block suivant, avec double coup V2 possible."; private string QuotaText => UsesMoveLimit ? $"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; Realtime.Changed += HandleRealtimeChanged; await Realtime.EnsureStartedAsync(); await LoadApplicationContextAsync(); } protected override async Task OnAfterRenderAsync(bool firstRender) { if (!firstRender) { return; } await Store.EnsureLoadedAsync(); if (!string.IsNullOrWhiteSpace(Store.Current?.CollaborationSessionId)) { await Realtime.EnsureJoinedPlaySessionAsync(Store.Current.CollaborationSessionId); } _ready = true; StateHasChanged(); } private void HandleAuthenticationStateChanged(Task authenticationStateTask) => _ = InvokeAsync(LoadApplicationContextAsync); private void HandleRealtimeChanged() => _ = InvokeAsync(HandleRealtimeChangedAsync); private async Task HandleRealtimeChangedAsync() { ApplyAcceptedPlaySession(); await ApplyCollaborativeSyncAsync(); if (IsAuthenticated && _knownSocialVersion != Realtime.SocialVersion) { _knownSocialVersion = Realtime.SocialVersion; await LoadSocialOverviewAsync(); return; } StateHasChanged(); } private async Task LoadApplicationContextAsync() { string? fallbackName = null; try { var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync(); var user = authState.User; if (user.Identity?.IsAuthenticated != true) { IsAuthenticated = false; ConnectedPlayerName = null; ConnectedPlayerSubject = null; AssignedWhiteSubject = null; AssignedBlackSubject = null; ResetSocialState(); await InvokeAsync(StateHasChanged); return; } IsAuthenticated = true; fallbackName = BuildConnectedPlayerFallback(user); ConnectedPlayerSubject = ResolveSubject(user); var response = await Http.GetAsync("api/users/me"); if (!response.IsSuccessStatusCode) { if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) { IsAuthenticated = false; ConnectedPlayerName = null; ConnectedPlayerSubject = null; AssignedWhiteSubject = null; AssignedBlackSubject = null; ResetSocialState(); await InvokeAsync(StateHasChanged); return; } ConnectedPlayerName = fallbackName; await LoadSocialOverviewAsync(); await InvokeAsync(StateHasChanged); return; } var profile = await response.Content.ReadFromJsonAsync(); ConnectedPlayerName = !string.IsNullOrWhiteSpace(profile?.DisplayName) ? profile.DisplayName : fallbackName; await LoadSocialOverviewAsync(); } catch { ConnectedPlayerName = fallbackName; } ApplyAcceptedPlaySession(); await ApplyCollaborativeSyncAsync(); await InvokeAsync(StateHasChanged); } private async Task LoadSocialOverviewAsync() { if (!IsAuthenticated) { ResetSocialState(); return; } IsSocialLoading = true; SocialLoadError = null; try { var response = await Http.GetAsync("api/social/overview"); if (!response.IsSuccessStatusCode) { SocialLoadError = response.StatusCode switch { HttpStatusCode.Unauthorized => "La session a expire. Reconnecte-toi puis recharge la page.", _ => "Le chargement des amis a echoue.", }; SocialOverview = null; return; } SocialOverview = await response.Content.ReadFromJsonAsync() ?? new SocialOverviewResponse(); _knownSocialVersion = Realtime.SocialVersion; } catch (HttpRequestException) { SocialLoadError = "Le service social est temporairement indisponible."; SocialOverview = null; } catch (TaskCanceledException) { SocialLoadError = "Le chargement des amis a pris trop de temps."; SocialOverview = null; } finally { IsSocialLoading = false; StateHasChanged(); } } private async Task HandleSubmit() { SetupError = ValidateDistinctPlayers(); if (!string.IsNullOrWhiteSpace(SetupError)) { StateHasChanged(); return; } await Store.EnsureLoadedAsync(); var match = MatchEngine.CreateMatch(Form.ToMatchConfig()); if (Realtime.ActivePlaySession is { } session) { 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); try { await Store.SaveAsync(); await Realtime.PublishMatchStateAsync(match, "/chrono.html"); } catch { // La navigation vers la phase chrono doit rester possible meme si la persistence // du navigateur echoue ponctuellement. } Navigation.NavigateTo("/chrono.html"); } private void LoadDemo() { Form = SetupFormModel.CreateDemo(); AssignedWhiteSubject = null; AssignedBlackSubject = null; SetupError = null; } private void FillWhiteWithConnectedPlayer() { if (!CanUseConnectedPlayerName) { return; } AssignConnectedPlayerToWhite(); } private void FillBlackWithConnectedPlayer() { if (!CanUseConnectedPlayerName) { return; } AssignConnectedPlayerToBlack(); } private async Task InviteFriendToPlayAsync(string friendSubject, string recipientColor) { InviteActionError = null; try { await Realtime.SendPlayInviteAsync(friendSubject, recipientColor); } catch (InvalidOperationException exception) { InviteActionError = exception.Message; } catch (Exception exception) { InviteActionError = string.IsNullOrWhiteSpace(exception.Message) ? "L'invitation de partie n'a pas pu etre envoyee." : exception.Message; } } private async Task CancelOutgoingPlayInviteAsync() { InviteActionError = null; try { await Realtime.CancelOutgoingPlayInviteAsync(); } catch (Exception exception) { InviteActionError = string.IsNullOrWhiteSpace(exception.Message) ? "L'invitation de partie n'a pas pu etre annulee." : exception.Message; } } private Task InviteFriendAsWhiteAsync(string friendSubject) => InviteFriendToPlayAsync(friendSubject, "white"); private Task InviteFriendAsBlackAsync(string friendSubject) => InviteFriendToPlayAsync(friendSubject, "black"); private void SetMode(string mode) => Form.Mode = mode; private void SetPreset(string preset) => Form.Preset = preset; private void ResumeMatch() { if (CurrentMatch is null) { return; } Navigation.NavigateTo(MatchEngine.RouteForMatch(CurrentMatch)); } private async Task ClearMatchAsync() { var sessionId = CurrentMatch?.CollaborationSessionId; await Store.ClearAsync(); if (!string.IsNullOrWhiteSpace(sessionId)) { await Realtime.PublishMatchStateAsync(null, "/application.html"); } StateHasChanged(); } private Task ForceRefreshAsync() => Browser.ForceRefreshAsync("/application.html").AsTask(); private static string ResumeMatchLabel(MatchState match) => string.IsNullOrWhiteSpace(match.Config.MatchLabel) ? "Rencontre ChessCubing" : match.Config.MatchLabel; private static string CurrentMatchMode(MatchState match) => MatchEngine.Modes.TryGetValue(match.Config.Mode, out var mode) ? mode.Label : "ChessCubing Twice"; private static string ResumePhaseLabel(MatchState match) { if (!string.IsNullOrEmpty(match.Result)) { return MatchEngine.ResultText(match); } return match.Phase == MatchEngine.PhaseCube ? "Page cube prete" : "Page chrono prete"; } private string BuildPrefillTitle(string color) => $"Utiliser mon nom cote {color}"; private void ApplyAcceptedPlaySession() { var session = Realtime.ActivePlaySession; if (session is null || string.Equals(_appliedActiveSessionId, session.SessionId, StringComparison.Ordinal)) { return; } Form.WhiteName = session.WhiteName; Form.BlackName = session.BlackName; AssignedWhiteSubject = NormalizeOptional(session.WhiteSubject); AssignedBlackSubject = NormalizeOptional(session.BlackSubject); SetupError = null; _appliedActiveSessionId = session.SessionId; } private void AssignConnectedPlayerToWhite() { if (!CanUseConnectedPlayerName) { return; } var connectedName = ConnectedPlayerName!; Form.WhiteName = connectedName; AssignedWhiteSubject = NormalizeOptional(ConnectedPlayerSubject); if (SamePlayerName(Form.BlackName, connectedName) || string.Equals(AssignedBlackSubject, ConnectedPlayerSubject, StringComparison.Ordinal)) { Form.BlackName = "Noir"; AssignedBlackSubject = null; } SetupError = null; } private void AssignConnectedPlayerToBlack() { if (!CanUseConnectedPlayerName) { return; } var connectedName = ConnectedPlayerName!; Form.BlackName = connectedName; AssignedBlackSubject = NormalizeOptional(ConnectedPlayerSubject); if (SamePlayerName(Form.WhiteName, connectedName) || string.Equals(AssignedWhiteSubject, ConnectedPlayerSubject, StringComparison.Ordinal)) { Form.WhiteName = "Blanc"; AssignedWhiteSubject = null; } SetupError = null; } private string? ValidateDistinctPlayers() { var whiteName = NormalizePlayerName(Form.WhiteName, "Blanc"); var blackName = NormalizePlayerName(Form.BlackName, "Noir"); return SamePlayerName(whiteName, blackName) ? "Le meme joueur ne peut pas etre renseigne des deux cotes. Choisis deux noms differents pour Blanc et Noir." : null; } private async Task ApplyCollaborativeSyncAsync() { var snapshot = Realtime.CollaborativeSnapshot; if (snapshot is null || snapshot.Revision <= _knownCollaborativeRevision) { return; } _knownCollaborativeRevision = snapshot.Revision; Store.ReplaceCurrent(snapshot.Match); await Store.SaveAsync(); if (snapshot.Match is not null) { Form.WhiteName = snapshot.Match.Config.WhiteName; Form.BlackName = snapshot.Match.Config.BlackName; AssignedWhiteSubject = NormalizeOptional(snapshot.Match.WhiteSubject); AssignedBlackSubject = NormalizeOptional(snapshot.Match.BlackSubject); SetupError = null; } var route = NormalizeRoute(snapshot.Route); if (!string.Equals(route, "/application.html", StringComparison.OrdinalIgnoreCase)) { Navigation.NavigateTo(route, replace: true); } } private bool ResolveOnlineStatus(string subject, bool fallbackStatus) => Realtime.GetKnownOnlineStatus(subject) ?? fallbackStatus; private string BuildPresenceClass(string subject, bool fallbackStatus) => ResolveOnlineStatus(subject, fallbackStatus) ? "presence-badge online" : "presence-badge"; private string BuildPresenceLabel(string subject, bool fallbackStatus) => ResolveOnlineStatus(subject, fallbackStatus) ? "En ligne" : "Hors ligne"; private static string BuildColorLabel(string color) => string.Equals(color, "white", StringComparison.OrdinalIgnoreCase) ? "blanc" : "noir"; private static string NormalizePlayerName(string? value, string fallback) => MatchEngine.SanitizeText(value) is { Length: > 0 } normalized ? normalized : fallback; private static bool SamePlayerName(string? left, string? right) => string.Equals( MatchEngine.SanitizeText(left) ?? string.Empty, MatchEngine.SanitizeText(right) ?? string.Empty, StringComparison.OrdinalIgnoreCase); private static string NormalizeRoute(string? route) { var normalized = string.IsNullOrWhiteSpace(route) ? "/application.html" : route.Trim(); return normalized.StartsWith('/') ? normalized : $"/{normalized}"; } private void ResetSocialState() { SocialOverview = null; SocialLoadError = null; InviteActionError = null; _knownSocialVersion = Realtime.SocialVersion; _knownCollaborativeRevision = 0; } private static string? BuildConnectedPlayerFallback(ClaimsPrincipal user) => FirstNonEmpty( user.FindFirst("name")?.Value, user.FindFirst(ClaimTypes.Name)?.Value, 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; Realtime.Changed -= HandleRealtimeChanged; } }