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; }