Files
chesscubing/ChessCubing.App/Services/MatchEngine.cs

1031 lines
35 KiB
C#

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<string, MatchPresetInfo> Presets =
new Dictionary<string, MatchPresetInfo>(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<string, MatchModeInfo> Modes =
new Dictionary<string, MatchModeInfo>(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 = 4,
MatchId = Guid.NewGuid().ToString("N"),
Config = config,
Phase = PhaseBlock,
Running = false,
LastTickAt = null,
WhiteSubject = null,
BlackSubject = null,
BlockNumber = 1,
CurrentTurn = ColorWhite,
BlockRemainingMs = config.BlockDurationMs,
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,
ResultRecordedUtc = 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();
if (string.IsNullOrWhiteSpace(storedMatch.MatchId))
{
storedMatch.MatchId = Guid.NewGuid().ToString("N");
changed = true;
}
var blockDurationMs = GetBlockDurationMs(storedMatch);
var moveLimitMs = GetMoveLimitMs(storedMatch);
var timeInitialMs = GetTimeInitialMs(storedMatch);
if (storedMatch.SchemaVersion != 4)
{
storedMatch.SchemaVersion = 4;
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 or 4;
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;
}