1021 lines
35 KiB
C#
1021 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 = 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;
|
|
}
|