@page "/chrono" @page "/chrono.html" @implements IAsyncDisposable @inject MatchStore Store @inject NavigationManager Navigation @inject SocialRealtimeService Realtime @inject MatchStatsService MatchStats ChessCubing Arena | Phase Chrono @{ var summary = Match is null ? null : BuildSummary(); var blackZone = Match is null ? null : BuildZone(MatchEngine.ColorBlack); var whiteZone = Match is null ? null : BuildZone(MatchEngine.ColorWhite); } @if (!_ready) {
Chargement de la phase chrono...
} else if (Match is not null && summary is not null && blackZone is not null && whiteZone is not null) {

Phase chrono

@summary.Title

@summary.Subtitle

Temps Block @summary.BlockTimer
@summary.CenterLabel @summary.CenterValue
Noir

@blackZone.Name

@blackZone.Moves @blackZone.Clock

@blackZone.Hint

@summary.SpineLabel

@summary.SpineHeadline

@summary.SpineText

Blanc

@whiteZone.Name

@whiteZone.Moves @whiteZone.Clock

@whiteZone.Hint

} @code { private CancellationTokenSource? _tickerCancellation; private bool _ready; private bool ShowArbiterModal; private long _knownCollaborativeRevision; private MatchState? Match => Store.Current; private string ChronoBodyClass => Match is not null && MatchEngine.IsTimeMode(Match) ? "phase-body time-mode" : "phase-body"; protected override async Task OnAfterRenderAsync(bool firstRender) { if (!firstRender) { return; } Realtime.Changed += HandleRealtimeChanged; await Realtime.EnsureStartedAsync(); await Store.EnsureLoadedAsync(); if (!string.IsNullOrWhiteSpace(Store.Current?.CollaborationSessionId)) { await Realtime.EnsureJoinedPlaySessionAsync(Store.Current.CollaborationSessionId); await ApplyCollaborativeSyncAsync(); } _ready = true; if (Match is null) { Navigation.NavigateTo("/application.html", replace: true); return; } if (string.IsNullOrEmpty(Match.Result) && Match.Phase == MatchEngine.PhaseCube) { await Store.SaveAsync(); Navigation.NavigateTo("/cube.html", replace: true); return; } await EnsureResultReportedAsync(); _tickerCancellation = new CancellationTokenSource(); _ = RunTickerAsync(_tickerCancellation.Token); StateHasChanged(); } public async ValueTask DisposeAsync() { if (_tickerCancellation is not null) { _tickerCancellation.Cancel(); _tickerCancellation.Dispose(); } Realtime.Changed -= HandleRealtimeChanged; await Store.FlushIfDueAsync(0); } private void HandleRealtimeChanged() => _ = InvokeAsync(HandleRealtimeChangedAsync); private async Task HandleRealtimeChangedAsync() { await ApplyCollaborativeSyncAsync(); await EnsureResultReportedAsync(); StateHasChanged(); } private async Task RunTickerAsync(CancellationToken cancellationToken) { try { using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100)); while (await timer.WaitForNextTickAsync(cancellationToken)) { var match = Match; if (match is null) { continue; } if (MatchEngine.SyncRunningState(match)) { Store.MarkDirty(); } await Store.FlushIfDueAsync(); if (string.IsNullOrEmpty(match.Result) && match.Phase == MatchEngine.PhaseCube) { await Store.SaveAsync(); await InvokeAsync(() => Navigation.NavigateTo("/cube.html", replace: true)); return; } await InvokeAsync(StateHasChanged); } } catch (OperationCanceledException) { } } private void OpenArbiterModal() => ShowArbiterModal = true; private void CloseArbiterModal() => ShowArbiterModal = false; private async Task HandleChronoTapAsync(string color) { var match = Match; if (match is null || !string.IsNullOrEmpty(match.Result) || match.Phase != MatchEngine.PhaseBlock) { return; } MatchEngine.SyncRunningState(match); if (!match.Running || match.CurrentTurn != color) { return; } if (match.DoubleCoup.Step == 1) { MatchEngine.RegisterFreeDoubleMove(match); } else { MatchEngine.RegisterCountedMove(match, match.DoubleCoup.Step == 2 ? "double" : "standard"); } await PersistAndRouteAsync(); } private async Task HandlePrimaryActionAsync() { var match = Match; if (match is null) { return; } MatchEngine.SyncRunningState(match); if (!string.IsNullOrEmpty(match.Result)) { Navigation.NavigateTo("/application.html"); return; } if (match.Phase != MatchEngine.PhaseBlock) { await Store.SaveAsync(); Navigation.NavigateTo("/cube.html", replace: true); return; } if (match.Running) { MatchEngine.PauseBlock(match); } else { MatchEngine.StartBlock(match); } await PersistAndRouteAsync(); } private async Task TogglePauseAsync() { var match = Match; if (match is null || !string.IsNullOrEmpty(match.Result) || match.Phase != MatchEngine.PhaseBlock) { return; } MatchEngine.SyncRunningState(match); if (match.Running) { MatchEngine.PauseBlock(match); } else { MatchEngine.StartBlock(match); } await PersistAndRouteAsync(); } private async Task CloseBlockAsync() { var match = Match; if (match is null || !string.IsNullOrEmpty(match.Result) || match.Phase != MatchEngine.PhaseBlock) { return; } MatchEngine.SyncRunningState(match); MatchEngine.RequestBlockClosure(match, $"Cloture manuelle {MatchEngine.GetBlockGenitivePhrase(match)} demandee par l'arbitre."); await PersistAndRouteAsync(); } private async Task TimeoutMoveAsync() { var match = Match; if (match is null || !string.IsNullOrEmpty(match.Result) || match.Phase != MatchEngine.PhaseBlock || !MatchEngine.UsesMoveLimit(match)) { return; } MatchEngine.SyncRunningState(match); MatchEngine.RegisterMoveTimeout(match, false); await PersistAndRouteAsync(); } private async Task SwitchTurnAsync() { var match = Match; if (match is null || !string.IsNullOrEmpty(match.Result) || match.Phase != MatchEngine.PhaseBlock) { return; } MatchEngine.SyncRunningState(match); match.CurrentTurn = MatchEngine.OpponentOf(match.CurrentTurn); if (MatchEngine.UsesMoveLimit(match)) { match.MoveRemainingMs = MatchEngine.GetMoveLimitMs(match); } MatchEngine.LogEvent(match, "Trait corrige manuellement par l'arbitre."); await PersistAndRouteAsync(); } private async Task SetResultAsync(string result) { var match = Match; if (match is null) { return; } MatchEngine.SyncRunningState(match); MatchEngine.SetResult(match, result); await PersistAndRouteAsync(); } private async Task ResetMatchAsync() { var sessionId = Match?.CollaborationSessionId; await Store.ClearAsync(); if (!string.IsNullOrWhiteSpace(sessionId)) { await Realtime.PublishMatchStateAsync(null, "/application.html"); } Navigation.NavigateTo("/application.html", replace: true); } private async Task PersistAndRouteAsync() { await EnsureResultReportedAsync(); Store.MarkDirty(); await Store.SaveAsync(); var route = Match is not null && string.IsNullOrEmpty(Match.Result) && Match.Phase == MatchEngine.PhaseCube ? "/cube.html" : "/chrono.html"; await Realtime.PublishMatchStateAsync(Match, route); if (Match is not null && string.IsNullOrEmpty(Match.Result) && Match.Phase == MatchEngine.PhaseCube) { Navigation.NavigateTo("/cube.html", replace: true); return; } 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!; var blockHeading = MatchEngine.FormatBlockHeading(match, match.BlockNumber); var subtitle = $"{blockHeading} - {MatchEngine.Modes[match.Config.Mode].Label} - {MatchEngine.RenderModeContext(match)}"; var hideMoveTimer = MatchEngine.IsTimeMode(match); var timeoutButtonText = $"Depassement {MatchEngine.FormatClock(MatchEngine.GetMoveLimitMs(match))}"; if (!string.IsNullOrEmpty(match.Result)) { return new ChronoSummaryView( match.Config.MatchLabel, subtitle, MatchEngine.FormatClock(match.BlockRemainingMs), hideMoveTimer ? "--:--" : MatchEngine.FormatClock(match.MoveRemainingMs), "Resultat", MatchEngine.ResultText(match), "Termine", MatchEngine.ResultText(match), "Retournez a la configuration pour lancer une nouvelle rencontre.", "Retour a l'accueil", "Le match est termine. Vous pouvez revenir a l'accueil ou reinitialiser.", hideMoveTimer, timeoutButtonText); } if (match.Running) { return new ChronoSummaryView( match.Config.MatchLabel, subtitle, MatchEngine.FormatClock(match.BlockRemainingMs), hideMoveTimer ? "--:--" : MatchEngine.FormatClock(match.MoveRemainingMs), "Trait", MatchEngine.PlayerName(match, match.CurrentTurn), "Chrono en cours", $"{blockHeading} actif", "Chaque joueur tape sur sa grande zone quand son coup est termine. La page cube s'ouvrira automatiquement a la fin de la phase chess.", "Pause arbitre", $"{blockHeading} en cours. Joueur au trait : {MatchEngine.PlayerName(match, match.CurrentTurn)}.", hideMoveTimer, timeoutButtonText); } return new ChronoSummaryView( match.Config.MatchLabel, subtitle, MatchEngine.FormatClock(match.BlockRemainingMs), hideMoveTimer ? "--:--" : MatchEngine.FormatClock(match.MoveRemainingMs), "Trait", MatchEngine.PlayerName(match, match.CurrentTurn), MatchEngine.IsTimeMode(match) ? "Etat du Block" : "Pret", blockHeading, "Demarrez le Block, puis laissez uniquement les deux grandes zones aux joueurs. La page cube prendra automatiquement le relais.", "Demarrer le Block", $"{blockHeading} pret. {MatchEngine.PlayerName(match, match.CurrentTurn)} commencera.", hideMoveTimer, timeoutButtonText); } private ChronoZoneView BuildZone(string color) { var match = Match!; var active = match.CurrentTurn == color; var clockValue = match.Clocks is not null ? color == MatchEngine.ColorWhite ? match.Clocks.White : match.Clocks.Black : 0; var clockText = match.Clocks is not null ? MatchEngine.FormatSignedClock(clockValue) : $"Dernier cube {MatchEngine.RenderLastCube(match, color)}"; if (!string.IsNullOrEmpty(match.Result)) { return new ChronoZoneView( MatchEngine.PlayerName(match, color), $"{(color == MatchEngine.ColorWhite ? match.Moves.White : match.Moves.Black)} / {match.Quota}", clockText, MatchEngine.ResultText(match), "Le match est termine.", true, false, false, match.Clocks is not null, match.Clocks is not null && clockValue < 0, false); } if (!match.Running) { return new ChronoZoneView( MatchEngine.PlayerName(match, color), $"{(color == MatchEngine.ColorWhite ? match.Moves.White : match.Moves.Black)} / {match.Quota}", clockText, "Block en pause", active ? $"{MatchEngine.GetBlockPhrase(match)} n'a pas encore demarre ou a ete mis en pause." : $"{MatchEngine.PlayerName(match, match.CurrentTurn)} reprendra au demarrage.", true, false, active, match.Clocks is not null, match.Clocks is not null && clockValue < 0, false); } if (!active) { return new ChronoZoneView( MatchEngine.PlayerName(match, color), $"{(color == MatchEngine.ColorWhite ? match.Moves.White : match.Moves.Black)} / {match.Quota}", clockText, "Attends", $"{MatchEngine.PlayerName(match, match.CurrentTurn)} est en train de jouer.", true, false, false, match.Clocks is not null, match.Clocks is not null && clockValue < 0, false); } if (match.DoubleCoup.Step == 1) { return new ChronoZoneView( MatchEngine.PlayerName(match, color), $"{(color == MatchEngine.ColorWhite ? match.Moves.White : match.Moves.Black)} / {match.Quota}", clockText, "1er coup gratuit", "Ce coup ne compte pas et ne doit pas donner echec.", false, true, true, match.Clocks is not null, match.Clocks is not null && clockValue < 0, match.Clocks is not null); } if (match.DoubleCoup.Step == 2) { return new ChronoZoneView( MatchEngine.PlayerName(match, color), $"{(color == MatchEngine.ColorWhite ? match.Moves.White : match.Moves.Black)} / {match.Quota}", clockText, "2e coup du double", "Ce coup compte dans le quota et l'echec redevient autorise.", false, true, true, match.Clocks is not null, match.Clocks is not null && clockValue < 0, match.Clocks is not null); } return new ChronoZoneView( MatchEngine.PlayerName(match, color), $"{(color == MatchEngine.ColorWhite ? match.Moves.White : match.Moves.Black)} / {match.Quota}", clockText, "J'ai fini mon coup", "Tape des que ton coup est joue sur l'echiquier.", false, true, true, match.Clocks is not null, match.Clocks is not null && clockValue < 0, match.Clocks is not null); } private static string BoolString(bool value) => value ? "true" : "false"; 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(); var route = NormalizeRoute(snapshot.Route); if (!string.Equals(route, "/chrono.html", StringComparison.OrdinalIgnoreCase)) { Navigation.NavigateTo(route, replace: true); } } private static string NormalizeRoute(string? route) { var normalized = string.IsNullOrWhiteSpace(route) ? "/application.html" : route.Trim(); return normalized.StartsWith('/') ? normalized : $"/{normalized}"; } private sealed record ChronoSummaryView( string Title, string Subtitle, string BlockTimer, string MoveTimer, string CenterLabel, string CenterValue, string SpineLabel, string SpineHeadline, string SpineText, string PrimaryButtonText, string ArbiterStatus, bool HideMoveTimer, string TimeoutButtonText); private sealed record ChronoZoneView( string Name, string Moves, string Clock, string ButtonText, string Hint, bool Disabled, bool ActiveTurn, bool ActiveZone, bool HasPlayerClock, bool NegativeClock, bool ActiveClock); }