From 90f17c9c892cf91e0ce7b2abd99bdad805e38581 Mon Sep 17 00:00:00 2001 From: Christophe Date: Mon, 13 Apr 2026 21:29:12 +0200 Subject: [PATCH] Migre le projet vers Blazor WebAssembly en .NET 10 --- .dockerignore | 2 + .gitignore | 2 + ChessCubing.App/App.razor | 6 + ChessCubing.App/ChessCubing.App.csproj | 28 + ChessCubing.App/Components/PageBody.razor | 28 + ChessCubing.App/Layout/MainLayout.razor | 3 + ChessCubing.App/Models/MatchModels.cs | 285 +++++ ChessCubing.App/Pages/ApplicationPage.razor | 352 ++++++ ChessCubing.App/Pages/ChronoPage.razor | 604 ++++++++++ ChessCubing.App/Pages/CubePage.razor | 821 +++++++++++++ ChessCubing.App/Pages/Home.razor | 238 ++++ ChessCubing.App/Pages/NotFound.razor | 39 + ChessCubing.App/Pages/RulesPage.razor | 344 ++++++ ChessCubing.App/Program.cs | 14 + .../Properties/launchSettings.json | 15 + ChessCubing.App/Services/BrowserBridge.cs | 30 + ChessCubing.App/Services/MatchEngine.cs | 1020 +++++++++++++++++ ChessCubing.App/Services/MatchStore.cs | 106 ++ ChessCubing.App/_Imports.razor | 11 + ChessCubing.App/wwwroot/icon-192.png | Bin 0 -> 2626 bytes ChessCubing.App/wwwroot/index.html | 45 + .../wwwroot/js/chesscubing-interop.js | 200 ++++ Dockerfile | 13 +- README.md | 118 +- scripts/install-proxmox-lxc.sh | 45 +- scripts/update-proxmox-lxc.sh | 39 +- 26 files changed, 4314 insertions(+), 94 deletions(-) create mode 100644 ChessCubing.App/App.razor create mode 100644 ChessCubing.App/ChessCubing.App.csproj create mode 100644 ChessCubing.App/Components/PageBody.razor create mode 100644 ChessCubing.App/Layout/MainLayout.razor create mode 100644 ChessCubing.App/Models/MatchModels.cs create mode 100644 ChessCubing.App/Pages/ApplicationPage.razor create mode 100644 ChessCubing.App/Pages/ChronoPage.razor create mode 100644 ChessCubing.App/Pages/CubePage.razor create mode 100644 ChessCubing.App/Pages/Home.razor create mode 100644 ChessCubing.App/Pages/NotFound.razor create mode 100644 ChessCubing.App/Pages/RulesPage.razor create mode 100644 ChessCubing.App/Program.cs create mode 100644 ChessCubing.App/Properties/launchSettings.json create mode 100644 ChessCubing.App/Services/BrowserBridge.cs create mode 100644 ChessCubing.App/Services/MatchEngine.cs create mode 100644 ChessCubing.App/Services/MatchStore.cs create mode 100644 ChessCubing.App/_Imports.razor create mode 100644 ChessCubing.App/wwwroot/icon-192.png create mode 100644 ChessCubing.App/wwwroot/index.html create mode 100644 ChessCubing.App/wwwroot/js/chesscubing-interop.js diff --git a/.dockerignore b/.dockerignore index b540d82..a91a81e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,5 @@ .git .codex WhatsApp Video 2026-04-11 at 20.38.50.mp4 +ChessCubing.App/bin +ChessCubing.App/obj diff --git a/.gitignore b/.gitignore index 971a363..aa605ff 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .codex WhatsApp Video 2026-04-11 at 20.38.50.mp4 +ChessCubing.App/bin/ +ChessCubing.App/obj/ diff --git a/ChessCubing.App/App.razor b/ChessCubing.App/App.razor new file mode 100644 index 0000000..2dba37e --- /dev/null +++ b/ChessCubing.App/App.razor @@ -0,0 +1,6 @@ + + + + + + diff --git a/ChessCubing.App/ChessCubing.App.csproj b/ChessCubing.App/ChessCubing.App.csproj new file mode 100644 index 0000000..f9e59d9 --- /dev/null +++ b/ChessCubing.App/ChessCubing.App.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + true + ChessCubing.App + ChessCubing.App + + + + + + + + + + + + + + + + + + + diff --git a/ChessCubing.App/Components/PageBody.razor b/ChessCubing.App/Components/PageBody.razor new file mode 100644 index 0000000..1f48922 --- /dev/null +++ b/ChessCubing.App/Components/PageBody.razor @@ -0,0 +1,28 @@ +@inject BrowserBridge Browser + +@code { + [Parameter] + public string? Page { get; set; } + + [Parameter] + public string? BodyClass { get; set; } + + private string? _lastSignature; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await Browser.StartViewportAsync(); + } + + var signature = $"{Page ?? string.Empty}|{BodyClass ?? string.Empty}"; + if (signature == _lastSignature) + { + return; + } + + _lastSignature = signature; + await Browser.SetBodyStateAsync(Page, BodyClass); + } +} diff --git a/ChessCubing.App/Layout/MainLayout.razor b/ChessCubing.App/Layout/MainLayout.razor new file mode 100644 index 0000000..4f3f76d --- /dev/null +++ b/ChessCubing.App/Layout/MainLayout.razor @@ -0,0 +1,3 @@ +@inherits LayoutComponentBase + +@Body diff --git a/ChessCubing.App/Models/MatchModels.cs b/ChessCubing.App/Models/MatchModels.cs new file mode 100644 index 0000000..8935694 --- /dev/null +++ b/ChessCubing.App/Models/MatchModels.cs @@ -0,0 +1,285 @@ +using System.Text.Json.Serialization; +using ChessCubing.App.Services; + +namespace ChessCubing.App.Models; + +public sealed class MatchConfig +{ + [JsonPropertyName("matchLabel")] + public string MatchLabel { get; set; } = "Rencontre ChessCubing"; + + [JsonPropertyName("competitionMode")] + public bool CompetitionMode { get; set; } + + [JsonPropertyName("mode")] + public string Mode { get; set; } = MatchEngine.ModeTwice; + + [JsonPropertyName("preset")] + public string Preset { get; set; } = MatchEngine.PresetFast; + + [JsonPropertyName("blockDurationMs")] + public long BlockDurationMs { get; set; } = MatchEngine.DefaultBlockDurationMs; + + [JsonPropertyName("moveLimitMs")] + public long MoveLimitMs { get; set; } = MatchEngine.DefaultMoveLimitMs; + + [JsonPropertyName("timeInitialMs")] + public long TimeInitialMs { get; set; } = MatchEngine.TimeModeInitialClockMs; + + [JsonPropertyName("whiteName")] + public string WhiteName { get; set; } = "Blanc"; + + [JsonPropertyName("blackName")] + public string BlackName { get; set; } = "Noir"; + + [JsonPropertyName("arbiterName")] + public string ArbiterName { get; set; } = string.Empty; + + [JsonPropertyName("eventName")] + public string EventName { get; set; } = string.Empty; + + [JsonPropertyName("notes")] + public string Notes { get; set; } = string.Empty; +} + +public sealed class MatchState +{ + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; set; } = 3; + + [JsonPropertyName("config")] + public MatchConfig Config { get; set; } = new(); + + [JsonPropertyName("phase")] + public string Phase { get; set; } = MatchEngine.PhaseBlock; + + [JsonPropertyName("running")] + public bool Running { get; set; } + + [JsonPropertyName("lastTickAt")] + public long? LastTickAt { get; set; } + + [JsonPropertyName("blockNumber")] + public int BlockNumber { get; set; } = 1; + + [JsonPropertyName("currentTurn")] + public string CurrentTurn { get; set; } = MatchEngine.ColorWhite; + + [JsonPropertyName("blockRemainingMs")] + public long BlockRemainingMs { get; set; } = MatchEngine.DefaultBlockDurationMs; + + [JsonPropertyName("moveRemainingMs")] + public long MoveRemainingMs { get; set; } = MatchEngine.DefaultMoveLimitMs; + + [JsonPropertyName("quota")] + public int Quota { get; set; } = 6; + + [JsonPropertyName("moves")] + public PlayerIntPair Moves { get; set; } = new(); + + [JsonPropertyName("clocks")] + public PlayerLongPair? Clocks { get; set; } + + [JsonPropertyName("lastMover")] + public string? LastMover { get; set; } + + [JsonPropertyName("awaitingBlockClosure")] + public bool AwaitingBlockClosure { get; set; } + + [JsonPropertyName("closureReason")] + public string ClosureReason { get; set; } = string.Empty; + + [JsonPropertyName("result")] + public string? Result { get; set; } + + [JsonPropertyName("cube")] + public CubeState Cube { get; set; } = new(); + + [JsonPropertyName("doubleCoup")] + public DoubleCoupState DoubleCoup { get; set; } = new(); + + [JsonPropertyName("history")] + public List History { get; set; } = []; +} + +public sealed class PlayerIntPair +{ + [JsonPropertyName("white")] + public int White { get; set; } + + [JsonPropertyName("black")] + public int Black { get; set; } +} + +public sealed class PlayerLongPair +{ + [JsonPropertyName("white")] + public long White { get; set; } + + [JsonPropertyName("black")] + public long Black { get; set; } +} + +public sealed class PlayerNullableLongPair +{ + [JsonPropertyName("white")] + public long? White { get; set; } + + [JsonPropertyName("black")] + public long? Black { get; set; } +} + +public sealed class CubeState +{ + [JsonPropertyName("number")] + public int? Number { get; set; } + + [JsonPropertyName("running")] + public bool Running { get; set; } + + [JsonPropertyName("startedAt")] + public long? StartedAt { get; set; } + + [JsonPropertyName("elapsedMs")] + public long ElapsedMs { get; set; } + + [JsonPropertyName("phaseAlertPending")] + public bool PhaseAlertPending { get; set; } + + [JsonPropertyName("times")] + public PlayerNullableLongPair Times { get; set; } = new(); + + [JsonPropertyName("playerState")] + public CubePlayerStates PlayerState { get; set; } = new(); + + [JsonPropertyName("round")] + public int Round { get; set; } = 1; + + [JsonPropertyName("history")] + public List History { get; set; } = []; +} + +public sealed class CubePlayerStates +{ + [JsonPropertyName("white")] + public CubePlayerState White { get; set; } = new(); + + [JsonPropertyName("black")] + public CubePlayerState Black { get; set; } = new(); +} + +public sealed class CubePlayerState +{ + [JsonPropertyName("running")] + public bool Running { get; set; } + + [JsonPropertyName("startedAt")] + public long? StartedAt { get; set; } + + [JsonPropertyName("elapsedMs")] + public long ElapsedMs { get; set; } +} + +public sealed class DoubleCoupState +{ + [JsonPropertyName("eligible")] + public bool Eligible { get; set; } + + [JsonPropertyName("step")] + public int Step { get; set; } + + [JsonPropertyName("starter")] + public string Starter { get; set; } = MatchEngine.ColorWhite; +} + +public sealed class MatchHistoryEntry +{ + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; + + [JsonPropertyName("time")] + public string Time { get; set; } = string.Empty; +} + +public sealed class CubeHistoryEntry +{ + [JsonPropertyName("blockNumber")] + public int BlockNumber { get; set; } + + [JsonPropertyName("number")] + public int? Number { get; set; } + + [JsonPropertyName("white")] + public long? White { get; set; } + + [JsonPropertyName("black")] + public long? Black { get; set; } +} + +public sealed class SetupFormModel +{ + public bool CompetitionMode { get; set; } + public string MatchLabel { get; set; } = string.Empty; + public string Mode { get; set; } = MatchEngine.ModeTwice; + public string Preset { get; set; } = MatchEngine.PresetFast; + public int BlockMinutes { get; set; } = 3; + public int MoveSeconds { get; set; } = 20; + public int TimeInitialMinutes { get; set; } = 10; + public string WhiteName { get; set; } = "Blanc"; + public string BlackName { get; set; } = "Noir"; + public string ArbiterName { get; set; } = string.Empty; + public string EventName { get; set; } = string.Empty; + public string Notes { get; set; } = string.Empty; + + public MatchConfig ToMatchConfig() + { + return new MatchConfig + { + MatchLabel = MatchEngine.SanitizeText(MatchLabel) is { Length: > 0 } label ? label : "Rencontre ChessCubing", + CompetitionMode = CompetitionMode, + Mode = Mode, + Preset = Preset, + BlockDurationMs = MatchEngine.NormalizeDurationMs(BlockMinutes * 60_000L, MatchEngine.DefaultBlockDurationMs), + MoveLimitMs = MatchEngine.NormalizeDurationMs(MoveSeconds * 1_000L, MatchEngine.DefaultMoveLimitMs), + TimeInitialMs = MatchEngine.NormalizeDurationMs(TimeInitialMinutes * 60_000L, MatchEngine.TimeModeInitialClockMs), + WhiteName = MatchEngine.SanitizeText(WhiteName) is { Length: > 0 } white ? white : "Blanc", + BlackName = MatchEngine.SanitizeText(BlackName) is { Length: > 0 } black ? black : "Noir", + ArbiterName = MatchEngine.SanitizeText(ArbiterName), + EventName = MatchEngine.SanitizeText(EventName), + Notes = MatchEngine.SanitizeText(Notes), + }; + } + + public static SetupFormModel CreateDemo() + { + return new SetupFormModel + { + CompetitionMode = true, + MatchLabel = "Demo officielle ChessCubing", + Mode = MatchEngine.ModeTwice, + Preset = MatchEngine.PresetFreeze, + BlockMinutes = 3, + MoveSeconds = 20, + TimeInitialMinutes = 10, + WhiteName = "Nora", + BlackName = "Leo", + ArbiterName = "Arbitre demo", + EventName = "Session telephone", + Notes = "8 cubes verifies, variante prete, tirage au sort effectue.", + }; + } +} + +public sealed record MatchPresetInfo(string Label, int Quota, string Description); + +public sealed record MatchModeInfo(string Label, string Subtitle); + +public sealed record TimeAdjustmentPreview( + string BlockType, + string? Winner, + long CappedWhite, + long CappedBlack, + long WhiteDelta, + long BlackDelta, + long WhiteAfter, + long BlackAfter); diff --git a/ChessCubing.App/Pages/ApplicationPage.razor b/ChessCubing.App/Pages/ApplicationPage.razor new file mode 100644 index 0000000..b8e469f --- /dev/null +++ b/ChessCubing.App/Pages/ApplicationPage.razor @@ -0,0 +1,352 @@ +@page "/application" +@page "/application.html" +@inject BrowserBridge Browser +@inject MatchStore Store +@inject NavigationManager Navigation + +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 (Form.CompetitionMode) + { + + + + + + } + +
+ @CurrentMode.Label + @CurrentPreset.Description + @TimingText + @TimeImpact + @QuotaText +
+ +
+ + + Consulter le reglement +
+
+
+ + +
+ + +
+ +@code { + private SetupFormModel Form { get; set; } = new(); + private bool _ready; + + private MatchState? CurrentMatch => Store.Current; + + private string SetupBodyClass => UsesMoveLimit ? string.Empty : "time-setup-mode"; + + 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."; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + { + return; + } + + await Store.EnsureLoadedAsync(); + _ready = true; + StateHasChanged(); + } + + private async Task HandleSubmit() + { + var match = MatchEngine.CreateMatch(Form.ToMatchConfig()); + Store.SetCurrent(match); + await Store.SaveAsync(); + Navigation.NavigateTo("/chrono.html"); + } + + private void LoadDemo() + => Form = SetupFormModel.CreateDemo(); + + 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() + { + await Store.ClearAsync(); + 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"; + } +} diff --git a/ChessCubing.App/Pages/ChronoPage.razor b/ChessCubing.App/Pages/ChronoPage.razor new file mode 100644 index 0000000..7bf2658 --- /dev/null +++ b/ChessCubing.App/Pages/ChronoPage.razor @@ -0,0 +1,604 @@ +@page "/chrono" +@page "/chrono.html" +@implements IAsyncDisposable +@inject MatchStore Store +@inject NavigationManager Navigation + +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 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; + } + + await Store.EnsureLoadedAsync(); + _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; + } + + _tickerCancellation = new CancellationTokenSource(); + _ = RunTickerAsync(_tickerCancellation.Token); + StateHasChanged(); + } + + public async ValueTask DisposeAsync() + { + if (_tickerCancellation is not null) + { + _tickerCancellation.Cancel(); + _tickerCancellation.Dispose(); + } + + await Store.FlushIfDueAsync(0); + } + + 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() + { + await Store.ClearAsync(); + Navigation.NavigateTo("/application.html", replace: true); + } + + private async Task PersistAndRouteAsync() + { + Store.MarkDirty(); + await Store.SaveAsync(); + + if (Match is not null && string.IsNullOrEmpty(Match.Result) && Match.Phase == MatchEngine.PhaseCube) + { + Navigation.NavigateTo("/cube.html", replace: true); + return; + } + + StateHasChanged(); + } + + 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 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); +} diff --git a/ChessCubing.App/Pages/CubePage.razor b/ChessCubing.App/Pages/CubePage.razor new file mode 100644 index 0000000..343d935 --- /dev/null +++ b/ChessCubing.App/Pages/CubePage.razor @@ -0,0 +1,821 @@ +@page "/cube" +@page "/cube.html" +@implements IAsyncDisposable +@inject BrowserBridge Browser +@inject MatchStore Store +@inject NavigationManager Navigation + +ChessCubing Arena | Phase Cube + + +@{ + 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); + var resultView = Match is null ? null : BuildResultView(); +} + +@if (!_ready) +{ +
+
Chargement de la phase cube...
+
+} +else if (Match is not null && summary is not null && blackZone is not null && whiteZone is not null) +{ +
+
+ +
+

Phase cube

+

@summary.Title

+

@summary.Subtitle

+
+ +
+ +
+
+ Block + @Match.BlockNumber +
+
+ Temps max + @MatchEngine.RenderCubeElapsed(Match) +
+
+ @summary.CenterLabel + @summary.CenterValue +
+
+ +
+
+
+
+
+ Noir +

@blackZone.Name

+
+
+ @blackZone.ResultText + @blackZone.MetaText +
+
+ + + +

@blackZone.Hint

+
+
+ +
+
+

@summary.SpineLabel

+ @summary.SpineHeadline +

@summary.SpineText

+
+ + +
+ +
+
+
+
+ Blanc +

@whiteZone.Name

+
+
+ @whiteZone.ResultText + @whiteZone.MetaText +
+
+ + + +

@whiteZone.Hint

+
+
+
+
+ + + + +} + +@code { + private readonly Dictionary _holdStates = new(StringComparer.Ordinal) + { + [MatchEngine.ColorWhite] = new(), + [MatchEngine.ColorBlack] = new(), + }; + + private CancellationTokenSource? _tickerCancellation; + private bool _ready; + private bool ShowHelpModal; + private bool ShowResultModal; + private string? _resultModalKey; + + private MatchState? Match => Store.Current; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + { + return; + } + + await Store.EnsureLoadedAsync(); + _ready = true; + + if (Match is null) + { + Navigation.NavigateTo("/application.html", replace: true); + return; + } + + if (string.IsNullOrEmpty(Match.Result) && Match.Phase != MatchEngine.PhaseCube) + { + Navigation.NavigateTo("/chrono.html", replace: true); + return; + } + + _tickerCancellation = new CancellationTokenSource(); + _ = RunTickerAsync(_tickerCancellation.Token); + await TryPlayPendingAlertAsync(); + UpdateResultModalState(); + StateHasChanged(); + } + + public async ValueTask DisposeAsync() + { + if (_tickerCancellation is not null) + { + _tickerCancellation.Cancel(); + _tickerCancellation.Dispose(); + } + + await Store.FlushIfDueAsync(0); + } + + 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 (string.IsNullOrEmpty(match.Result) && match.Phase != MatchEngine.PhaseCube) + { + await Store.SaveAsync(); + await InvokeAsync(() => Navigation.NavigateTo("/chrono.html", replace: true)); + return; + } + + UpdateResultModalState(); + await Store.FlushIfDueAsync(); + await InvokeAsync(StateHasChanged); + } + } + catch (OperationCanceledException) + { + } + } + + private void OpenHelpModal() + => ShowHelpModal = true; + + private void CloseHelpModal() + => ShowHelpModal = false; + + private void CloseResultModal() + => ShowResultModal = false; + + private async Task HandlePrimaryActionAsync() + { + if (Match is null) + { + return; + } + + if (!string.IsNullOrEmpty(Match.Result)) + { + Navigation.NavigateTo("/application.html"); + return; + } + + if (Match.Phase != MatchEngine.PhaseCube) + { + Navigation.NavigateTo("/chrono.html", replace: true); + return; + } + + if (BuildResultView() is not null) + { + ShowResultModal = true; + } + } + + private async Task HandleCubePointerDownAsync(string color) + { + var match = Match; + if (match is null) + { + return; + } + + await Browser.PrimeAudioAsync(); + await TryPlayPendingAlertAsync(); + + if (!string.IsNullOrEmpty(match.Result) || match.Phase != MatchEngine.PhaseCube) + { + return; + } + + if ((color == MatchEngine.ColorWhite ? match.Cube.Times.White : match.Cube.Times.Black) is not null) + { + return; + } + + var playerState = color == MatchEngine.ColorWhite ? match.Cube.PlayerState.White : match.Cube.PlayerState.Black; + if (playerState.Running) + { + return; + } + + _holdStates[color].Armed = true; + _holdStates[color].StartedAt = MatchEngine.NowUnixMs(); + } + + private async Task HandleCubePointerUpAsync(string color) + { + var match = Match; + if (match is null) + { + return; + } + + await Browser.PrimeAudioAsync(); + await TryPlayPendingAlertAsync(); + + if (!string.IsNullOrEmpty(match.Result) || match.Phase != MatchEngine.PhaseCube) + { + CancelCubeHold(color); + return; + } + + if ((color == MatchEngine.ColorWhite ? match.Cube.Times.White : match.Cube.Times.Black) is not null) + { + CancelCubeHold(color); + return; + } + + var playerState = color == MatchEngine.ColorWhite ? match.Cube.PlayerState.White : match.Cube.PlayerState.Black; + if (playerState.Running) + { + MatchEngine.CaptureCubeTime(match, color); + CancelCubeHold(color); + await PersistCubeAsync(); + return; + } + + var ready = IsHoldReady(color); + CancelCubeHold(color); + if (!ready) + { + return; + } + + MatchEngine.StartCubeTimer(match, color); + await PersistCubeAsync(); + } + + private void CancelCubeHold(string color) + { + _holdStates[color].Armed = false; + _holdStates[color].StartedAt = 0; + } + + private async Task ApplyResultAsync() + { + var match = Match; + var resultView = BuildResultView(); + if (match is null || resultView is null) + { + ShowResultModal = false; + return; + } + + if (resultView.ReplayRequired) + { + ShowResultModal = false; + _resultModalKey = null; + MatchEngine.ReplayCubePhase(match); + await PersistCubeAsync(); + await TryPlayPendingAlertAsync(); + return; + } + + ShowResultModal = false; + MatchEngine.ApplyCubeOutcome(match); + Store.MarkDirty(); + await Store.SaveAsync(); + Navigation.NavigateTo("/chrono.html"); + } + + private async Task ReplayCubeAsync() + { + var match = Match; + if (match is null || match.Phase != MatchEngine.PhaseCube) + { + return; + } + + ShowResultModal = false; + _resultModalKey = null; + MatchEngine.ReplayCubePhase(match); + await PersistCubeAsync(); + await TryPlayPendingAlertAsync(); + } + + private async Task ResetMatchAsync() + { + await Store.ClearAsync(); + Navigation.NavigateTo("/application.html", replace: true); + } + + private async Task PersistCubeAsync() + { + Store.MarkDirty(); + await Store.SaveAsync(); + UpdateResultModalState(); + StateHasChanged(); + } + + private async Task TryPlayPendingAlertAsync() + { + var match = Match; + if (match is null || !match.Cube.PhaseAlertPending) + { + return; + } + + if (!await Browser.PlayCubePhaseAlertAsync()) + { + return; + } + + match.Cube.PhaseAlertPending = false; + Store.MarkDirty(); + await Store.SaveAsync(); + } + + private CubeSummaryView BuildSummary() + { + var match = Match!; + var blockHeading = MatchEngine.FormatBlockHeading(match, match.BlockNumber); + var subtitle = $"{blockHeading} - {MatchEngine.Modes[match.Config.Mode].Label} - {MatchEngine.RenderModeContext(match)}"; + + if (!string.IsNullOrEmpty(match.Result)) + { + return new CubeSummaryView( + match.Cube.Number is not null ? $"Cube n{match.Cube.Number}" : "Phase cube", + subtitle, + "Resultat", + MatchEngine.ResultText(match), + "Termine", + MatchEngine.ResultText(match), + "Retournez a la configuration pour relancer une rencontre.", + "Retour a l'accueil", + "Le match est termine.", + false); + } + + if (match.Cube.Times.White is not null && match.Cube.Times.Black is not null && match.Config.Mode == MatchEngine.ModeTwice && match.Cube.Times.White == match.Cube.Times.Black) + { + return new CubeSummaryView( + match.Cube.Number is not null ? $"Cube n{match.Cube.Number}" : "Phase cube", + subtitle, + "Decision", + "Egalite parfaite", + "Reglement", + "Rejouer la phase cube", + "Le mode Twice impose de relancer immediatement la phase cube en cas d'egalite parfaite.", + "Voir le resume du cube", + "Le mode Twice impose de relancer immediatement la phase cube en cas d'egalite parfaite.", + false); + } + + if (match.Cube.Times.White is not null && match.Cube.Times.Black is not null) + { + var preview = match.Config.Mode == MatchEngine.ModeTime + ? MatchEngine.GetTimeAdjustmentPreview(match, match.Cube.Times.White.Value, match.Cube.Times.Black.Value) + : null; + + return preview is null + ? new CubeSummaryView( + match.Cube.Number is not null ? $"Cube n{match.Cube.Number}" : "Phase cube", + subtitle, + "Decision", + "Phase cube complete", + "Suite", + "Ouvrir la page chrono", + "Appliquer le resultat du cube pour preparer le Block suivant.", + "Voir le resume du cube", + "Appliquer le resultat du cube pour preparer le Block suivant.", + false) + : new CubeSummaryView( + match.Cube.Number is not null ? $"Cube n{match.Cube.Number}" : "Phase cube", + subtitle, + "Vainqueur cube", + preview.Winner is not null ? MatchEngine.PlayerName(match, preview.Winner) : "Egalite", + "Impact chrono", + preview.BlockType == "minus" ? "Bloc - a appliquer" : "Bloc + a appliquer", + $"Blanc {MatchEngine.FormatSignedStopwatch(preview.WhiteDelta)} -> {MatchEngine.FormatSignedClock(preview.WhiteAfter)}. Noir {MatchEngine.FormatSignedStopwatch(preview.BlackDelta)} -> {MatchEngine.FormatSignedClock(preview.BlackAfter)}.", + "Voir le resume du cube", + $"Blanc {MatchEngine.FormatSignedStopwatch(preview.WhiteDelta)} -> {MatchEngine.FormatSignedClock(preview.WhiteAfter)}. Noir {MatchEngine.FormatSignedStopwatch(preview.BlackDelta)} -> {MatchEngine.FormatSignedClock(preview.BlackAfter)}.", + false); + } + + if (match.Cube.Running) + { + return new CubeSummaryView( + match.Cube.Number is not null ? $"Cube n{match.Cube.Number}" : "Phase cube", + subtitle, + "Etat", + "Chronos lances", + "Arrets", + "Chaque joueur se chronometre", + "Chaque joueur demarre en relachant sa zone, puis retape sa zone une fois le cube termine.", + "Attendre les deux temps", + "Chaque joueur demarre en relachant sa zone, puis retape sa zone une fois le cube termine.", + true); + } + + if (match.Cube.Times.White is not null || match.Cube.Times.Black is not null) + { + return new CubeSummaryView( + match.Cube.Number is not null ? $"Cube n{match.Cube.Number}" : "Phase cube", + subtitle, + "Etat", + "Un temps saisi", + "Suite", + "Attendre l'autre joueur", + "Le deuxieme joueur peut encore maintenir puis relacher sa zone pour demarrer son propre chrono.", + "Attendre le deuxieme temps", + "Le deuxieme joueur peut encore maintenir puis relacher sa zone pour demarrer son propre chrono.", + true); + } + + return new CubeSummaryView( + match.Cube.Number is not null ? $"Cube n{match.Cube.Number}" : "Phase cube", + subtitle, + "Etat", + "Pret", + "Depart libre", + "Chaque joueur lance son chrono", + "Au debut de sa resolution, chaque joueur maintient sa grande zone puis la relache pour demarrer son propre chrono.", + "En attente des joueurs", + "Au debut de sa resolution, chaque joueur maintient sa grande zone puis la relache pour demarrer son propre chrono.", + true); + } + + private CubeZoneView BuildZone(string color) + { + var match = Match!; + var playerState = color == MatchEngine.ColorWhite ? match.Cube.PlayerState.White : match.Cube.PlayerState.Black; + var time = color == MatchEngine.ColorWhite ? match.Cube.Times.White : match.Cube.Times.Black; + var hold = _holdStates[color]; + var holdReady = IsHoldReady(color); + var holdArmed = hold.Armed && !holdReady && !playerState.Running && time is null && match.Phase == MatchEngine.PhaseCube && string.IsNullOrEmpty(match.Result); + var holdProgress = hold.Armed && time is null && !playerState.Running + ? Math.Min((MatchEngine.NowUnixMs() - hold.StartedAt) / (double)MatchEngine.CubeStartHoldMs, 1d) + : 0d; + + if (!string.IsNullOrEmpty(match.Result)) + { + return new CubeZoneView( + MatchEngine.PlayerName(match, color), + MatchEngine.FormatCubePlayerTime(match, color), + MatchEngine.RenderCubeMeta(match, color), + MatchEngine.ResultText(match), + "Le match est termine.", + true, + false, + false, + "--cube-hold-progress: 0"); + } + + if (match.Phase != MatchEngine.PhaseCube) + { + return new CubeZoneView( + MatchEngine.PlayerName(match, color), + MatchEngine.FormatCubePlayerTime(match, color), + MatchEngine.RenderCubeMeta(match, color), + "Retour chrono", + "La page cube est terminee.", + true, + false, + false, + "--cube-hold-progress: 0"); + } + + if (time is not null) + { + return new CubeZoneView( + MatchEngine.PlayerName(match, color), + MatchEngine.FormatCubePlayerTime(match, color), + MatchEngine.RenderCubeMeta(match, color), + "Temps enregistre", + "Ce joueur a deja termine son cube.", + true, + false, + false, + "--cube-hold-progress: 1"); + } + + if (playerState.Running) + { + return new CubeZoneView( + MatchEngine.PlayerName(match, color), + MatchEngine.FormatCubePlayerTime(match, color), + MatchEngine.RenderCubeMeta(match, color), + "J'ai fini le cube", + "Tape au moment exact ou le cube est resolu.", + false, + false, + false, + "--cube-hold-progress: 1"); + } + + if (holdReady) + { + return new CubeZoneView( + MatchEngine.PlayerName(match, color), + MatchEngine.FormatCubePlayerTime(match, color), + MatchEngine.RenderCubeMeta(match, color), + "Relachez pour demarrer", + "Le chrono partira des que vous levez le doigt.", + false, + true, + true, + "--cube-hold-progress: 1"); + } + + if (holdArmed) + { + return new CubeZoneView( + MatchEngine.PlayerName(match, color), + MatchEngine.FormatCubePlayerTime(match, color), + MatchEngine.RenderCubeMeta(match, color), + "Maintenez 2 s...", + "Gardez le doigt pose 2 secondes, jusqu'a la fin de la barre.", + false, + true, + false, + $"--cube-hold-progress: {holdProgress.ToString("0.###", CultureInfo.InvariantCulture)}"); + } + + return new CubeZoneView( + MatchEngine.PlayerName(match, color), + MatchEngine.FormatCubePlayerTime(match, color), + MatchEngine.RenderCubeMeta(match, color), + "Maintenir 2 s pour demarrer", + "Maintenez la grande zone 2 secondes, puis relachez pour lancer votre chrono.", + false, + false, + false, + "--cube-hold-progress: 0"); + } + + private CubeResultView? BuildResultView() + { + var match = Match; + if (match is null || match.Phase != MatchEngine.PhaseCube) + { + return null; + } + + var white = match.Cube.Times.White; + var black = match.Cube.Times.Black; + if (white is null || black is null) + { + return null; + } + + var whiteName = MatchEngine.PlayerName(match, MatchEngine.ColorWhite); + var blackName = MatchEngine.PlayerName(match, MatchEngine.ColorBlack); + + if (match.Config.Mode == MatchEngine.ModeTime) + { + var preview = MatchEngine.GetTimeAdjustmentPreview(match, white.Value, black.Value); + if (preview is null) + { + return null; + } + + return new CubeResultView( + $"time:{match.BlockNumber}:{match.Cube.Round}:{white}:{black}", + "Resume du cube", + preview.Winner is not null ? MatchEngine.PlayerName(match, preview.Winner) : "Egalite", + preview.BlockType == "minus" ? "Bloc - a appliquer" : "Bloc + a appliquer", + "Validez ce resume pour appliquer les impacts chrono puis revenir a la page chrono.", + "Appliquer et ouvrir la page chrono", + whiteName, + blackName, + $"Temps cube {MatchEngine.FormatStopwatch(white.Value)}", + $"Temps cube {MatchEngine.FormatStopwatch(black.Value)}", + $"Impact chrono {MatchEngine.FormatSignedStopwatch(preview.WhiteDelta)}", + $"Impact chrono {MatchEngine.FormatSignedStopwatch(preview.BlackDelta)}", + $"Chrono apres {MatchEngine.FormatSignedClock(preview.WhiteAfter)}", + $"Chrono apres {MatchEngine.FormatSignedClock(preview.BlackAfter)}", + false); + } + + var winner = white < black ? MatchEngine.ColorWhite : black < white ? MatchEngine.ColorBlack : null; + var tie = winner is null; + return new CubeResultView( + $"twice:{match.BlockNumber}:{match.Cube.Round}:{white}:{black}", + tie ? "Egalite parfaite" : "Resume du cube", + winner is not null ? MatchEngine.PlayerName(match, winner) : "Egalite parfaite", + tie ? "Rejouer la phase cube" : $"{MatchEngine.PlayerName(match, winner!)} ouvrira le Block suivant", + tie ? "Le reglement Twice impose de rejouer immediatement la phase cube." : "Validez ce resultat pour preparer le Block suivant.", + tie ? "Rejouer la phase cube" : "Appliquer et ouvrir la page chrono", + whiteName, + blackName, + $"Temps cube {MatchEngine.FormatStopwatch(white.Value)}", + $"Temps cube {MatchEngine.FormatStopwatch(black.Value)}", + winner == MatchEngine.ColorWhite ? "Gagne la phase cube" : tie ? "Egalite parfaite" : "Ne gagne pas la phase cube", + winner == MatchEngine.ColorBlack ? "Gagne la phase cube" : tie ? "Egalite parfaite" : "Ne gagne pas la phase cube", + "Aucun impact chrono en mode Twice", + "Aucun impact chrono en mode Twice", + tie); + } + + private void UpdateResultModalState() + { + var resultView = BuildResultView(); + if (resultView is null) + { + ShowResultModal = false; + _resultModalKey = null; + return; + } + + if (_resultModalKey != resultView.Key) + { + _resultModalKey = resultView.Key; + ShowResultModal = true; + } + } + + private bool IsHoldReady(string color) + => _holdStates[color].Armed && MatchEngine.NowUnixMs() - _holdStates[color].StartedAt >= MatchEngine.CubeStartHoldMs; + + private static string BoolString(bool value) + => value ? "true" : "false"; + + private sealed class CubeHoldState + { + public bool Armed { get; set; } + public long StartedAt { get; set; } + } + + private sealed record CubeSummaryView( + string Title, + string Subtitle, + string CenterLabel, + string CenterValue, + string SpineLabel, + string SpineHeadline, + string SpineText, + string PrimaryButtonText, + string HelpStatus, + bool PrimaryDisabled); + + private sealed record CubeZoneView( + string Name, + string ResultText, + string MetaText, + string ButtonText, + string Hint, + bool Disabled, + bool HoldArmed, + bool HoldReady, + string ProgressStyle); + + private sealed record CubeResultView( + string Key, + string Title, + string Winner, + string Outcome, + string Summary, + string ActionLabel, + string WhiteName, + string BlackName, + string WhiteTime, + string BlackTime, + string WhiteDetail, + string BlackDetail, + string WhiteClock, + string BlackClock, + bool ReplayRequired); +} diff --git a/ChessCubing.App/Pages/Home.razor b/ChessCubing.App/Pages/Home.razor new file mode 100644 index 0000000..95d8a00 --- /dev/null +++ b/ChessCubing.App/Pages/Home.razor @@ -0,0 +1,238 @@ +@page "/" +@page "/index.html" + +ChessCubing Arena | Accueil + + +
+
+ +
+
+
+ + Icone ChessCubing + + +

Association ChessCubing

+

Les echecs rencontrent le Rubik's Cube

+

+ ChessCubing propose un jeu hybride simple a comprendre, intense a + vivre et tres plaisant a regarder. On joue une partie d'echecs, on + passe par une phase cube obligatoire, puis la partie repart avec un + nouveau rythme. +

+ +
+ + +
+ +
+
+
+
+

L'association

+

Une passerelle entre deux passions

+
+

+ L'association ChessCubing veut creer des moments de rencontre, de + decouverte et de jeu partage autour d'un format hybride. +

+
+ +
+
+ Partager + Faire decouvrir un format original +

+ L'idee est simple : reunir des profils differents autour d'un + jeu lisible, vivant et immediatement intriguant. +

+
+
+ Rassembler + Creer un terrain commun +

+ Joueurs d'echecs, cubers, curieux, familles ou clubs peuvent se + retrouver dans une experience facile a lancer et a commenter. +

+
+
+ Animer + Donner envie de revenir jouer +

+ Le melange de reflexion, de vitesse et de rebondissements donne + au format un cote spectaculaire qui marche tres bien en + initiation comme en evenement. +

+
+
+
+ +
+
+
+

Le jeu en simple

+

Comment se passe une partie

+
+
+ +
+
+ 1 + On joue une partie d'echecs +

+ La partie avance par sequences courtes, ce qui garde un tres bon + rythme et rend l'action facile a suivre. +

+
+
+ 2 + On resout un cube +

+ Les deux joueurs enchainent avec une phase cube obligatoire qui + sert de relance, de pression et de bascule. +

+
+
+ 3 + Le match repart +

+ Selon le mode choisi, la phase cube donne l'initiative ou agit + sur les chronos. Le duel ne retombe jamais. +

+
+
+
+ +
+
+
+

Pourquoi ca plait

+

Un format qui donne envie d'essayer

+
+
+ +
+
+ Tres lisible +

On comprend vite quand la pression monte et pourquoi le cube change tout.

+
+
+ Toujours relance +

Chaque phase cube remet du suspense dans la partie et ouvre de nouvelles options.

+
+
+ Convivial +

Le format marche bien en initiation, en animation de club ou en demonstration publique.

+
+
+ Memorable +

On ressort d'une partie avec des coups, des temps et des retournements dont on se souvient.

+
+
+
+ +
+
+
+

Deux formats

+

Deux manieres de vivre le duel

+
+

+ Le reglement officiel propose deux lectures du meme concept, l'une + plus orientee initiative, l'autre plus orientee gestion du temps. +

+
+ +
+
+
+ ChessCubing Twice +

Le cube donne l'elan

+

+ Le joueur le plus rapide sur la phase cube prend le depart de + la partie suivante et peut meme obtenir un double coup dans + certaines situations. +

+
+
+ Mode nerveux + Initiative forte + Effet immediat +
+
+ +
+
+ ChessCubing Time +

Le cube agit sur les chronos

+

+ Ici, on garde le trait mais la performance sur le cube retire + ou ajoute du temps selon l'alternance des blocs. +

+
+
+ Gestion de temps + Blocs - et + + Suspense permanent +
+
+
+
+ +
+
+
+

Envie de tester

+

Choisis ton entree

+
+

+ Tu peux decouvrir le jeu tranquillement avec la page reglement ou + aller directement vers l'application officielle de match. +

+
+ + +
+
+
diff --git a/ChessCubing.App/Pages/NotFound.razor b/ChessCubing.App/Pages/NotFound.razor new file mode 100644 index 0000000..4aec759 --- /dev/null +++ b/ChessCubing.App/Pages/NotFound.razor @@ -0,0 +1,39 @@ +@page "/not-found" + +ChessCubing Arena | Page introuvable + + +
+
+ + diff --git a/ChessCubing.App/Pages/RulesPage.razor b/ChessCubing.App/Pages/RulesPage.razor new file mode 100644 index 0000000..1c4fafc --- /dev/null +++ b/ChessCubing.App/Pages/RulesPage.razor @@ -0,0 +1,344 @@ +@page "/reglement" +@page "/reglement.html" + +ChessCubing Arena | Reglement officiel + + +
+
+ +
+
+
+ + Icone ChessCubing + + +

Referentiel officiel

+

Reglement ChessCubing

+

+ Cette page reprend les regles officielles en vigueur du + ChessCubing Twice et du ChessCubing Time pour offrir une lecture + rapide, claire et directement exploitable en club, en demonstration + ou en arbitrage. +

+
+ Twice V2 + Time V1 + Entree en vigueur le 4 fevrier 2026 + Fin uniquement par mat ou abandon +
+ +
+ + +
+ +
+
+
+
+

Vue d'ensemble

+

Le match en 4 temps

+
+

+ Les deux reglements partagent la meme colonne vertebrale : un + match en parties successives, interrompues par une phase cube + obligatoire. +

+
+ +
+
+ 1. Avant le match + Installation et tirage +

+ L'arbitre controle le materiel, les cubes, les caches, les + melanges, puis le tirage au sort qui attribue Blancs ou Noirs. +

+
+
+ 2. Partie d'echecs + 180 secondes de jeu +

+ Chaque partie comporte une phase d'echecs limitee par une duree + fixe et par un quota de coups selon le format FAST, FREEZE ou + MASTERS. +

+
+
+ 3. Phase cube + Un cube identique par joueur +

+ L'application designe le numero du cube, les deux joueurs + resolvent le meme melange, et le resultat influe ensuite selon + le mode choisi. +

+
+
+ 4. Fin de partie + Jamais au temps +

+ La partie se termine uniquement par echec et mat ou abandon, + jamais par simple chute au temps ou depassement d'une partie. +

+
+
+
+ +
+
+
+

Materiel commun

+

Base officielle

+
+
+ +
    +
  • Un echiquier et un jeu de pieces reglementaires.
  • +
  • Huit Rubik's Cubes 3x3, soit quatre par joueur.
  • +
  • Des caches opaques numerotes de 1 a 4.
  • +
  • Des melanges strictement identiques pour chaque numero.
  • +
  • L'application officielle ChessCubing.
  • +
  • Un arbitre pour piloter le match et les transitions.
  • +
+
+ +
+
+
+

Arbitrage

+

Check-list terrain

+
+
+ +
    +
  • Verifier la presence des huit cubes et des caches numerotes.
  • +
  • Confirmer des melanges identiques sous chaque numero.
  • +
  • Preparer l'echiquier et la variante dans l'application.
  • +
  • Controler le tirage au sort avant la premiere partie.
  • +
  • Declencher chaque phase cube au bon moment.
  • +
  • Surveiller le respect du plafond de 120 s en mode Time.
  • +
+
+ +
+
+
+

Formats officiels

+

Twice et Time, cote a cote

+
+

+ Les deux formats partagent les parties et la phase cube, mais leur + logique d'avantage differe completement. +

+
+ +
+
+
+ Version V2 +

ChessCubing Twice

+

+ Le gagnant du cube obtient l'initiative sur la partie suivante, + avec une regle de double coup encadree. +

+
+ +
+ Partie : 180 s + Temps par coup : 20 s max + FAST / FREEZE / MASTERS : 6 / 8 / 10 +
+ +
+

Debut et fin des parties

+
    +
  • Les Blancs commencent la partie 1.
  • +
  • Aucun double coup n'est possible a la partie 1.
  • +
  • Une partie s'arrete a 180 s ou quand les deux quotas sont atteints.
  • +
  • Il est interdit de finir une partie avec un roi en echec.
  • +
  • Si le dernier coup donne echec, les coups necessaires pour parer sont joues hors quota.
  • +
+
+ +
+

Phase cube

+
    +
  • Le numero du cube est designe par l'application.
  • +
  • Les deux joueurs recoivent un melange identique.
  • +
  • Le joueur le plus rapide gagne la phase cube.
  • +
  • En cas d'egalite parfaite, la phase cube est rejouee.
  • +
  • Le gagnant du cube commence la partie suivante.
  • +
+
+ +
+ Double coup V2 + Condition stricte +

+ Le gagnant du cube ne doit pas avoir joue le dernier coup de + la partie precedente. Le premier coup est gratuit, non compte, + peut capturer mais ne peut pas donner echec. Le second compte + comme premier coup de la partie, peut donner echec, mais ne peut + capturer qu'un pion ou une piece mineure. +

+
+ +
+

Temps par coup et fin de partie

+
    +
  • Chaque coup doit etre joue en 20 secondes maximum.
  • +
  • En depassement, le coup est perdu et compte dans le quota.
  • +
  • Sur le premier coup d'un double coup, le depassement annule l'avantage.
  • +
  • Sur le second coup d'un double coup, le coup est perdu et comptabilise.
  • +
  • La partie se termine uniquement par mat ou abandon.
  • +
+
+
+ +
+
+ Version V1 +

ChessCubing Time

+

+ Ici, la phase cube n'offre pas l'initiative mais modifie les + chronos selon une alternance bloc - puis bloc +. +

+
+ +
+ Temps initial : 10 min / joueur + Block : 180 s + Cap cube pris en compte : 120 s +
+ +
+

Structure temporelle

+
    +
  • La structure des Blocks est identique a celle du Twice.
  • +
  • Les quotas de coups restent les memes : 6, 8 ou 10.
  • +
  • Chaque Block est suivi d'une phase cube obligatoire.
  • +
  • Le trait est conserve apres la phase cube.
  • +
  • Aucun systeme de priorite ou de double coup n'existe.
  • +
+
+ +
+
+ Block impair + Bloc - +

+ Le temps de resolution du cube est retire du chrono du + joueur concerne, avec un plafond de 120 secondes. +

+
+
+ Block pair + Bloc + +

+ Le temps de resolution du cube est ajoute au chrono adverse, + lui aussi plafonne a 120 secondes. +

+
+
+ +
+

Exemples officiels

+
    +
  • Bloc - : 35 s retire 35 s a son propre chrono.
  • +
  • Bloc - : 110 s retire 110 s a son propre chrono.
  • +
  • Bloc + : 25 s ajoute 25 s au chrono adverse.
  • +
  • Bloc + : 150 s ajoute 120 s au chrono adverse.
  • +
+
+ +
+ Reprise du jeu + Pas de coup pendant le cube +

+ Aucun coup ne peut etre joue pendant la phase cube. Des que + les deux resolutions sont terminees, les chronos sont ajustes + et la partie reprend immediatement. +

+
+ +
+

Fin de partie et vigilance

+
    +
  • La partie s'arrete uniquement par mat ou abandon volontaire.
  • +
  • L'arbitre surveille le chronometrage exact et le plafond de 120 s.
  • +
  • L'absence de priorite et de double coup fait partie des points cles du mode.
  • +
+
+
+
+
+ +
+
+
+

Sources

+

Documents officiels

+
+

+ Cette synthese reprend les versions officielles actuellement + embarquees dans l'application. +

+
+ + +
+
+
diff --git a/ChessCubing.App/Program.cs b/ChessCubing.App/Program.cs new file mode 100644 index 0000000..be6caa2 --- /dev/null +++ b/ChessCubing.App/Program.cs @@ -0,0 +1,14 @@ +using ChessCubing.App; +using ChessCubing.App.Services; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); +builder.RootComponents.Add("#app"); +builder.RootComponents.Add("head::after"); + +builder.Services.AddScoped(_ => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +await builder.Build().RunAsync(); diff --git a/ChessCubing.App/Properties/launchSettings.json b/ChessCubing.App/Properties/launchSettings.json new file mode 100644 index 0000000..345c357 --- /dev/null +++ b/ChessCubing.App/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "http://localhost:5263", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/ChessCubing.App/Services/BrowserBridge.cs b/ChessCubing.App/Services/BrowserBridge.cs new file mode 100644 index 0000000..150d833 --- /dev/null +++ b/ChessCubing.App/Services/BrowserBridge.cs @@ -0,0 +1,30 @@ +using Microsoft.JSInterop; + +namespace ChessCubing.App.Services; + +public sealed class BrowserBridge(IJSRuntime jsRuntime) +{ + public ValueTask StartViewportAsync() + => jsRuntime.InvokeVoidAsync("chesscubingViewport.start"); + + public ValueTask SetBodyStateAsync(string? page, string? bodyClass) + => jsRuntime.InvokeVoidAsync("chesscubingPage.setBodyState", page, bodyClass ?? string.Empty); + + public ValueTask ReadMatchJsonAsync() + => jsRuntime.InvokeAsync("chesscubingStorage.getMatchState", MatchStore.StorageKey, MatchStore.WindowNameKey); + + public ValueTask WriteMatchJsonAsync(string json) + => jsRuntime.InvokeVoidAsync("chesscubingStorage.setMatchState", MatchStore.StorageKey, MatchStore.WindowNameKey, json); + + public ValueTask ClearMatchAsync() + => jsRuntime.InvokeVoidAsync("chesscubingStorage.clearMatchState", MatchStore.StorageKey); + + public ValueTask PlayCubePhaseAlertAsync() + => jsRuntime.InvokeAsync("chesscubingAudio.playCubePhaseAlert"); + + public ValueTask PrimeAudioAsync() + => jsRuntime.InvokeVoidAsync("chesscubingAudio.prime"); + + public ValueTask ForceRefreshAsync(string path) + => jsRuntime.InvokeVoidAsync("chesscubingBrowser.forceRefresh", path); +} diff --git a/ChessCubing.App/Services/MatchEngine.cs b/ChessCubing.App/Services/MatchEngine.cs new file mode 100644 index 0000000..a81fb2b --- /dev/null +++ b/ChessCubing.App/Services/MatchEngine.cs @@ -0,0 +1,1020 @@ +using System.Globalization; +using ChessCubing.App.Models; + +namespace ChessCubing.App.Services; + +public static class MatchEngine +{ + public const string PhaseBlock = "block"; + public const string PhaseCube = "cube"; + public const string ColorWhite = "white"; + public const string ColorBlack = "black"; + public const string ModeTwice = "twice"; + public const string ModeTime = "time"; + public const string PresetFast = "fast"; + public const string PresetFreeze = "freeze"; + public const string PresetMasters = "masters"; + public const long DefaultBlockDurationMs = 180_000; + public const long DefaultMoveLimitMs = 20_000; + public const long TimeModeInitialClockMs = 600_000; + public const long CubeTimeCapMs = 120_000; + public const long CubeStartHoldMs = 2_000; + + public static readonly IReadOnlyDictionary Presets = + new Dictionary(StringComparer.Ordinal) + { + [PresetFast] = new("FAST", 6, "6 coups par joueur."), + [PresetFreeze] = new("FREEZE", 8, "8 coups par joueur."), + [PresetMasters] = new("MASTERS", 10, "10 coups par joueur."), + }; + + public static readonly IReadOnlyDictionary Modes = + new Dictionary(StringComparer.Ordinal) + { + [ModeTwice] = new("ChessCubing Twice", "Le gagnant du cube ouvre le Block suivant."), + [ModeTime] = new("ChessCubing Time", "Chronos cumules et alternance bloc - / bloc +."), + }; + + public static MatchState CreateMatch(MatchConfig config) + { + var normalizedMode = Modes.ContainsKey(config.Mode) ? config.Mode : ModeTwice; + var normalizedPreset = Presets.ContainsKey(config.Preset) ? config.Preset : PresetFast; + config.Mode = normalizedMode; + config.Preset = normalizedPreset; + config.BlockDurationMs = GetBlockDurationMs(config); + config.MoveLimitMs = GetMoveLimitMs(config); + config.TimeInitialMs = GetTimeInitialMs(config); + config.MatchLabel = SanitizeText(config.MatchLabel) is { Length: > 0 } label ? label : "Rencontre ChessCubing"; + config.WhiteName = SanitizeText(config.WhiteName) is { Length: > 0 } white ? white : "Blanc"; + config.BlackName = SanitizeText(config.BlackName) is { Length: > 0 } black ? black : "Noir"; + config.ArbiterName = SanitizeText(config.ArbiterName); + config.EventName = SanitizeText(config.EventName); + config.Notes = SanitizeText(config.Notes); + + var quota = Presets[config.Preset].Quota; + var match = new MatchState + { + SchemaVersion = 3, + Config = config, + Phase = PhaseBlock, + Running = false, + LastTickAt = null, + BlockNumber = 1, + CurrentTurn = ColorWhite, + BlockRemainingMs = config.BlockDurationMs, + MoveRemainingMs = config.MoveLimitMs, + Quota = quota, + Moves = new PlayerIntPair(), + Clocks = normalizedMode == ModeTime + ? new PlayerLongPair + { + White = GetTimeInitialMs(config), + Black = GetTimeInitialMs(config), + } + : null, + LastMover = null, + AwaitingBlockClosure = false, + ClosureReason = string.Empty, + Result = null, + Cube = CreateCubeState(), + DoubleCoup = new DoubleCoupState + { + Eligible = false, + Step = 0, + Starter = ColorWhite, + }, + History = [], + }; + + LogEvent( + match, + UsesMoveLimit(config.Mode) + ? $"Match cree en mode {Modes[config.Mode].Label}, cadence {Presets[config.Preset].Label}, Block {FormatClock(config.BlockDurationMs)} et coup {FormatClock(config.MoveLimitMs)}." + : $"Match cree en mode {Modes[config.Mode].Label}, cadence {Presets[config.Preset].Label}, Block {FormatClock(config.BlockDurationMs)} et chrono initial {FormatClock(GetTimeInitialMs(config))} par joueur, sans temps par coup."); + LogEvent(match, $"Les Blancs commencent {FormatBlockHeading(config, 1)}."); + return match; + } + + public static bool NormalizeRecoveredMatch(MatchState storedMatch) + { + var changed = false; + + storedMatch.Config ??= new MatchConfig(); + storedMatch.History ??= []; + storedMatch.Cube ??= CreateCubeState(); + storedMatch.Cube.History ??= []; + storedMatch.Cube.PlayerState ??= new CubePlayerStates(); + storedMatch.Cube.PlayerState.White = NormalizeCubePlayerState(storedMatch.Cube.PlayerState.White); + storedMatch.Cube.PlayerState.Black = NormalizeCubePlayerState(storedMatch.Cube.PlayerState.Black); + storedMatch.Cube.Times ??= new PlayerNullableLongPair(); + storedMatch.DoubleCoup ??= new DoubleCoupState + { + Eligible = false, + Step = 0, + Starter = ColorWhite, + }; + storedMatch.Moves ??= new PlayerIntPair(); + + var blockDurationMs = GetBlockDurationMs(storedMatch); + var moveLimitMs = GetMoveLimitMs(storedMatch); + var timeInitialMs = GetTimeInitialMs(storedMatch); + + if (storedMatch.SchemaVersion != 3) + { + storedMatch.SchemaVersion = 3; + changed = true; + } + + if (storedMatch.Config.Mode is not (ModeTwice or ModeTime)) + { + storedMatch.Config.Mode = ModeTwice; + changed = true; + } + + if (!Presets.ContainsKey(storedMatch.Config.Preset)) + { + storedMatch.Config.Preset = PresetFast; + changed = true; + } + + if (storedMatch.Config.BlockDurationMs != blockDurationMs) + { + storedMatch.Config.BlockDurationMs = blockDurationMs; + changed = true; + } + + if (storedMatch.Config.MoveLimitMs != moveLimitMs) + { + storedMatch.Config.MoveLimitMs = moveLimitMs; + changed = true; + } + + if (storedMatch.Config.TimeInitialMs != timeInitialMs) + { + storedMatch.Config.TimeInitialMs = timeInitialMs; + changed = true; + } + + if (storedMatch.BlockRemainingMs <= 0) + { + storedMatch.BlockRemainingMs = blockDurationMs; + changed = true; + } + + if (storedMatch.MoveRemainingMs <= 0) + { + storedMatch.MoveRemainingMs = moveLimitMs; + changed = true; + } + + if (storedMatch.BlockNumber <= 0) + { + storedMatch.BlockNumber = 1; + changed = true; + } + + if (storedMatch.Quota <= 0) + { + storedMatch.Quota = Presets[storedMatch.Config.Preset].Quota; + changed = true; + } + + if (storedMatch.CurrentTurn is not (ColorWhite or ColorBlack)) + { + storedMatch.CurrentTurn = ColorWhite; + changed = true; + } + + if (IsTimeMode(storedMatch) && storedMatch.Clocks is null) + { + storedMatch.Clocks = new PlayerLongPair + { + White = timeInitialMs, + Black = timeInitialMs, + }; + changed = true; + } + + if (!IsTimeMode(storedMatch)) + { + storedMatch.Clocks = null; + } + + if (storedMatch.Phase == PhaseBlock && storedMatch.LastTickAt is not null && storedMatch.LastTickAt <= 0) + { + storedMatch.LastTickAt = null; + changed = true; + } + + if (storedMatch.Cube.Round <= 0) + { + storedMatch.Cube.Round = 1; + changed = true; + } + + storedMatch.Cube.Running = IsAnyCubeTimerRunning(storedMatch); + + if (storedMatch.AwaitingBlockClosure && storedMatch.Phase == PhaseBlock) + { + OpenCubePhase(storedMatch, string.IsNullOrWhiteSpace(storedMatch.ClosureReason) ? "La phase chess etait deja terminee." : storedMatch.ClosureReason); + changed = true; + } + + return changed; + } + + public static bool SyncRunningState(MatchState? storedMatch, long? nowMs = null) + { + if (storedMatch is null || !string.IsNullOrEmpty(storedMatch.Result)) + { + return false; + } + + if (!storedMatch.Running || storedMatch.Phase != PhaseBlock || storedMatch.LastTickAt is null) + { + return false; + } + + var now = nowMs ?? NowUnixMs(); + var delta = now - storedMatch.LastTickAt.Value; + if (delta <= 0) + { + return false; + } + + storedMatch.LastTickAt = now; + storedMatch.BlockRemainingMs = Math.Max(0, storedMatch.BlockRemainingMs - delta); + if (UsesMoveLimit(storedMatch)) + { + storedMatch.MoveRemainingMs = Math.Max(0, storedMatch.MoveRemainingMs - delta); + } + + if (storedMatch.Clocks is not null) + { + if (storedMatch.CurrentTurn == ColorWhite) + { + storedMatch.Clocks.White -= delta; + } + else + { + storedMatch.Clocks.Black -= delta; + } + } + + if (storedMatch.BlockRemainingMs == 0) + { + RequestBlockClosure( + storedMatch, + $"Le temps {GetBlockGenitivePhrase(storedMatch)} {FormatClock(GetBlockDurationMs(storedMatch))} est ecoule."); + } + else if (UsesMoveLimit(storedMatch) && storedMatch.MoveRemainingMs == 0) + { + RegisterMoveTimeout(storedMatch, true); + } + + return true; + } + + public static void StartBlock(MatchState storedMatch) + { + if (storedMatch.Phase != PhaseBlock || !string.IsNullOrEmpty(storedMatch.Result)) + { + return; + } + + storedMatch.Running = true; + storedMatch.AwaitingBlockClosure = false; + storedMatch.ClosureReason = string.Empty; + storedMatch.LastTickAt = NowUnixMs(); + LogEvent( + storedMatch, + storedMatch.BlockNumber == 1 && storedMatch.Moves.White == 0 && storedMatch.Moves.Black == 0 + ? $"{FormatBlockHeading(storedMatch, 1)} demarre." + : $"{FormatBlockHeading(storedMatch, storedMatch.BlockNumber)} relance."); + } + + public static void PauseBlock(MatchState storedMatch) + { + if (!storedMatch.Running) + { + return; + } + + storedMatch.Running = false; + storedMatch.LastTickAt = null; + LogEvent(storedMatch, $"{FormatBlockHeading(storedMatch, storedMatch.BlockNumber)} passe en pause."); + } + + public static void RequestBlockClosure(MatchState storedMatch, string reason) + { + if (storedMatch.Phase != PhaseBlock) + { + return; + } + + storedMatch.Running = false; + storedMatch.LastTickAt = null; + storedMatch.AwaitingBlockClosure = false; + storedMatch.ClosureReason = string.Empty; + storedMatch.MoveRemainingMs = GetMoveLimitMs(storedMatch); + LogEvent(storedMatch, $"{reason} Passage automatique vers la page cube."); + OpenCubePhase(storedMatch, reason); + } + + public static void OpenCubePhase(MatchState storedMatch, string reason = "") + { + storedMatch.Phase = PhaseCube; + storedMatch.Running = false; + storedMatch.LastTickAt = null; + storedMatch.AwaitingBlockClosure = false; + storedMatch.ClosureReason = string.Empty; + storedMatch.Cube.Number = PickCubeNumber(); + storedMatch.Cube.Running = false; + storedMatch.Cube.StartedAt = null; + storedMatch.Cube.ElapsedMs = 0; + storedMatch.Cube.PhaseAlertPending = true; + storedMatch.Cube.Times = new PlayerNullableLongPair(); + storedMatch.Cube.PlayerState = new CubePlayerStates + { + White = CreateCubePlayerState(), + Black = CreateCubePlayerState(), + }; + storedMatch.Cube.Round = 1; + LogEvent( + storedMatch, + $"{(string.IsNullOrWhiteSpace(reason) ? string.Empty : $"{reason} ")}Page cube ouverte. Cube n{storedMatch.Cube.Number} designe."); + } + + public static void StartCubeTimer(MatchState storedMatch, string color) + { + if (storedMatch.Phase != PhaseCube || !string.IsNullOrEmpty(storedMatch.Result)) + { + return; + } + + if (storedMatch.Cube.Number is null) + { + storedMatch.Cube.Number = PickCubeNumber(); + } + + if (GetCubeTime(storedMatch.Cube.Times, color) is not null) + { + return; + } + + var playerState = GetCubePlayerState(storedMatch.Cube.PlayerState, color); + if (playerState.Running) + { + return; + } + + playerState.Running = true; + playerState.StartedAt = NowUnixMs(); + storedMatch.Cube.Running = true; + LogEvent( + storedMatch, + $"{PlayerName(storedMatch, color)} demarre son chrono cube sur le cube n{storedMatch.Cube.Number}."); + } + + public static void CaptureCubeTime(MatchState storedMatch, string color) + { + if (storedMatch.Phase != PhaseCube) + { + return; + } + + var playerState = GetCubePlayerState(storedMatch.Cube.PlayerState, color); + if (!playerState.Running || GetCubeTime(storedMatch.Cube.Times, color) is not null) + { + return; + } + + var elapsedMs = playerState.ElapsedMs + (NowUnixMs() - (playerState.StartedAt ?? NowUnixMs())); + SetCubeTime(storedMatch.Cube.Times, color, elapsedMs); + playerState.ElapsedMs = elapsedMs; + playerState.StartedAt = null; + playerState.Running = false; + storedMatch.Cube.ElapsedMs = Math.Max(GetCubePlayerElapsed(storedMatch, ColorWhite), GetCubePlayerElapsed(storedMatch, ColorBlack)); + storedMatch.Cube.Running = IsAnyCubeTimerRunning(storedMatch); + LogEvent(storedMatch, $"{PlayerName(storedMatch, color)} arrete le cube en {FormatStopwatch(elapsedMs)}."); + + if (storedMatch.Cube.Times.White is not null && storedMatch.Cube.Times.Black is not null) + { + storedMatch.Cube.Running = false; + storedMatch.Cube.History.Add( + new CubeHistoryEntry + { + BlockNumber = storedMatch.BlockNumber, + Number = storedMatch.Cube.Number, + White = storedMatch.Cube.Times.White, + Black = storedMatch.Cube.Times.Black, + }); + } + } + + public static void ApplyCubeOutcome(MatchState storedMatch) + { + if (storedMatch.Phase != PhaseCube) + { + return; + } + + var white = storedMatch.Cube.Times.White; + var black = storedMatch.Cube.Times.Black; + if (white is null || black is null) + { + return; + } + + if (storedMatch.Config.Mode == ModeTwice) + { + var winner = white < black ? ColorWhite : ColorBlack; + PrepareNextTwiceBlock(storedMatch, winner); + return; + } + + ApplyTimeAdjustments(storedMatch, white.Value, black.Value); + PrepareNextTimeBlock(storedMatch); + } + + public static void PrepareNextTwiceBlock(MatchState storedMatch, string winner) + { + var hadDouble = storedMatch.LastMover != winner && !string.IsNullOrEmpty(storedMatch.LastMover); + LogEvent(storedMatch, $"{PlayerName(storedMatch, winner)} gagne la phase cube et ouvrira le Block suivant."); + + storedMatch.BlockNumber += 1; + storedMatch.Phase = PhaseBlock; + storedMatch.Running = false; + storedMatch.LastTickAt = null; + storedMatch.BlockRemainingMs = GetBlockDurationMs(storedMatch); + storedMatch.MoveRemainingMs = GetMoveLimitMs(storedMatch); + storedMatch.Moves = new PlayerIntPair(); + storedMatch.CurrentTurn = winner; + storedMatch.DoubleCoup = new DoubleCoupState + { + Eligible = hadDouble, + Step = hadDouble ? 1 : 0, + Starter = winner, + }; + ResetCubeState(storedMatch); + + if (hadDouble) + { + LogEvent(storedMatch, $"Double coup disponible pour {PlayerName(storedMatch, winner)}."); + } + else + { + LogEvent(storedMatch, "Aucun double coup n'est accorde sur ce depart."); + } + } + + public static void PrepareNextTimeBlock(MatchState storedMatch) + { + storedMatch.BlockNumber += 1; + storedMatch.Phase = PhaseBlock; + storedMatch.Running = false; + storedMatch.LastTickAt = null; + storedMatch.BlockRemainingMs = GetBlockDurationMs(storedMatch); + storedMatch.MoveRemainingMs = GetMoveLimitMs(storedMatch); + storedMatch.Moves = new PlayerIntPair(); + storedMatch.DoubleCoup = new DoubleCoupState + { + Eligible = false, + Step = 0, + Starter = storedMatch.CurrentTurn, + }; + ResetCubeState(storedMatch); + LogEvent( + storedMatch, + $"{FormatBlockHeading(storedMatch, storedMatch.BlockNumber)} pret. Le trait est conserve : {PlayerName(storedMatch, storedMatch.CurrentTurn)} reprend."); + } + + public static void ApplyTimeAdjustments(MatchState storedMatch, long whiteTime, long blackTime) + { + var preview = GetTimeAdjustmentPreview(storedMatch, whiteTime, blackTime); + if (preview is null || storedMatch.Clocks is null) + { + return; + } + + storedMatch.Clocks.White = preview.WhiteAfter; + storedMatch.Clocks.Black = preview.BlackAfter; + + if (preview.BlockType == "minus") + { + LogEvent( + storedMatch, + $"Bloc - : {FormatStopwatch(preview.CappedWhite)} retire au chrono Blanc, {FormatStopwatch(preview.CappedBlack)} retire au chrono Noir."); + } + else + { + LogEvent( + storedMatch, + $"Bloc + : {FormatStopwatch(preview.CappedBlack)} ajoute au chrono Blanc, {FormatStopwatch(preview.CappedWhite)} ajoute au chrono Noir."); + } + } + + public static void ReplayCubePhase(MatchState storedMatch) + { + if (storedMatch.Phase != PhaseCube) + { + return; + } + + storedMatch.Cube.Running = false; + storedMatch.Cube.StartedAt = null; + storedMatch.Cube.ElapsedMs = 0; + storedMatch.Cube.PhaseAlertPending = true; + storedMatch.Cube.Times = new PlayerNullableLongPair(); + storedMatch.Cube.PlayerState = new CubePlayerStates + { + White = CreateCubePlayerState(), + Black = CreateCubePlayerState(), + }; + storedMatch.Cube.Round += 1; + LogEvent(storedMatch, $"Phase cube relancee (tentative {storedMatch.Cube.Round})."); + } + + public static void RegisterCountedMove(MatchState storedMatch, string source) + { + if (storedMatch.Phase != PhaseBlock) + { + return; + } + + var actor = storedMatch.CurrentTurn; + if (GetMoveCount(storedMatch.Moves, actor) >= storedMatch.Quota) + { + LogEvent(storedMatch, $"{PlayerName(storedMatch, actor)} a deja atteint son quota. Utiliser le mode hors quota si necessaire."); + return; + } + + SetMoveCount(storedMatch.Moves, actor, GetMoveCount(storedMatch.Moves, actor) + 1); + storedMatch.LastMover = actor; + storedMatch.MoveRemainingMs = GetMoveLimitMs(storedMatch); + + if (source == "double") + { + storedMatch.DoubleCoup.Step = 0; + } + + LogEvent( + storedMatch, + $"{PlayerName(storedMatch, actor)} valide son coup ({GetMoveCount(storedMatch.Moves, actor)} / {storedMatch.Quota})."); + + storedMatch.CurrentTurn = OpponentOf(actor); + VerifyQuotaCompletion(storedMatch); + } + + public static void RegisterFreeDoubleMove(MatchState storedMatch) + { + if (storedMatch.DoubleCoup.Step != 1) + { + return; + } + + var actor = storedMatch.CurrentTurn; + storedMatch.LastMover = actor; + storedMatch.MoveRemainingMs = GetMoveLimitMs(storedMatch); + storedMatch.DoubleCoup.Step = 2; + LogEvent(storedMatch, $"Premier coup gratuit du double coup joue par {PlayerName(storedMatch, actor)}."); + } + + public static void RegisterMoveTimeout(MatchState storedMatch, bool automatic) + { + if (storedMatch.Phase != PhaseBlock || !UsesMoveLimit(storedMatch)) + { + return; + } + + var actor = storedMatch.CurrentTurn; + + if (storedMatch.DoubleCoup.Step == 1) + { + storedMatch.DoubleCoup.Step = 0; + storedMatch.MoveRemainingMs = GetMoveLimitMs(storedMatch); + LogEvent(storedMatch, $"Depassement sur le premier coup gratuit de {PlayerName(storedMatch, actor)} : le double coup est annule."); + return; + } + + if (GetMoveCount(storedMatch.Moves, actor) < storedMatch.Quota) + { + SetMoveCount(storedMatch.Moves, actor, GetMoveCount(storedMatch.Moves, actor) + 1); + } + + if (storedMatch.DoubleCoup.Step == 2) + { + storedMatch.DoubleCoup.Step = 0; + } + + storedMatch.CurrentTurn = OpponentOf(actor); + storedMatch.MoveRemainingMs = GetMoveLimitMs(storedMatch); + LogEvent( + storedMatch, + $"{(automatic ? "Temps par coup ecoule." : $"Depassement manuel du temps par coup {FormatClock(GetMoveLimitMs(storedMatch))}.")} {PlayerName(storedMatch, actor)} perd son coup, qui reste compte dans le quota."); + VerifyQuotaCompletion(storedMatch); + } + + public static void VerifyQuotaCompletion(MatchState storedMatch) + { + if (storedMatch.Moves.White >= storedMatch.Quota && storedMatch.Moves.Black >= storedMatch.Quota) + { + RequestBlockClosure(storedMatch, "Les deux joueurs ont atteint leur quota de coups."); + } + } + + public static void SetResult(MatchState storedMatch, string result) + { + if (!string.IsNullOrEmpty(storedMatch.Result)) + { + return; + } + + storedMatch.Running = false; + storedMatch.LastTickAt = null; + storedMatch.Result = result; + LogEvent(storedMatch, $"{ResultText(storedMatch)}."); + } + + public static string RenderModeContext(MatchState storedMatch) + { + if (storedMatch.Config.Mode == ModeTime) + { + return GetTimeBlockType(storedMatch.BlockNumber) == "minus" + ? "Bloc - : temps cube retire a son propre chrono" + : "Bloc + : temps cube ajoute au chrono adverse"; + } + + if (storedMatch.DoubleCoup.Step == 1) + { + return $"Double coup actif pour {PlayerName(storedMatch, storedMatch.DoubleCoup.Starter)}"; + } + + if (storedMatch.DoubleCoup.Step == 2) + { + return "Deuxieme coup du double en attente"; + } + + return "Le gagnant du cube ouvrira la prochaine partie"; + } + + public static string RenderLastCube(MatchState storedMatch, string color) + { + var last = storedMatch.Cube.History.Count == 0 ? null : storedMatch.Cube.History[^1]; + if (last is null) + { + return "--"; + } + + var value = color == ColorWhite ? last.White : last.Black; + return value is null ? "--" : FormatStopwatch(value.Value); + } + + public static string RenderCubeElapsed(MatchState storedMatch) + { + if (storedMatch.Phase != PhaseCube) + { + return "00:00.0"; + } + + return FormatStopwatch(Math.Max(GetCubePlayerElapsed(storedMatch, ColorWhite), GetCubePlayerElapsed(storedMatch, ColorBlack))); + } + + public static string RenderCubeMeta(MatchState storedMatch, string color) + { + var time = color == ColorWhite ? storedMatch.Cube.Times.White : storedMatch.Cube.Times.Black; + if (time is null) + { + return string.Empty; + } + + if (storedMatch.Config.Mode != ModeTime) + { + return "temps capture"; + } + + if (storedMatch.Cube.Times.White is not null && storedMatch.Cube.Times.Black is not null) + { + var preview = GetTimeAdjustmentPreview(storedMatch, storedMatch.Cube.Times.White.Value, storedMatch.Cube.Times.Black.Value); + if (preview is null) + { + return string.Empty; + } + + var delta = color == ColorWhite ? preview.WhiteDelta : preview.BlackDelta; + var cappedTime = color == ColorWhite ? preview.CappedWhite : preview.CappedBlack; + var wasCapped = time.Value > cappedTime; + return wasCapped + ? $"Impact chrono {FormatSignedStopwatch(delta)} (cap {FormatStopwatch(cappedTime)})" + : $"Impact chrono {FormatSignedStopwatch(delta)}"; + } + + return time.Value <= CubeTimeCapMs + ? "impact chrono en attente" + : $"impact en attente, cap {FormatStopwatch(CubeTimeCapMs)}"; + } + + public static string ResultText(MatchState storedMatch) + { + return storedMatch.Result switch + { + ColorWhite => $"Victoire de {PlayerName(storedMatch, ColorWhite)}", + ColorBlack => $"Victoire de {PlayerName(storedMatch, ColorBlack)}", + _ => "Partie arretee", + }; + } + + public static string RouteForMatch(MatchState? storedMatch) + { + if (storedMatch is null) + { + return "/application.html"; + } + + return storedMatch.Phase == PhaseCube && string.IsNullOrEmpty(storedMatch.Result) + ? "/cube.html" + : "/chrono.html"; + } + + public static bool IsTimeMode(object? matchOrConfig) + => ResolveMode(matchOrConfig) == ModeTime; + + public static bool UsesMoveLimit(object? matchOrConfig) + => !IsTimeMode(matchOrConfig); + + public static long GetTimeInitialMs(object? matchOrConfig) + { + return matchOrConfig switch + { + MatchState match => NormalizeDurationMs(match.Config.TimeInitialMs, TimeModeInitialClockMs), + MatchConfig config => NormalizeDurationMs(config.TimeInitialMs, TimeModeInitialClockMs), + _ => TimeModeInitialClockMs, + }; + } + + public static string GetBlockLabel(object? _) + => "Block"; + + public static string GetBlockPhrase(object? _) + => "Le Block"; + + public static string GetBlockGenitivePhrase(object? _) + => "du Block"; + + public static string FormatBlockHeading(object? matchOrConfig, int blockNumber) + => $"{GetBlockLabel(matchOrConfig)} {blockNumber}"; + + public static string PlayerName(MatchState storedMatch, string color) + => color == ColorWhite ? storedMatch.Config.WhiteName : storedMatch.Config.BlackName; + + public static string OpponentOf(string color) + => color == ColorWhite ? ColorBlack : ColorWhite; + + public static string GetTimeBlockType(int blockNumber) + => blockNumber % 2 == 1 ? "minus" : "plus"; + + public static void LogEvent(MatchState storedMatch, string message) + { + storedMatch.History.Add( + new MatchHistoryEntry + { + Message = message, + Time = DateTime.Now.ToString("HH:mm:ss", CultureInfo.GetCultureInfo("fr-FR")), + }); + } + + public static string FormatClock(long ms) + { + var totalSeconds = Math.Max(0, (long)Math.Floor(ms / 1000d)); + var minutes = (totalSeconds / 60).ToString("00", CultureInfo.InvariantCulture); + var seconds = (totalSeconds % 60).ToString("00", CultureInfo.InvariantCulture); + return $"{minutes}:{seconds}"; + } + + public static string FormatSignedClock(long ms) + { + var negative = ms < 0 ? "-" : string.Empty; + var totalSeconds = (long)Math.Floor(Math.Abs(ms) / 1000d); + var minutes = (totalSeconds / 60).ToString("00", CultureInfo.InvariantCulture); + var seconds = (totalSeconds % 60).ToString("00", CultureInfo.InvariantCulture); + return $"{negative}{minutes}:{seconds}"; + } + + public static string FormatSignedStopwatch(long ms) + => $"{(ms < 0 ? "-" : "+")}{FormatStopwatch(ms)}"; + + public static string FormatStopwatch(long ms) + { + var absolute = Math.Abs(ms); + var totalSeconds = (long)Math.Floor(absolute / 1000d); + var minutes = (totalSeconds / 60).ToString("00", CultureInfo.InvariantCulture); + var seconds = (totalSeconds % 60).ToString("00", CultureInfo.InvariantCulture); + var tenths = (absolute % 1000) / 100; + return $"{minutes}:{seconds}.{tenths}"; + } + + public static string SanitizeText(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + var sanitized = value.Replace("<", string.Empty, StringComparison.Ordinal) + .Replace(">", string.Empty, StringComparison.Ordinal); + return string.Join(" ", sanitized.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)).Trim(); + } + + public static bool IsSupportedSchemaVersion(int version) + => version is 2 or 3; + + public static long GetBlockDurationMs(object? matchOrConfig) + { + return matchOrConfig switch + { + MatchState match => NormalizeDurationMs(match.Config.BlockDurationMs, DefaultBlockDurationMs), + MatchConfig config => NormalizeDurationMs(config.BlockDurationMs, DefaultBlockDurationMs), + _ => DefaultBlockDurationMs, + }; + } + + public static long GetMoveLimitMs(object? matchOrConfig) + { + return matchOrConfig switch + { + MatchState match => NormalizeDurationMs(match.Config.MoveLimitMs, DefaultMoveLimitMs), + MatchConfig config => NormalizeDurationMs(config.MoveLimitMs, DefaultMoveLimitMs), + _ => DefaultMoveLimitMs, + }; + } + + public static long NormalizeDurationMs(long value, long fallbackMs) + => value <= 0 ? fallbackMs : value; + + public static TimeAdjustmentPreview? GetTimeAdjustmentPreview(MatchState storedMatch, long whiteTime, long blackTime) + { + if (storedMatch.Clocks is null) + { + return null; + } + + var cappedWhite = Math.Min(whiteTime, CubeTimeCapMs); + var cappedBlack = Math.Min(blackTime, CubeTimeCapMs); + var blockType = GetTimeBlockType(storedMatch.BlockNumber); + var whiteDelta = blockType == "minus" ? -cappedWhite : cappedBlack; + var blackDelta = blockType == "minus" ? -cappedBlack : cappedWhite; + + return new TimeAdjustmentPreview( + blockType, + whiteTime < blackTime ? ColorWhite : blackTime < whiteTime ? ColorBlack : null, + cappedWhite, + cappedBlack, + whiteDelta, + blackDelta, + storedMatch.Clocks.White + whiteDelta, + storedMatch.Clocks.Black + blackDelta); + } + + public static CubePlayerState CreateCubePlayerState() + => new() + { + Running = false, + StartedAt = null, + ElapsedMs = 0, + }; + + public static CubePlayerState NormalizeCubePlayerState(CubePlayerState? playerState) + { + return new CubePlayerState + { + Running = playerState?.Running ?? false, + StartedAt = playerState?.StartedAt, + ElapsedMs = playerState?.ElapsedMs ?? 0, + }; + } + + public static bool IsAnyCubeTimerRunning(MatchState storedMatch) + => storedMatch.Cube.PlayerState.White.Running || storedMatch.Cube.PlayerState.Black.Running; + + public static long GetCubePlayerElapsed(MatchState storedMatch, string color) + { + var playerState = GetCubePlayerState(storedMatch.Cube.PlayerState, color); + var capturedTime = GetCubeTime(storedMatch.Cube.Times, color); + + if (capturedTime is not null) + { + return capturedTime.Value; + } + + if (playerState.Running && playerState.StartedAt is not null) + { + return playerState.ElapsedMs + (NowUnixMs() - playerState.StartedAt.Value); + } + + return playerState.ElapsedMs; + } + + public static string FormatCubePlayerTime(MatchState storedMatch, string color) + { + var elapsed = GetCubePlayerElapsed(storedMatch, color); + var playerState = GetCubePlayerState(storedMatch.Cube.PlayerState, color); + var captured = GetCubeTime(storedMatch.Cube.Times, color); + + if (playerState.Running) + { + return FormatStopwatch(elapsed); + } + + if (elapsed <= 0 && captured is null) + { + return "--"; + } + + return FormatStopwatch(elapsed); + } + + public static long NowUnixMs() + => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + private static string ResolveMode(object? matchOrConfig) + { + return matchOrConfig switch + { + MatchState match => match.Config.Mode, + MatchConfig config => config.Mode, + string mode => mode, + _ => ModeTwice, + }; + } + + private static CubeState CreateCubeState() + { + return new CubeState + { + Number = null, + Running = false, + StartedAt = null, + ElapsedMs = 0, + PhaseAlertPending = false, + Times = new PlayerNullableLongPair(), + PlayerState = new CubePlayerStates + { + White = CreateCubePlayerState(), + Black = CreateCubePlayerState(), + }, + Round = 1, + History = [], + }; + } + + private static void ResetCubeState(MatchState storedMatch) + { + storedMatch.Cube.Running = false; + storedMatch.Cube.StartedAt = null; + storedMatch.Cube.ElapsedMs = 0; + storedMatch.Cube.PhaseAlertPending = false; + storedMatch.Cube.Times = new PlayerNullableLongPair(); + storedMatch.Cube.PlayerState = new CubePlayerStates + { + White = CreateCubePlayerState(), + Black = CreateCubePlayerState(), + }; + storedMatch.Cube.Number = null; + } + + private static int PickCubeNumber() + => Random.Shared.Next(1, 5); + + private static int GetMoveCount(PlayerIntPair pair, string color) + => color == ColorWhite ? pair.White : pair.Black; + + private static void SetMoveCount(PlayerIntPair pair, string color, int value) + { + if (color == ColorWhite) + { + pair.White = value; + } + else + { + pair.Black = value; + } + } + + private static long? GetCubeTime(PlayerNullableLongPair pair, string color) + => color == ColorWhite ? pair.White : pair.Black; + + private static void SetCubeTime(PlayerNullableLongPair pair, string color, long value) + { + if (color == ColorWhite) + { + pair.White = value; + } + else + { + pair.Black = value; + } + } + + private static CubePlayerState GetCubePlayerState(CubePlayerStates states, string color) + => color == ColorWhite ? states.White : states.Black; +} diff --git a/ChessCubing.App/Services/MatchStore.cs b/ChessCubing.App/Services/MatchStore.cs new file mode 100644 index 0000000..49d3d4d --- /dev/null +++ b/ChessCubing.App/Services/MatchStore.cs @@ -0,0 +1,106 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using ChessCubing.App.Models; + +namespace ChessCubing.App.Services; + +public sealed class MatchStore(BrowserBridge browser) +{ + public const string StorageKey = "chesscubing-arena-state-v2"; + public const string WindowNameKey = "chesscubing-arena-state-v2:"; + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + private bool _dirty; + private long _lastPersistedAt; + + public MatchState? Current { get; private set; } + + public bool IsLoaded { get; private set; } + + public async Task EnsureLoadedAsync() + { + if (IsLoaded) + { + return; + } + + try + { + var raw = await browser.ReadMatchJsonAsync(); + if (!string.IsNullOrWhiteSpace(raw)) + { + var parsed = JsonSerializer.Deserialize(raw, JsonOptions); + if (parsed is not null && MatchEngine.IsSupportedSchemaVersion(parsed.SchemaVersion)) + { + MatchEngine.NormalizeRecoveredMatch(parsed); + Current = parsed; + } + } + } + catch + { + Current = null; + } + + IsLoaded = true; + _lastPersistedAt = MatchEngine.NowUnixMs(); + } + + public void SetCurrent(MatchState? match) + { + Current = match; + MarkDirty(); + } + + public void MarkDirty() + => _dirty = true; + + public async Task SaveAsync() + { + if (!IsLoaded) + { + return; + } + + if (Current is null) + { + await browser.ClearMatchAsync(); + _dirty = false; + _lastPersistedAt = MatchEngine.NowUnixMs(); + return; + } + + var json = JsonSerializer.Serialize(Current, JsonOptions); + await browser.WriteMatchJsonAsync(json); + _dirty = false; + _lastPersistedAt = MatchEngine.NowUnixMs(); + } + + public async Task FlushIfDueAsync(long minimumIntervalMs = 1_000) + { + if (!_dirty) + { + return; + } + + if (MatchEngine.NowUnixMs() - _lastPersistedAt < minimumIntervalMs) + { + return; + } + + await SaveAsync(); + } + + public async Task ClearAsync() + { + Current = null; + IsLoaded = true; + _dirty = false; + _lastPersistedAt = MatchEngine.NowUnixMs(); + await browser.ClearMatchAsync(); + } +} diff --git a/ChessCubing.App/_Imports.razor b/ChessCubing.App/_Imports.razor new file mode 100644 index 0000000..dc2c25b --- /dev/null +++ b/ChessCubing.App/_Imports.razor @@ -0,0 +1,11 @@ +@using System.Globalization +@using System.Net.Http +@using ChessCubing.App +@using ChessCubing.App.Components +@using ChessCubing.App.Models +@using ChessCubing.App.Services +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop diff --git a/ChessCubing.App/wwwroot/icon-192.png b/ChessCubing.App/wwwroot/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..166f56da7612ea74df6a297154c8d281a4f28a14 GIT binary patch literal 2626 zcmV-I3cdA-P)v0A9xRwxP|bki~~&uFk>U z#P+PQh zyZ;-jwXKqnKbb6)@RaxQz@vm={%t~VbaZrdbaZrdbaeEeXj>~BG?&`J0XrqR#sSlO zg~N5iUk*15JibvlR1f^^1czzNKWvoJtc!Sj*G37QXbZ8LeD{Fzxgdv#Q{x}ytfZ5q z+^k#NaEp>zX_8~aSaZ`O%B9C&YLHb(mNtgGD&Kezd5S@&C=n~Uy1NWHM`t07VQP^MopUXki{2^#ryd94>UJMYW|(#4qV`kb7eD)Q=~NN zaVIRi@|TJ!Rni8J=5DOutQ#bEyMVr8*;HU|)MEKmVC+IOiDi9y)vz=rdtAUHW$yjt zrj3B7v(>exU=IrzC<+?AE=2vI;%fafM}#ShGDZx=0Nus5QHKdyb9pw&4>4XCpa-o?P(Gnco1CGX|U> z$f+_tA3+V~<{MU^A%eP!8R*-sD9y<>Jc7A(;aC5hVbs;kX9&Sa$JMG!W_BLFQa*hM zri__C@0i0U1X#?)Y=)>JpvTnY6^s;fu#I}K9u>OldV}m!Ch`d1Vs@v9 zb}w(!TvOmSzmMBa9gYvD4xocL2r0ds6%Hs>Z& z#7#o9PGHDmfG%JQq`O5~dt|MAQN@2wyJw_@``7Giyy(yyk(m8U*kk5$X1^;3$a3}N^Lp6hE5!#8l z#~NYHmKAs6IAe&A;bvM8OochRmXN>`D`{N$%#dZCRxp4-dJ?*3P}}T`tYa3?zz5BA zTu7uE#GsDpZ$~j9q=Zq!LYjLbZPXFILZK4?S)C-zE1(dC2d<7nO4-nSCbV#9E|E1MM|V<9>i4h?WX*r*ul1 z5#k6;po8z=fdMiVVz*h+iaTlz#WOYmU^SX5#97H~B32s-#4wk<1NTN#g?LrYieCu> zF7pbOLR;q2D#Q`^t%QcY06*X-jM+ei7%ZuanUTH#9Y%FBi*Z#22({_}3^=BboIsbg zR0#jJ>9QR8SnmtSS6x($?$}6$x+q)697#m${Z@G6Ujf=6iO^S}7P`q8DkH!IHd4lB zDzwxt3BHsPAcXFFY^Fj}(073>NL_$A%v2sUW(CRutd%{G`5ow?L`XYSO*Qu?x+Gzv zBtR}Y6`XF4xX7)Z04D+fH;TMapdQFFameUuHL34NN)r@aF4RO%x&NApeWGtr#mG~M z6sEIZS;Uj1HB1*0hh=O@0q1=Ia@L>-tETu-3n(op+97E z#&~2xggrl(LA|giII;RwBlX2^Q`B{_t}gxNL;iB11gEPC>v` zb4SJ;;BFOB!{chn>?cCeGDKuqI0+!skyWTn*k!WiPNBf=8rn;@y%( znhq%8fj2eAe?`A5mP;TE&iLEmQ^xV%-kmC-8mWao&EUK_^=GW-Y3z ksi~={si~={skwfB0gq6itke#r1ONa407*qoM6N<$g11Kq@c;k- literal 0 HcmV?d00001 diff --git a/ChessCubing.App/wwwroot/index.html b/ChessCubing.App/wwwroot/index.html new file mode 100644 index 0000000..d3a3989 --- /dev/null +++ b/ChessCubing.App/wwwroot/index.html @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + ChessCubing Arena + + + + + + + + + + + + + +
+
+
+ ChessCubing Arena + Chargement de l'application Blazor... +
+
+
+ +
+ Une erreur inattendue est survenue. + Recharger + x +
+ + + + diff --git a/ChessCubing.App/wwwroot/js/chesscubing-interop.js b/ChessCubing.App/wwwroot/js/chesscubing-interop.js new file mode 100644 index 0000000..c1de074 --- /dev/null +++ b/ChessCubing.App/wwwroot/js/chesscubing-interop.js @@ -0,0 +1,200 @@ +(() => { + const assetTokenStorageKey = "chesscubing-arena-asset-token"; + let viewportStarted = false; + let audioContext = null; + + function syncViewportHeight() { + const visibleHeight = window.visualViewport?.height ?? window.innerHeight; + const viewportTopOffset = window.visualViewport?.offsetTop ?? 0; + const viewportHeight = Math.max( + visibleHeight + viewportTopOffset, + window.innerHeight, + document.documentElement.clientHeight, + ); + + document.documentElement.style.setProperty("--app-visible-height", `${Math.round(visibleHeight)}px`); + document.documentElement.style.setProperty("--app-viewport-top", `${Math.round(viewportTopOffset)}px`); + document.documentElement.style.setProperty("--app-viewport-height", `${Math.round(viewportHeight)}px`); + } + + function startViewport() { + if (viewportStarted) { + return; + } + + viewportStarted = true; + syncViewportHeight(); + window.addEventListener("load", syncViewportHeight); + window.addEventListener("resize", syncViewportHeight); + window.addEventListener("scroll", syncViewportHeight, { passive: true }); + window.addEventListener("pageshow", syncViewportHeight); + window.addEventListener("orientationchange", syncViewportHeight); + window.visualViewport?.addEventListener("resize", syncViewportHeight); + window.visualViewport?.addEventListener("scroll", syncViewportHeight); + window.setTimeout(syncViewportHeight, 0); + window.setTimeout(syncViewportHeight, 150); + window.setTimeout(syncViewportHeight, 400); + window.requestAnimationFrame(() => window.requestAnimationFrame(syncViewportHeight)); + } + + function getAudioContext() { + const AudioContextClass = window.AudioContext || window.webkitAudioContext; + if (!AudioContextClass) { + return null; + } + + if (!audioContext) { + try { + audioContext = new AudioContextClass(); + } catch { + return null; + } + } + + return audioContext; + } + + function primeAudio() { + const context = getAudioContext(); + if (!context || context.state === "running") { + return; + } + + context.resume().catch(() => undefined); + } + + function playCubePhaseAlert() { + primeAudio(); + const context = getAudioContext(); + if (!context || context.state !== "running") { + return false; + } + + const pattern = [ + { frequency: 740, offset: 0, duration: 0.12, gain: 0.035 }, + { frequency: 988, offset: 0.18, duration: 0.12, gain: 0.04 }, + { frequency: 1318, offset: 0.36, duration: 0.2, gain: 0.05 }, + ]; + const startAt = context.currentTime + 0.02; + + pattern.forEach(({ frequency, offset, duration, gain }) => { + const oscillator = context.createOscillator(); + const envelope = context.createGain(); + const toneStartAt = startAt + offset; + + oscillator.type = "triangle"; + oscillator.frequency.setValueAtTime(frequency, toneStartAt); + envelope.gain.setValueAtTime(0.0001, toneStartAt); + envelope.gain.exponentialRampToValueAtTime(gain, toneStartAt + 0.02); + envelope.gain.exponentialRampToValueAtTime(0.0001, toneStartAt + duration); + + oscillator.connect(envelope); + envelope.connect(context.destination); + oscillator.start(toneStartAt); + oscillator.stop(toneStartAt + duration + 0.03); + }); + + return true; + } + + window.chesscubingPage = { + setBodyState(page, bodyClass) { + if (page) { + document.body.dataset.page = page; + } else { + delete document.body.dataset.page; + } + + document.body.className = bodyClass || ""; + }, + }; + + window.chesscubingViewport = { + start: startViewport, + }; + + window.chesscubingStorage = { + getMatchState(storageKey, windowNameKey) { + try { + const raw = window.localStorage.getItem(storageKey); + if (raw) { + return raw; + } + } catch { + } + + try { + if (!window.name || !window.name.startsWith(windowNameKey)) { + return null; + } + + return window.name.slice(windowNameKey.length); + } catch { + return null; + } + }, + + setMatchState(storageKey, windowNameKey, value) { + try { + window.name = `${windowNameKey}${value}`; + } catch { + } + + try { + window.localStorage.setItem(storageKey, value); + } catch { + } + }, + + clearMatchState(storageKey) { + try { + window.localStorage.removeItem(storageKey); + } catch { + } + + try { + window.name = ""; + } catch { + } + }, + }; + + window.chesscubingAudio = { + prime: primeAudio, + playCubePhaseAlert, + }; + + window.chesscubingBrowser = { + async forceRefresh(path) { + const refreshToken = `${Date.now()}`; + + try { + window.sessionStorage.setItem(assetTokenStorageKey, refreshToken); + } catch { + } + + if ("caches" in window) { + try { + const cacheKeys = await window.caches.keys(); + await Promise.all(cacheKeys.map((cacheKey) => window.caches.delete(cacheKey))); + } catch { + } + } + + if ("serviceWorker" in navigator) { + try { + const registrations = await navigator.serviceWorker.getRegistrations(); + await Promise.all(registrations.map((registration) => registration.update().catch(() => undefined))); + await Promise.all(registrations.map((registration) => registration.unregister().catch(() => undefined))); + } catch { + } + } + + const targetUrl = new URL(path, window.location.href); + targetUrl.searchParams.set("refresh", refreshToken); + window.location.replace(targetUrl.toString()); + }, + }; + + startViewport(); +})(); diff --git a/Dockerfile b/Dockerfile index 239d315..a6b54c8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,15 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +COPY ChessCubing.App/ChessCubing.App.csproj ChessCubing.App/ +RUN dotnet restore ChessCubing.App/ChessCubing.App.csproj + +COPY ChessCubing.App/ ChessCubing.App/ +COPY ethan/ ethan/ +COPY favicon.png logo.png transparent.png styles.css site.webmanifest ChessCubing_Time_Reglement_Officiel_V1-1.pdf ChessCubing_Twice_Reglement_Officiel_V2-1.pdf ./ +RUN dotnet publish ChessCubing.App/ChessCubing.App.csproj -c Release -o /app/publish + FROM nginx:1.27-alpine COPY nginx.conf /etc/nginx/conf.d/default.conf -COPY . /usr/share/nginx/html +COPY --from=build /app/publish/wwwroot /usr/share/nginx/html diff --git a/README.md b/README.md index af0ccc7..ccdd607 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,35 @@ # ChessCubing Arena -Application web mobile-first pour téléphone et tablette, pensée comme application officielle de suivi de match pour `ChessCubing Twice` et `ChessCubing Time`. +Application web mobile-first pour telephone et tablette, maintenant migree vers **Blazor WebAssembly en C# / .NET 10**. -## Ce que fait cette première version +## Ce que fait l'application - configure une rencontre `Twice` ou `Time` -- sépare l'application en pages dédiées : configuration, phase chrono, phase cube -- permet de définir librement le temps de partie et le temps par coup +- separe l'experience en pages dediees : configuration, phase chrono, phase cube +- permet de definir librement le temps de partie et le temps par coup - suit les quotas `FAST`, `FREEZE` et `MASTERS` -- orchestre la phase cube avec désignation du cube, capture des temps et préparation de la partie suivante +- orchestre la phase cube avec designation du cube, capture des temps et preparation de la partie suivante - applique la logique du double coup V2 en `Twice` - applique les ajustements `bloc -` et `bloc +` en `Time` avec plafond de 120 s pris en compte -- conserve un historique local dans le navigateur -- propose une page chrono pensée pour le téléphone avec deux grandes zones tactiles, une par joueur -- ouvre automatiquement la page cube dès que la phase chess de la partie est terminée +- conserve l'etat du match dans le navigateur +- propose une page chrono pensee pour le telephone avec deux grandes zones tactiles +- ouvre automatiquement la page cube des que la phase chess est terminee -## Hypothèse de produit +## Architecture -Cette version est volontairement construite comme une **application d'arbitrage et de direction de match** autour d'un vrai échiquier physique, et non comme un moteur d'échecs complet. C'est le choix le plus fidèle aux règlements fournis et le plus réaliste pour une utilisation immédiate en club, en démonstration ou en tournoi. +Le coeur de l'application se trouve dans `ChessCubing.App/`. -## Démarrage avec Docker +- `ChessCubing.App/Pages/` : pages Razor du site et de l'application +- `ChessCubing.App/Services/MatchEngine.cs` : logique metier des matchs +- `ChessCubing.App/Services/MatchStore.cs` : persistance navigateur +- `ChessCubing.App/wwwroot/` : assets statiques, manifeste, PDFs, appli Ethan +- `docker-compose.yml` + `Dockerfile` : build Blazor puis service via nginx + +Le projet continue a exposer les routes historiques `index.html`, `application.html`, `chrono.html`, `cube.html` et `reglement.html`. + +## Demarrage local + +### Avec Docker ```bash docker compose down @@ -28,16 +38,23 @@ docker compose up -d --build L'application est ensuite disponible sur `http://localhost:8080`. -## Déploiement dans un LXC Proxmox +### Avec .NET 10 -Deux scripts Bash permettent de créer un conteneur LXC Debian sur Proxmox puis de le mettre à jour depuis Git. +```bash +dotnet restore ChessCubing.App/ChessCubing.App.csproj +dotnet run --project ChessCubing.App/ChessCubing.App.csproj +``` -Prérequis sur la machine qui lance les scripts : +## Deploiement dans un LXC Proxmox + +Deux scripts Bash permettent de creer un conteneur LXC Debian sur Proxmox puis de le mettre a jour depuis Git. + +Prerrequis sur la machine qui lance les scripts : - en mode distant : `ssh` et `sshpass` -- en mode local sur l'hôte Proxmox : aucun paquet supplémentaire n'est installé sur Proxmox +- en mode local sur l'hote Proxmox : aucun paquet supplementaire n'est installe sur Proxmox -Le déploiement dans le LXC n'utilise pas Docker. Le script installe `nginx`, `git` et `rsync` dans le conteneur, clone le dépôt principal, synchronise aussi le projet d'Ethan, puis publie uniquement les fichiers web. +Le deploiement dans le LXC n'utilise pas Docker. Le script clone le depot, publie l'application Blazor dans le conteneur, puis sert le resultat via `nginx`. ### Installer un nouveau LXC @@ -54,38 +71,7 @@ Version "curl | bash" : bash -c "$(curl -fsSL https://git.jeannerot.fr/christophe/chesscubing/raw/branch/main/install-chesscubing-proxmox.sh)" ``` -Cette version pose les questions nécessaires si les variables d'environnement ne sont pas déjà définies. - -Si elle est lancée directement sur l'hôte Proxmox, elle passe automatiquement en mode local : - -- elle ne demande ni serveur, ni login, ni mot de passe SSH -- elle n'installe rien sur l'hôte Proxmox -- elle crée uniquement le LXC puis installe les dépendances dans ce LXC - -Valeurs par défaut utiles : - -- LXC nommé `chesscubing-web` -- IP du LXC en `dhcp` -- branche Git `main` -- dépôt `https://git.jeannerot.fr/christophe/chesscubing.git` -- dépôt Ethan `https://git.jeannerot.fr/Mineloulou/Chesscubing.git` - -Options utiles si besoin : - -- `--ctid 120` -- `--lxc-ip 192.168.1.50/24 --gateway 192.168.1.1` -- `--template-storage local` -- `--rootfs-storage local-lvm` -- `--branch main` -- `--ethan-branch main` - -À la fin, le script affiche : - -- le `CTID` -- le mot de passe `root` du LXC -- l'URL probable du site - -### Mettre à jour depuis Git +### Mettre a jour depuis Git ```bash ./scripts/update-proxmox-lxc.sh \ @@ -101,30 +87,14 @@ Version "curl | bash" : bash -c "$(curl -fsSL https://git.jeannerot.fr/christophe/chesscubing/raw/branch/main/update-chesscubing-proxmox.sh)" ``` -Sur l'hôte Proxmox, cette commande met à jour le LXC local sans passer par SSH. -Par défaut, elle cible le conteneur `chesscubing-web` sans demander le `CTID`. +## Fichiers cles -On peut aussi cibler le conteneur par nom si on n'a pas le `CTID` : - -```bash -./scripts/update-proxmox-lxc.sh \ - --proxmox-host 10.0.0.2 \ - --proxmox-user root@pam \ - --proxmox-password 'secret' \ - --hostname chesscubing-web -``` - -Le script de mise à jour exécute un `git pull --ff-only` pour le dépôt principal et le dépôt d'Ethan dans le conteneur, puis republie les fichiers statiques via `nginx`, y compris la route `/ethan/`. - -## Fichiers clés - -- `index.html` : page d'accueil du site -- `application.html` : page de configuration et reprise de match -- `chrono.html` : page dédiée à la phase chrono -- `cube.html` : page dédiée à la phase cube -- `reglement.html` : page éditoriale qui présente le règlement officiel -- `styles.css` : design mobile/tablette -- `app.js` : logique de match et arbitrage -- `docker-compose.yml` + `Dockerfile` : exécution locale -- `scripts/install-proxmox-lxc.sh` : création et déploiement d'un LXC Proxmox -- `scripts/update-proxmox-lxc.sh` : mise à jour d'un LXC existant depuis Git +- `ChessCubing.App/Pages/Home.razor` : page d'accueil du site +- `ChessCubing.App/Pages/ApplicationPage.razor` : configuration et reprise de match +- `ChessCubing.App/Pages/ChronoPage.razor` : phase chrono +- `ChessCubing.App/Pages/CubePage.razor` : phase cube +- `ChessCubing.App/Pages/RulesPage.razor` : synthese du reglement +- `ChessCubing.App/Services/MatchEngine.cs` : regles de jeu et transitions +- `docker-compose.yml` + `Dockerfile` : execution locale +- `scripts/install-proxmox-lxc.sh` : creation et deploiement d'un LXC Proxmox +- `scripts/update-proxmox-lxc.sh` : mise a jour d'un LXC existant depuis Git diff --git a/scripts/install-proxmox-lxc.sh b/scripts/install-proxmox-lxc.sh index 746f75f..ba7b6c1 100755 --- a/scripts/install-proxmox-lxc.sh +++ b/scripts/install-proxmox-lxc.sh @@ -339,10 +339,10 @@ done pct exec "$ctid" -- true >/dev/null 2>&1 || die "Le LXC n'est pas joignable apres le demarrage." -printf 'Installation de nginx, git et rsync dans le conteneur...\n' -ct_exec "apt-get update && apt-get install -y ca-certificates git nginx rsync" +printf 'Installation de nginx, git, rsync et des prerequis de build dans le conteneur...\n' +ct_exec "apt-get update && apt-get install -y ca-certificates curl gpg git nginx rsync" -ct_exec "install -d -m 0755 /opt/chesscubing/repo /opt/chesscubing/ethan-repo /var/www/chesscubing/current" +ct_exec "install -d -m 0755 /opt/chesscubing/repo /opt/chesscubing/ethan-repo /opt/chesscubing/publish /var/www/chesscubing/current" printf 'Clonage du depot %s...\n' "$repo_url" ct_exec "if [ ! -d /opt/chesscubing/repo/.git ]; then \ @@ -363,11 +363,30 @@ trap 'printf \"Erreur: echec de la commande [%s] a la ligne %s.\\n\" \"\$BASH_CO main_repo_dir='/opt/chesscubing/repo' ethan_repo_dir='/opt/chesscubing/ethan-repo' +publish_root='/opt/chesscubing/publish' web_root='/var/www/chesscubing/current' main_branch=\"\${1:-${repo_branch}}\" ethan_repo_url='${ethan_repo_url}' ethan_branch='${ethan_repo_branch}' +ensure_dotnet_sdk() { + if command -v dotnet >/dev/null 2>&1; then + return 0 + fi + + apt-get update + apt-get install -y ca-certificates curl gpg + + if [[ ! -f /etc/apt/sources.list.d/microsoft-prod.list ]]; then + curl -fsSL https://packages.microsoft.com/config/debian/12/packages-microsoft-prod.deb -o /tmp/packages-microsoft-prod.deb + dpkg -i /tmp/packages-microsoft-prod.deb + rm -f /tmp/packages-microsoft-prod.deb + fi + + apt-get update + apt-get install -y dotnet-sdk-10.0 +} + sync_git_repo() { local repo_dir=\"\$1\" local repo_url=\"\$2\" @@ -401,6 +420,15 @@ sync_git_repo() { git clone --branch \"\$branch\" --single-branch \"\$repo_url\" \"\$repo_dir\" } +publish_blazor_app() { + local repo_dir=\"\$1\" + local output_dir=\"\$2\" + + ensure_dotnet_sdk + rm -rf \"\$output_dir\" + dotnet publish \"\$repo_dir/ChessCubing.App/ChessCubing.App.csproj\" -c Release -o \"\$output_dir\" +} + publish_static_tree() { local source_dir=\"\$1\" local destination_dir=\"\$2\" @@ -428,17 +456,12 @@ publish_static_tree() { sync_git_repo \"\$main_repo_dir\" '${repo_url}' \"\$main_branch\" 'principal' sync_git_repo \"\$ethan_repo_dir\" \"\$ethan_repo_url\" \"\$ethan_branch\" 'Ethan' -asset_version=\"\$(git -C \"\$main_repo_dir\" rev-parse --short HEAD)-\$(git -C \"\$ethan_repo_dir\" rev-parse --short HEAD)\" +install -d -m 0755 \"\$web_root\" \"\$publish_root\" -install -d -m 0755 \"\$web_root\" - -publish_static_tree \"\$main_repo_dir\" \"\$web_root\" +publish_blazor_app \"\$main_repo_dir\" \"\$publish_root/main\" +rsync -a --delete \"\$publish_root/main/wwwroot/\" \"\$web_root/\" publish_static_tree \"\$ethan_repo_dir\" \"\$web_root/ethan\" -while IFS= read -r -d '' html_file; do - LC_ALL=C LANG=C ASSET_VERSION=\"\$asset_version\" perl -0pi -e 's{((?:href|src)=\")(?!https?://|data:|//)([^\"?]+?\.(?:css|js|mjs|png|jpg|jpeg|svg|webp|ico|pdf|webmanifest))(?:\?[^\"]*)?(\")}{\$1 . \$2 . \"?v=\" . \$ENV{ASSET_VERSION} . \$3}ge' \"\$html_file\" -done < <(find \"\$web_root\" -type f -name '*.html' -print0) - chown -R www-data:www-data \"\$web_root\" nginx -t diff --git a/scripts/update-proxmox-lxc.sh b/scripts/update-proxmox-lxc.sh index 561a9ad..7669cbd 100755 --- a/scripts/update-proxmox-lxc.sh +++ b/scripts/update-proxmox-lxc.sh @@ -181,11 +181,30 @@ trap 'printf \"Erreur: echec de la commande [%s] a la ligne %s.\\n\" \"\$BASH_CO main_repo_dir='/opt/chesscubing/repo' ethan_repo_dir='/opt/chesscubing/ethan-repo' +publish_root='/opt/chesscubing/publish' web_root='/var/www/chesscubing/current' main_branch=\"\${1:-${repo_branch}}\" ethan_repo_url='${ethan_repo_url}' ethan_branch='${ethan_repo_branch}' +ensure_dotnet_sdk() { + if command -v dotnet >/dev/null 2>&1; then + return 0 + fi + + apt-get update + apt-get install -y ca-certificates curl gpg + + if [[ ! -f /etc/apt/sources.list.d/microsoft-prod.list ]]; then + curl -fsSL https://packages.microsoft.com/config/debian/12/packages-microsoft-prod.deb -o /tmp/packages-microsoft-prod.deb + dpkg -i /tmp/packages-microsoft-prod.deb + rm -f /tmp/packages-microsoft-prod.deb + fi + + apt-get update + apt-get install -y dotnet-sdk-10.0 +} + sync_git_repo() { local repo_dir=\"\$1\" local repo_url=\"\$2\" @@ -219,6 +238,15 @@ sync_git_repo() { git clone --branch \"\$branch\" --single-branch \"\$repo_url\" \"\$repo_dir\" } +publish_blazor_app() { + local repo_dir=\"\$1\" + local output_dir=\"\$2\" + + ensure_dotnet_sdk + rm -rf \"\$output_dir\" + dotnet publish \"\$repo_dir/ChessCubing.App/ChessCubing.App.csproj\" -c Release -o \"\$output_dir\" +} + publish_static_tree() { local source_dir=\"\$1\" local destination_dir=\"\$2\" @@ -246,17 +274,12 @@ publish_static_tree() { sync_git_repo \"\$main_repo_dir\" '' \"\$main_branch\" 'principal' sync_git_repo \"\$ethan_repo_dir\" \"\$ethan_repo_url\" \"\$ethan_branch\" 'Ethan' -asset_version=\"\$(git -C \"\$main_repo_dir\" rev-parse --short HEAD)-\$(git -C \"\$ethan_repo_dir\" rev-parse --short HEAD)\" +install -d -m 0755 \"\$web_root\" \"\$publish_root\" -install -d -m 0755 \"\$web_root\" - -publish_static_tree \"\$main_repo_dir\" \"\$web_root\" +publish_blazor_app \"\$main_repo_dir\" \"\$publish_root/main\" +rsync -a --delete \"\$publish_root/main/wwwroot/\" \"\$web_root/\" publish_static_tree \"\$ethan_repo_dir\" \"\$web_root/ethan\" -while IFS= read -r -d '' html_file; do - LC_ALL=C LANG=C ASSET_VERSION=\"\$asset_version\" perl -0pi -e 's{((?:href|src)=\")(?!https?://|data:|//)([^\"?]+?\.(?:css|js|mjs|png|jpg|jpeg|svg|webp|ico|pdf|webmanifest))(?:\?[^\"]*)?(\")}{\$1 . \$2 . \"?v=\" . \$ENV{ASSET_VERSION} . \$3}ge' \"\$html_file\" -done < <(find \"\$web_root\" -type f -name '*.html' -print0) - chown -R www-data:www-data \"\$web_root\" nginx -t