diff --git a/RandomizerTMF.sln b/RandomizerTMF.sln index 971d53c..8619b41 100644 --- a/RandomizerTMF.sln +++ b/RandomizerTMF.sln @@ -15,6 +15,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Položky řešení", "Polo README.md = README.md EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{EF5000F9-46AE-451F-A50A-7E69DB05360E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RandomizerTMF.Logic.Tests", "Tests\RandomizerTMF.Logic.Tests\RandomizerTMF.Logic.Tests.csproj", "{5F783679-AC15-4941-BBE6-0038A15DF187}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -29,6 +33,10 @@ Global {A1DFC07E-66E3-418B-AB19-2435FA2E3A42}.Debug|Any CPU.Build.0 = Debug|Any CPU {A1DFC07E-66E3-418B-AB19-2435FA2E3A42}.Release|Any CPU.ActiveCfg = Release|Any CPU {A1DFC07E-66E3-418B-AB19-2435FA2E3A42}.Release|Any CPU.Build.0 = Release|Any CPU + {5F783679-AC15-4941-BBE6-0038A15DF187}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5F783679-AC15-4941-BBE6-0038A15DF187}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5F783679-AC15-4941-BBE6-0038A15DF187}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5F783679-AC15-4941-BBE6-0038A15DF187}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -36,6 +44,7 @@ Global GlobalSection(NestedProjects) = preSolution {CB7687F3-9485-44C6-A751-F63FCE8C8D23} = {C7477BCC-8CD0-47CF-9DCE-397A4D65FAC3} {A1DFC07E-66E3-418B-AB19-2435FA2E3A42} = {C7477BCC-8CD0-47CF-9DCE-397A4D65FAC3} + {5F783679-AC15-4941-BBE6-0038A15DF187} = {EF5000F9-46AE-451F-A50A-7E69DB05360E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {9F3B68FA-C841-400A-AAFE-0701C43ED400} diff --git a/Src/RandomizerTMF.Logic/CompiledRegex.cs b/Src/RandomizerTMF.Logic/CompiledRegex.cs new file mode 100644 index 0000000..65baead --- /dev/null +++ b/Src/RandomizerTMF.Logic/CompiledRegex.cs @@ -0,0 +1,9 @@ +using System.Text.RegularExpressions; + +namespace RandomizerTMF.Logic; + +internal static partial class CompiledRegex +{ + [GeneratedRegex("[^a-zA-Z0-9_.]+")] + public static partial Regex SpecialCharRegex(); +} diff --git a/Src/RandomizerTMF.Logic/Constants.cs b/Src/RandomizerTMF.Logic/Constants.cs index 3f651be..5c33428 100644 --- a/Src/RandomizerTMF.Logic/Constants.cs +++ b/Src/RandomizerTMF.Logic/Constants.cs @@ -17,5 +17,20 @@ public static class Constants public const string Skipped = "Skipped"; public const string DefaultReplayFileFormat = "{0}_{1}_{2}.Replay.Gbx"; public const string OfficialBlocksYml = "OfficialBlocks.yml"; + public const string MapSizesYml = "MapSizes.yml"; public const string Replays = "Replays"; + public const string Tracks = "Tracks"; + public const string Autosaves = "Autosaves"; + public const string Challenges = "Challenges"; + public const string Downloaded = "Downloaded"; + + public const string AlpineCar = "AlpineCar"; + public const string SnowCar = "SnowCar"; + public const string American = "American"; + public const string SpeedCar = "SpeedCar"; + public const string DesertCar = "DesertCar"; + public const string Rally = "Rally"; + public const string RallyCar = "RallyCar"; + public const string SportCar = "SportCar"; + public const string IslandCar = "IslandCar"; } diff --git a/Src/RandomizerTMF.Logic/DiscordRichPresenceConfig.cs b/Src/RandomizerTMF.Logic/DiscordRichPresenceConfig.cs new file mode 100644 index 0000000..af23bda --- /dev/null +++ b/Src/RandomizerTMF.Logic/DiscordRichPresenceConfig.cs @@ -0,0 +1,7 @@ +namespace RandomizerTMF.Logic; + +public class DiscordRichPresenceConfig +{ + public bool Disable { get; set; } + public bool DisableMapThumbnail { get; set; } +} diff --git a/Src/RandomizerTMF.Logic/DiscordRpcLogger.cs b/Src/RandomizerTMF.Logic/DiscordRpcLogger.cs new file mode 100644 index 0000000..1cf7f5e --- /dev/null +++ b/Src/RandomizerTMF.Logic/DiscordRpcLogger.cs @@ -0,0 +1,44 @@ +using Microsoft.Extensions.Logging; + +namespace RandomizerTMF.Logic; + +internal class DiscordRpcLogger : DiscordRPC.Logging.ILogger +{ + private readonly ILogger logger; + + public DiscordRpcLogger(ILogger logger) + { + this.logger = logger; + } + + public DiscordRPC.Logging.LogLevel Level { get; set; } = DiscordRPC.Logging.LogLevel.Info; + + public void Error(string message, params object[] args) + { + using var _ = CreateScope(); + logger.LogError(message, args); + } + + public void Info(string message, params object[] args) + { + using var _ = CreateScope(); + logger.LogInformation(message, args); + } + + public void Trace(string message, params object[] args) + { + // using var _ = CreateScope(); + // logger.LogTrace(message, args); + } + + public void Warning(string message, params object[] args) + { + using var _ = CreateScope(); + logger.LogWarning(message, args); + } + + private IDisposable? CreateScope() + { + return logger.BeginScope("Discord RPC"); + } +} diff --git a/Src/RandomizerTMF.Logic/ISessionMap.cs b/Src/RandomizerTMF.Logic/ISessionMap.cs index d1f9ff3..99eec9a 100644 --- a/Src/RandomizerTMF.Logic/ISessionMap.cs +++ b/Src/RandomizerTMF.Logic/ISessionMap.cs @@ -1,7 +1,6 @@ -namespace RandomizerTMF.Logic +namespace RandomizerTMF.Logic; + +public interface ISessionMap { - public interface ISessionMap - { - TimeSpan? LastTimestamp { get; set; } - } + TimeSpan? LastTimestamp { get; set; } } \ No newline at end of file diff --git a/Src/RandomizerTMF.Logic/LoggerToFile.cs b/Src/RandomizerTMF.Logic/LoggerToFile.cs index 52b5f3f..b793339 100644 --- a/Src/RandomizerTMF.Logic/LoggerToFile.cs +++ b/Src/RandomizerTMF.Logic/LoggerToFile.cs @@ -1,21 +1,28 @@ using Microsoft.Extensions.Logging; +using System.Collections.Immutable; +using System.Text; namespace RandomizerTMF.Logic; public class LoggerToFile : ILogger { - private readonly IList writers; - internal LogScope? CurrentScope { get; set; } - public LoggerToFile(params StreamWriter[] writers) + public ImmutableArray Writers { get; private set; } + + public LoggerToFile(StreamWriter writer) { - this.writers = writers; + Writers = ImmutableArray.Create(writer); } - public LoggerToFile(StreamWriter writer) + public void SetSessionWriter(StreamWriter writer) { - writers = new List { writer }; + Writers = ImmutableArray.Create(Writers[0], writer); + } + + public void RemoveSessionWriter() + { + Writers = ImmutableArray.Create(Writers[0]); } public IDisposable BeginScope(TState state) where TState : notnull @@ -56,13 +63,37 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except // CurrentScope is not utilized - foreach (var writer in writers) + var builder = new StringBuilder("["); + builder.Append(DateTime.Now.ToString()); + builder.Append(", "); + builder.Append(logLevel); + builder.Append("] "); + + var scope = CurrentScope; + + while (scope is not null) { - writer.WriteLine($"[{DateTime.Now}, {logLevel}] {message}"); + builder.Append(scope); + builder.Append(" => "); + scope = scope.Parent; + } - if (exception is not null) + builder.Append(message); + + foreach (var writer in Writers) + { + try + { + writer.WriteLine(builder); + + if (exception is not null) + { + writer.WriteLine(exception); + } + } + catch { - writer.WriteLine(exception); + // usually writer of ended session } } } diff --git a/Src/RandomizerTMF.Logic/MapSizes.yml b/Src/RandomizerTMF.Logic/MapSizes.yml new file mode 100644 index 0000000..13039e4 --- /dev/null +++ b/Src/RandomizerTMF.Logic/MapSizes.yml @@ -0,0 +1,29 @@ +Rally: +- [10, 18, 150] +- [20, 18, 60] +- [30, 18, 30] +- [32, 12, 32] +- [45, 18, 45] +Island: +- [36, 36, 36] +- [45, 36, 45] +Speed: +- [10, 18, 150] +- [20, 18, 60] +- [30, 18, 30] +- [32, 12, 32] +- [45, 18, 45] +Stadium: +- [32, 32, 32] +Coast: +- [36, 36, 36] +- [45, 36, 45] +Alpine: +- [10, 18, 150] +- [20, 18, 60] +- [30, 18, 30] +- [32, 12, 32] +- [45, 18, 45] +Bay: +- [36, 36, 36] +- [45, 36, 45] \ No newline at end of file diff --git a/Src/RandomizerTMF.Logic/NadeoIni.cs b/Src/RandomizerTMF.Logic/NadeoIni.cs index eb7b0ef..6cf6361 100644 --- a/Src/RandomizerTMF.Logic/NadeoIni.cs +++ b/Src/RandomizerTMF.Logic/NadeoIni.cs @@ -1,14 +1,16 @@ -namespace RandomizerTMF.Logic; +using System.IO.Abstractions; + +namespace RandomizerTMF.Logic; public class NadeoIni { public required string UserSubDir { get; init; } - public static NadeoIni Parse(string nadeoIniFilePath) + public static NadeoIni Parse(string nadeoIniFilePath, IFileSystem fileSystem) { var userSubDir = "TmForever"; - foreach (var line in File.ReadLines(nadeoIniFilePath)) + foreach (var line in fileSystem.File.ReadLines(nadeoIniFilePath)) { if (line.Length == 0 || line[0] is '#' or ';' or '[') { diff --git a/Src/RandomizerTMF.Logic/RandomizerConfig.cs b/Src/RandomizerTMF.Logic/RandomizerConfig.cs deleted file mode 100644 index e409643..0000000 --- a/Src/RandomizerTMF.Logic/RandomizerConfig.cs +++ /dev/null @@ -1,23 +0,0 @@ -using YamlDotNet.Serialization; - -namespace RandomizerTMF.Logic; - -public class RandomizerConfig -{ - public string? GameDirectory { get; set; } - public string? DownloadedMapsDirectory { get; set; } = Constants.DownloadedMapsDirectory; - - [YamlMember(Order = 998)] - public ModulesConfig Modules { get; set; } = new(); - - [YamlMember(Order = 999)] - public RandomizerRules Rules { get; set; } = new(); - - /// - /// {0} is the map name, {1} is the replay score (example: 9'59''59 in Race/Puzzle or 999_9'59''59 in Platform/Stunts), {2} is the player login. - /// - public string? ReplayFileFormat { get; set; } = Constants.DefaultReplayFileFormat; - - public int ReplayParseFailRetries { get; set; } = 10; - public int ReplayParseFailDelayMs { get; set; } = 50; -} diff --git a/Src/RandomizerTMF.Logic/RandomizerEngine.cs b/Src/RandomizerTMF.Logic/RandomizerEngine.cs deleted file mode 100644 index 2ba8a98..0000000 --- a/Src/RandomizerTMF.Logic/RandomizerEngine.cs +++ /dev/null @@ -1,1341 +0,0 @@ -using GBX.NET; -using GBX.NET.Engines.Game; -using GBX.NET.Exceptions; -using Microsoft.Extensions.Logging; -using RandomizerTMF.Logic.Exceptions; -using RandomizerTMF.Logic.TypeConverters; -using System.Collections.Concurrent; -using System.Diagnostics; -using System.Net; -using System.Text.RegularExpressions; -using TmEssentials; -using YamlDotNet.Serialization; - -namespace RandomizerTMF.Logic; - -public static partial class RandomizerEngine -{ - private static readonly int requestMaxAttempts = 10; - private static int requestAttempt; - - private static bool isActualSkipCancellation; - private static string? userDataDirectoryPath; - private static bool hasAutosavesScanned; - - public static ISerializer YamlSerializer { get; } = new SerializerBuilder() - .WithTypeConverter(new DateOnlyConverter()) - .WithTypeConverter(new DateTimeOffsetConverter()) - .WithTypeConverter(new TimeInt32Converter()) - .Build(); - - public static IDeserializer YamlDeserializer { get; } = new DeserializerBuilder() - .WithTypeConverter(new DateOnlyConverter()) - .WithTypeConverter(new DateTimeOffsetConverter()) - .WithTypeConverter(new TimeInt32Converter()) - .IgnoreUnmatchedProperties() - .Build(); - - public static RandomizerConfig Config { get; } - public static Dictionary> OfficialBlocks { get; } - - public static HttpClient Http { get; } - - public static string? TmForeverExeFilePath { get; set; } - public static string? TmUnlimiterExeFilePath { get; set; } - - /// - /// General directory of the user data. It also sets the , , and path with it. - /// - public static string? UserDataDirectoryPath - { - get => userDataDirectoryPath; - set - { - userDataDirectoryPath = value; - - AutosavesDirectoryPath = userDataDirectoryPath is null ? null : Path.Combine(userDataDirectoryPath, "Tracks", "Replays", "Autosaves"); - DownloadedDirectoryPath = userDataDirectoryPath is null ? null : Path.Combine(userDataDirectoryPath, "Tracks", "Challenges", "Downloaded", string.IsNullOrWhiteSpace(Config.DownloadedMapsDirectory) ? Constants.DownloadedMapsDirectory : Config.DownloadedMapsDirectory); - - AutosaveWatcher.Path = AutosavesDirectoryPath ?? ""; - } - } - - public static string? AutosavesDirectoryPath { get; private set; } - public static string? DownloadedDirectoryPath { get; private set; } - public static string SessionsDirectoryPath => Constants.Sessions; - - public static Task? CurrentSession { get; private set; } - public static CancellationTokenSource? CurrentSessionTokenSource { get; private set; } - public static CancellationTokenSource? SkipTokenSource { get; private set; } - public static SessionMap? CurrentSessionMap { get; private set; } - public static string? CurrentSessionMapSavePath { get; private set; } - public static Stopwatch? CurrentSessionWatch { get; private set; } - - public static SessionData? CurrentSessionData { get; private set; } - public static string? CurrentSessionDataDirectoryPath => CurrentSessionData is null ? null : Path.Combine(SessionsDirectoryPath, CurrentSessionData.StartedAtText); - - // This "trilogy" handles the storage of played maps. If the player didn't receive at least gold and didn't skip it, it is not counted in the progress. - // It may (?) be better to wrap the CGameCtnChallenge into "CompletedMap" and have status of it being "gold", "author", or "skipped", and better handle that to make it script-friendly. - public static Dictionary CurrentSessionGoldMaps { get; } = new(); - public static Dictionary CurrentSessionAuthorMaps { get; } = new(); - public static Dictionary CurrentSessionSkippedMaps { get; } = new(); - - public static bool HasSessionRunning => CurrentSession is not null; - - /// - /// If the map UIDs of the autosaves have been fully stored to the and dictionaries. - /// This property is required to be true in order to start a new session. It also handles the state of , to catch other autosaves that might be created while the program is running. - /// - public static bool HasAutosavesScanned - { - get => hasAutosavesScanned; - private set - { - hasAutosavesScanned = value; - AutosaveWatcher.EnableRaisingEvents = value; - } - } - - public static ConcurrentDictionary AutosaveHeaders { get; } = new(); - public static ConcurrentDictionary AutosaveDetails { get; } = new(); - - public static FileSystemWatcher AutosaveWatcher { get; } - - public static event Action? MapStarted; - public static event Action? MapEnded; - public static event Action? MapSkip; - public static event Action Status; - public static event Action? MedalUpdate; - - public static ILogger Logger { get; private set; } - public static StreamWriter LogWriter { get; private set; } - public static StreamWriter? CurrentSessionLogWriter { get; private set; } - public static bool SessionEnding { get; private set; } - - public static string? Version { get; } = typeof(RandomizerEngine).Assembly.GetName().Version?.ToString(3); - - [GeneratedRegex("[^a-zA-Z0-9_.]+")] - private static partial Regex SpecialCharRegex(); - - static RandomizerEngine() - { - LogWriter = new StreamWriter(Constants.RandomizerTmfLog, append: true) - { - AutoFlush = true - }; - - Logger = new LoggerToFile(LogWriter); - - Logger.LogInformation("Starting Randomizer Engine..."); - - Directory.CreateDirectory(SessionsDirectoryPath); - - Logger.LogInformation("Predefining LZO algorithm..."); - - GBX.NET.Lzo.SetLzo(typeof(GBX.NET.LZO.MiniLZO)); - - Logger.LogInformation("Loading config..."); - - Config = GetOrCreateConfig(); - - Logger.LogInformation("Loading official blocks..."); - - OfficialBlocks = GetOfficialBlocks(); - - Logger.LogInformation("Preparing HTTP client..."); - - var socketHandler = new SocketsHttpHandler() - { - PooledConnectionLifetime = TimeSpan.FromMinutes(1), - }; - - Http = new HttpClient(socketHandler); - Http.DefaultRequestHeaders.UserAgent.TryParseAdd($"Randomizer TMF {Version}"); - - Logger.LogInformation("Preparing general events..."); - - AutosaveWatcher = new FileSystemWatcher - { - NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite, - Filter = "*.Replay.gbx" - }; - - AutosaveWatcher.Changed += AutosaveCreatedOrChanged; - - Status += RandomizerEngineStatus; - - Logger.LogInformation("Randomizer TMF initialized."); - } - - private static void RandomizerEngineStatus(string status) - { - // Status kind of logging - // Unlike regular logs, these are shown to the user in a module, while also written to the log file in its own way - Logger.LogInformation("STATUS: {status}", status); - } - - private static DateTime lastAutosaveUpdate = DateTime.MinValue; - - private static void AutosaveCreatedOrChanged(object sender, FileSystemEventArgs e) - { - // Hack to fix the issue of this event sometimes running twice - var lastWriteTime = File.GetLastWriteTime(e.FullPath); - - if (lastWriteTime == lastAutosaveUpdate) - { - return; - } - - lastAutosaveUpdate = lastWriteTime; - // - - var retryCounter = 0; - - CGameCtnReplayRecord replay; - - while (true) - { - try - { - // Any kind of autosave update section - - Logger.LogInformation("Analyzing a new file {autosavePath} in autosaves folder...", e.FullPath); - - if (GameBox.ParseNode(e.FullPath) is not CGameCtnReplayRecord r) - { - Logger.LogWarning("Found file {file} that is not a replay.", e.FullPath); - return; - } - - if (r.MapInfo is null) - { - Logger.LogWarning("Found replay {file} that has no map info.", e.FullPath); - return; - } - - AutosaveHeaders.TryAdd(r.MapInfo.Id, new AutosaveHeader(Path.GetFileName(e.FullPath), r)); - - replay = r; - } - catch (Exception ex) - { - retryCounter++; - - Logger.LogError(ex, "Error while analyzing a new file {autosavePath} in autosaves folder (retry {counter}/{maxRetries}).", - e.FullPath, retryCounter, Config.ReplayParseFailRetries); - - if (retryCounter >= Config.ReplayParseFailRetries) - { - return; - } - - Thread.Sleep(Config.ReplayParseFailDelayMs); - - continue; - } - - break; - } - - try - { - // Current session map autosave update section - - if (CurrentSessionMap is null) - { - Logger.LogWarning("Found autosave {autosavePath} for map {mapUid} while no session is running.", e.FullPath, replay.MapInfo.Id); - return; - } - - if (CurrentSessionMap.MapInfo != replay.MapInfo) - { - Logger.LogWarning("Found autosave {autosavePath} for map {mapUid} while the current session map is {currentSessionMapUid}.", e.FullPath, replay.MapInfo.Id, CurrentSessionMap.MapInfo.Id); - return; - } - - UpdateSessionDataFromAutosave(e.FullPath, CurrentSessionMap, replay); - - Status("Checking the autosave..."); - - // New autosave from the current map, save it into session for progression reasons - - // The following part has a scriptable potential - // There are different medal rules for each gamemode (and where to look for validating) - // So that's why the code looks like this for the time being - - if (CurrentSessionMap.ChallengeParameters?.AuthorTime is null) - { - Logger.LogWarning("Found autosave {autosavePath} for map {mapName} ({mapUid}) that has no author time.", - e.FullPath, - TextFormatter.Deformat(CurrentSessionMap.Map.MapName).Trim(), - replay.MapInfo.Id); - - SkipTokenSource?.Cancel(); - } - else - { - var ghost = replay.GetGhosts().First(); - - if ((CurrentSessionMap.Mode is CGameCtnChallenge.PlayMode.Race or CGameCtnChallenge.PlayMode.Puzzle && replay.Time <= CurrentSessionMap.ChallengeParameters.AuthorTime) - || (CurrentSessionMap.Mode is CGameCtnChallenge.PlayMode.Platform && ((CurrentSessionMap.ChallengeParameters.AuthorScore > 0 && ghost.Respawns <= CurrentSessionMap.ChallengeParameters.AuthorScore) || (ghost.Respawns == 0 && replay.Time <= CurrentSessionMap.ChallengeParameters.AuthorTime))) - || (CurrentSessionMap.Mode is CGameCtnChallenge.PlayMode.Stunts && ghost.StuntScore >= CurrentSessionMap.ChallengeParameters.AuthorScore)) - { - AuthorMedalReceived(CurrentSessionMap); - - SkipTokenSource?.Cancel(); - } - else if ((CurrentSessionMap.Mode is CGameCtnChallenge.PlayMode.Race or CGameCtnChallenge.PlayMode.Puzzle && replay.Time <= CurrentSessionMap.ChallengeParameters.GoldTime) - || (CurrentSessionMap.Mode is CGameCtnChallenge.PlayMode.Platform && ghost.Respawns <= CurrentSessionMap.ChallengeParameters.GoldTime.GetValueOrDefault().TotalMilliseconds) - || (CurrentSessionMap.Mode is CGameCtnChallenge.PlayMode.Stunts && ghost.StuntScore >= CurrentSessionMap.ChallengeParameters.GoldTime.GetValueOrDefault().TotalMilliseconds)) - { - GoldMedalReceived(CurrentSessionMap); - } - } - - Status("Playing the map..."); - } - catch (Exception ex) - { - Status("Error when checking the autosave..."); - Logger.LogError(ex, "Error when checking the autosave {autosavePath}.", e.FullPath); - } - } - - private static void UpdateSessionDataFromAutosave(string fullPath, SessionMap map, CGameCtnReplayRecord replay) - { - if (CurrentSessionDataDirectoryPath is null) - { - return; - } - - Status("Copying the autosave..."); - - var score = map.Map.Mode switch - { - CGameCtnChallenge.PlayMode.Stunts => replay.GetGhosts().First().StuntScore + "_", - CGameCtnChallenge.PlayMode.Platform => replay.GetGhosts().First().Respawns + "_", - _ => "" - } + replay.Time.ToTmString(useHundredths: true, useApostrophe: true); - - var mapName = SpecialCharRegex().Replace(TextFormatter.Deformat(map.Map.MapName).Trim(), "_"); - - var replayFileFormat = string.IsNullOrWhiteSpace(Config.ReplayFileFormat) - ? Constants.DefaultReplayFileFormat - : Config.ReplayFileFormat; - - var replayFileName = ClearFileName(string.Format(replayFileFormat, mapName, score, replay.PlayerLogin)); - - var replaysDir = Path.Combine(CurrentSessionDataDirectoryPath, Constants.Replays); - var replayFilePath = Path.Combine(replaysDir, replayFileName); - - Directory.CreateDirectory(replaysDir); - File.Copy(fullPath, replayFilePath, overwrite: true); - - if (CurrentSessionWatch is null) - { - return; - } - - CurrentSessionData?.Maps - .FirstOrDefault(x => x.Uid == map.MapUid)? - .Replays - .Add(new() - { - FileName = replayFileName, - Timestamp = CurrentSessionWatch.Elapsed - }); - - SaveSessionData(); - } - - private static string ClearFileName(string fileName) - { - return string.Join('_', fileName.Split(Path.GetInvalidFileNameChars())); - } - - private static void GoldMedalReceived(SessionMap map) - { - CurrentSessionGoldMaps.TryAdd(map.MapUid, map); - map.LastTimestamp = CurrentSessionWatch?.Elapsed; - SetMapResult(map, Constants.GoldMedal); - - MedalUpdate?.Invoke(); - } - - private static void AuthorMedalReceived(SessionMap map) - { - CurrentSessionGoldMaps.Remove(map.MapUid); - CurrentSessionAuthorMaps.TryAdd(map.MapUid, map); - map.LastTimestamp = CurrentSessionWatch?.Elapsed; - SetMapResult(map, Constants.AuthorMedal); - - MedalUpdate?.Invoke(); - } - - private static void Skipped(SessionMap map) - { - // If the player didn't receive at least a gold medal, the skip is counted (author medal automatically skips the map) - if (!CurrentSessionGoldMaps.ContainsKey(map.MapUid)) - { - CurrentSessionSkippedMaps.TryAdd(map.MapUid, map); - map.LastTimestamp = CurrentSessionWatch?.Elapsed; - SetMapResult(map, Constants.Skipped); - } - - // In other words, if the player received at least a gold medal, the skip is forgiven - - // MapSkip event is thrown to update the UI - MapSkip?.Invoke(); - } - - private static void SetMapResult(SessionMap map, string result) - { - var dataMap = CurrentSessionData?.Maps.FirstOrDefault(x => x.Uid == map.MapUid); - - if (dataMap is not null) - { - dataMap.Result = result; - dataMap.LastTimestamp = map.LastTimestamp; - } - - SaveSessionData(); - } - - /// - /// This method should be ran only at the start of the randomizer engine. - /// - /// - private static RandomizerConfig GetOrCreateConfig() - { - var config = default(RandomizerConfig); - - if (File.Exists(Constants.ConfigYml)) - { - Logger.LogInformation("Config file found, loading..."); - - try - { - using var reader = new StreamReader(Constants.ConfigYml); - config = YamlDeserializer.Deserialize(reader); - } - catch (Exception ex) - { - Logger.LogWarning(ex, "Error while deserializing the config file ({configPath}).", Constants.ConfigYml); - } - } - - if (config is null) - { - Logger.LogInformation("Config file not found or is corrupted, creating a new one..."); - config = new RandomizerConfig(); - } - - SaveConfig(config); - - return config; - } - - private static Dictionary> GetOfficialBlocks() - { - using var reader = new StreamReader(Constants.OfficialBlocksYml); - return YamlDeserializer.Deserialize>>(reader); - } - - public static GameDirInspectResult UpdateGameDirectory(string gameDirectoryPath) - { - Config.GameDirectory = gameDirectoryPath; - - var nadeoIniFilePath = Path.Combine(gameDirectoryPath, Constants.NadeoIni); - var tmForeverExeFilePath = Path.Combine(gameDirectoryPath, Constants.TmForeverExe); - var tmUnlimiterExeFilePath = Path.Combine(gameDirectoryPath, Constants.TmInifinityExe); - - var nadeoIniException = default(Exception); - var tmForeverExeException = default(Exception); - var tmUnlimiterExeException = default(Exception); - - try - { - var nadeoIni = NadeoIni.Parse(nadeoIniFilePath); - var myDocuments = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); - var newUserDataDirectoryPath = Path.Combine(myDocuments, nadeoIni.UserSubDir); - - if (UserDataDirectoryPath != newUserDataDirectoryPath) - { - UserDataDirectoryPath = newUserDataDirectoryPath; - - ResetAutosaves(); - } - } - catch (Exception ex) - { - nadeoIniException = ex; - } - - try - { - using var fs = File.OpenRead(tmForeverExeFilePath); - TmForeverExeFilePath = tmForeverExeFilePath; - } - catch (Exception ex) - { - tmForeverExeException = ex; - } - - try - { - using var fs = File.OpenRead(tmUnlimiterExeFilePath); - TmUnlimiterExeFilePath = tmUnlimiterExeFilePath; - } - catch (Exception ex) - { - tmUnlimiterExeException = ex; - } - - return new GameDirInspectResult(nadeoIniException, tmForeverExeException, tmUnlimiterExeException); - } - - /// - /// Cleans up the autosave storage and, most importantly, restricts the engine from functionalities that require the autosaves (). - /// - public static void ResetAutosaves() - { - AutosaveHeaders.Clear(); - AutosaveDetails.Clear(); - HasAutosavesScanned = false; - } - - public static void SaveConfig() - { - SaveConfig(Config); - } - - private static void SaveConfig(RandomizerConfig config) - { - Logger.LogInformation("Saving the config file..."); - - File.WriteAllText(Constants.ConfigYml, YamlSerializer.Serialize(config)); - - Logger.LogInformation("Config file saved."); - } - - private static void SaveSessionData() - { - if (CurrentSessionData is null || CurrentSessionDataDirectoryPath is null) - { - return; - } - - Logger.LogInformation("Saving the session data into file..."); - - File.WriteAllText(Path.Combine(CurrentSessionDataDirectoryPath, Constants.SessionYml), YamlSerializer.Serialize(CurrentSessionData)); - - Logger.LogInformation("Session data saved."); - } - - /// - /// Scans the autosaves, which is required before running the session, to avoid already played maps. - /// - /// True if anything changed. - /// - public static bool ScanAutosaves() - { - if (AutosavesDirectoryPath is null) - { - throw new ImportantPropertyNullException("Cannot scan autosaves without a valid user data directory path."); - } - - var anythingChanged = false; - - foreach (var file in Directory.EnumerateFiles(AutosavesDirectoryPath).AsParallel()) - { - try - { - if (GameBox.ParseNodeHeader(file) is not CGameCtnReplayRecord replay || replay.MapInfo is null) - { - continue; - } - - var mapUid = replay.MapInfo.Id; - - if (AutosaveHeaders.ContainsKey(mapUid)) - { - continue; - } - - AutosaveHeaders.TryAdd(mapUid, new AutosaveHeader(Path.GetFileName(file), replay)); - - anythingChanged = true; - } - catch (NotAGbxException) - { - // do nothing - } - catch (Exception ex) - { - Logger.LogWarning(ex, "Gbx error found in the Autosaves folder when reading the header."); - } - } - - HasAutosavesScanned = true; - - return anythingChanged; - } - - /// - /// Scans autosave details (mostly the map inside the replay and getting its info) that are then used to display more detailed information about a list of maps. Only some replay properties are stored to value memory. - /// - /// - public static void ScanDetailsFromAutosaves() - { - if (AutosavesDirectoryPath is null) - { - throw new Exception("Cannot update autosaves without a valid user data directory path."); - } - - foreach (var autosave in AutosaveHeaders.Keys) - { - try - { - UpdateAutosaveDetail(autosave); - } - catch (Exception ex) - { - // this happens in async context, logger may not be safe for that, some errors may get lost - Debug.WriteLine("Exception during autosave detail scan: " + ex); - } - } - } - - private static void UpdateAutosaveDetail(string autosaveFileName) - { - var autosavePath = Path.Combine(AutosavesDirectoryPath!, AutosaveHeaders[autosaveFileName].FilePath); // Forgive because it's a private method - - if (GameBox.ParseNode(autosavePath) is not CGameCtnReplayRecord { Time: not null } replay) - { - return; - } - - if (replay.Challenge is null) - { - return; - } - - var mapName = TextFormatter.Deformat(replay.Challenge.MapName); - var mapEnv = (string)replay.Challenge.Collection; - var mapBronzeTime = replay.Challenge.TMObjective_BronzeTime ?? throw new ImportantPropertyNullException("Bronze time is null."); - var mapSilverTime = replay.Challenge.TMObjective_SilverTime ?? throw new ImportantPropertyNullException("Silver time is null."); - var mapGoldTime = replay.Challenge.TMObjective_GoldTime ?? throw new ImportantPropertyNullException("Gold time is null."); - var mapAuthorTime = replay.Challenge.TMObjective_AuthorTime ?? throw new ImportantPropertyNullException("Author time is null."); - var mapAuthorScore = replay.Challenge.AuthorScore ?? throw new ImportantPropertyNullException("AuthorScore is null."); - var mapMode = replay.Challenge.Mode; - var mapCarPure = replay.Challenge.PlayerModel?.Id; - var mapCar = string.IsNullOrEmpty(mapCarPure) ? $"{mapEnv}Car" : mapCarPure; - - mapCar = mapCar switch - { - "AlpineCar" => "SnowCar", - "American" or "SpeedCar" => "DesertCar", - "Rally" => "RallyCar", - "SportCar" => "IslandCar", - _ => mapCar - }; - - var ghost = replay.GetGhosts().FirstOrDefault(); - - AutosaveDetails[autosaveFileName] = new( - replay.Time.Value, - Score: ghost?.StuntScore, - Respawns: ghost?.Respawns, - mapName, - mapEnv, - mapCar, - mapBronzeTime, - mapSilverTime, - mapGoldTime, - mapAuthorTime, - mapAuthorScore, - mapMode); - } - - /// - /// Starts the randomizer session by creating a new watch and for (+ ) that will handle randomization on different thread from the UI thread. - /// - public static Task StartSessionAsync() - { - if (Config.GameDirectory is null) - { - return Task.CompletedTask; - } - - CurrentSessionWatch = new Stopwatch(); - CurrentSessionTokenSource = new CancellationTokenSource(); - CurrentSession = Task.Run(() => RunSessionSafeAsync(CurrentSessionTokenSource.Token), CurrentSessionTokenSource.Token); - - return Task.CompletedTask; - } - - /// - /// Runs the session in a way it won't ever throw an exception. Clears the session after its end as well. - /// - /// - /// - private static async Task RunSessionSafeAsync(CancellationToken cancellationToken) - { - try - { - await RunSessionAsync(cancellationToken); - } - catch (TaskCanceledException) - { - Status("Session ended."); - } - catch (InvalidSessionException) - { - Status("Session ended. No maps found."); - } - catch (Exception ex) - { - Status("Session ended due to error."); - Logger.LogError(ex, "Error during session."); - } - finally - { - ClearCurrentSession(); - } - } - - /// - /// Does the actual work during a running session. That this method ends means the session also ends. It does NOT clean up the session after its end. - /// - /// - /// - /// - private static async Task RunSessionAsync(CancellationToken cancellationToken) - { - if (Config.GameDirectory is null) - { - throw new UnreachableException("Game directory is null"); - } - - ValidateRules(); - - InitializeSessionData(); - - while (true) - { - // This try block is used to handle map requests and their HTTP errors, mostly. - - try - { - await PrepareNewMapAsync(cancellationToken); - } - catch (HttpRequestException) - { - Status("Failed to fetch a track. Retrying..."); - await Task.Delay(1000, cancellationToken); - continue; - } - catch (MapValidationException) - { - Logger.LogInformation("Map has not passed the validator, attempting another one..."); - await Task.Delay(500, cancellationToken); - continue; - } - catch (InvalidSessionException) - { - Logger.LogWarning("Session is invalid."); - throw; - } - catch (TaskCanceledException) - { - Logger.LogInformation("Session terminated during map request."); - throw; - } - catch (Exception ex) - { - Status("Error! Check the log for more details."); - Logger.LogError(ex, "An error occurred during map request."); - - await Task.Delay(1000, cancellationToken); - - continue; - } - - await PlayCurrentSessionMapAsync(cancellationToken); - } - } - - private static void InitializeSessionData() - { - var startedAt = DateTimeOffset.Now; - - CurrentSessionData = new SessionData(Version, startedAt, Config.Rules); - - if (CurrentSessionDataDirectoryPath is null) - { - throw new UnreachableException("CurrentSessionDataDirectoryPath is null"); - } - - Directory.CreateDirectory(CurrentSessionDataDirectoryPath); - - CurrentSessionLogWriter = new StreamWriter(Path.Combine(CurrentSessionDataDirectoryPath, Constants.SessionLog)) - { - AutoFlush = true - }; - - Logger = new LoggerToFile(CurrentSessionLogWriter, LogWriter); - - SaveSessionData(); - } - - /// - /// Validates the session rules. This should be called right before the session start and after loading the modules. - /// - /// - public static void ValidateRules() - { - if (Config.Rules.TimeLimit == TimeSpan.Zero) - { - throw new RuleValidationException("Time limit cannot be 0:00:00."); - } - - if (Config.Rules.TimeLimit > new TimeSpan(9, 59, 59)) - { - throw new RuleValidationException("Time limit cannot be above 9:59:59."); - } - - foreach (var primaryType in Enum.GetValues()) - { - if (primaryType is EPrimaryType.Race) - { - continue; - } - - if (Config.Rules.RequestRules.PrimaryType == primaryType - && (Config.Rules.RequestRules.Site is ESite.Any - || Config.Rules.RequestRules.Site.HasFlag(ESite.TMNF) || Config.Rules.RequestRules.Site.HasFlag(ESite.Nations))) - { - throw new RuleValidationException($"{primaryType} cannot be specifically selected with TMNF or Nations Exchange."); - } - } - - if (Config.Rules.RequestRules.Environment?.Count > 0) - { - if (Config.Rules.RequestRules.Site.HasFlag(ESite.Sunrise) - && !Config.Rules.RequestRules.Environment.Contains(EEnvironment.Island) - && !Config.Rules.RequestRules.Environment.Contains(EEnvironment.Coast) - && !Config.Rules.RequestRules.Environment.Contains(EEnvironment.Bay)) - { - throw new RuleValidationException("Island, Coast, or Bay has to be selected when environments are specified and Sunrise Exchange is selected."); - } - - if (Config.Rules.RequestRules.Site.HasFlag(ESite.Original) - && !Config.Rules.RequestRules.Environment.Contains(EEnvironment.Snow) - && !Config.Rules.RequestRules.Environment.Contains(EEnvironment.Desert) - && !Config.Rules.RequestRules.Environment.Contains(EEnvironment.Rally)) - { - throw new RuleValidationException("Snow, Desert, or Rally has to be selected when environments are specified and Original Exchange is selected."); - } - - if (!Config.Rules.RequestRules.Environment.Contains(EEnvironment.Stadium) - && (Config.Rules.RequestRules.Site.HasFlag(ESite.TMNF) || Config.Rules.RequestRules.Site.HasFlag(ESite.Nations))) - { - throw new RuleValidationException("Stadium has to be selected when environments are specified and TMNF or Nations Exchange is selected."); - } - - if (Config.Rules.RequestRules.Site.HasFlag(ESite.Sunrise) || Config.Rules.RequestRules.Site.HasFlag(ESite.Original)) - { - foreach (var env in Config.Rules.RequestRules.Environment) - { - if (Config.Rules.RequestRules.Vehicle?.Contains(env) == false) - { - throw new RuleValidationException("Envimix randomization is not allowed when Sunrise or Original Exchange is selected."); - } - } - } - } - - if (Config.Rules.RequestRules.Vehicle?.Count > 0) - { - if (!Config.Rules.RequestRules.Vehicle.Contains(EEnvironment.Island) - && !Config.Rules.RequestRules.Vehicle.Contains(EEnvironment.Coast) - && !Config.Rules.RequestRules.Vehicle.Contains(EEnvironment.Bay) - && Config.Rules.RequestRules.Site.HasFlag(ESite.Sunrise)) - { - throw new RuleValidationException("IslandCar, CoastCar, or BayCar has to be selected when cars are specified and Sunrise Exchange is selected."); - } - - if (!Config.Rules.RequestRules.Vehicle.Contains(EEnvironment.Snow) - && !Config.Rules.RequestRules.Vehicle.Contains(EEnvironment.Desert) - && !Config.Rules.RequestRules.Vehicle.Contains(EEnvironment.Rally) - && Config.Rules.RequestRules.Site.HasFlag(ESite.Original)) - { - throw new RuleValidationException("SnowCar, DesertCar, or RallyCar has to be selected when cars are specified and Original Exchange is selected."); - } - - if (!Config.Rules.RequestRules.Vehicle.Contains(EEnvironment.Stadium) - && (Config.Rules.RequestRules.Site.HasFlag(ESite.TMNF) || Config.Rules.RequestRules.Site.HasFlag(ESite.Nations))) - { - throw new RuleValidationException("StadiumCar has to be selected when cars are specified and TMNF or Nations Exchange is selected."); - } - } - - if (Config.Rules.RequestRules.EqualEnvironmentDistribution - && Config.Rules.RequestRules.EqualVehicleDistribution - && Config.Rules.RequestRules.Site is not ESite.TMUF) - { - throw new RuleValidationException("Equal environment and car distribution combined is only valid with TMUF Exchange."); - } - - if (Config.Rules.RequestRules.EqualEnvironmentDistribution - && (Config.Rules.RequestRules.Site is ESite.Any - || Config.Rules.RequestRules.Site.HasFlag(ESite.TMNF) || Config.Rules.RequestRules.Site.HasFlag(ESite.Nations))) - { - throw new RuleValidationException("Equal environment distribution is not valid with TMNF or Nations Exchange."); - } - - if (Config.Rules.RequestRules.EqualVehicleDistribution - && (Config.Rules.RequestRules.Site is ESite.Any - || Config.Rules.RequestRules.Site.HasFlag(ESite.TMNF) || Config.Rules.RequestRules.Site.HasFlag(ESite.Nations))) - { - throw new RuleValidationException("Equal vehicle distribution is not valid with TMNF or Nations Exchange."); - } - } - - /// - /// Does the cleanup of the session so that the new one can be instantiated without issues. - /// - private static void ClearCurrentSession() - { - StopTrackingCurrentSessionMap(); - - CurrentSessionGoldMaps.Clear(); - CurrentSessionAuthorMaps.Clear(); - CurrentSessionSkippedMaps.Clear(); - - CurrentSessionWatch?.Stop(); - CurrentSessionWatch = null; - - CurrentSession = null; - CurrentSessionTokenSource = null; - - SetReadOnlySessionYml(); - - CurrentSessionData = null; - - CurrentSessionLogWriter?.Dispose(); - CurrentSessionLogWriter = null; - - Logger = new LoggerToFile(LogWriter); - } - - private static void SetReadOnlySessionYml() - { - if (CurrentSessionDataDirectoryPath is null) - { - return; - } - - try - { - var sessionYmlFile = Path.Combine(CurrentSessionDataDirectoryPath, Constants.SessionYml); - File.SetAttributes(sessionYmlFile, File.GetAttributes(sessionYmlFile) | FileAttributes.ReadOnly); - } - catch (Exception ex) - { - Logger.LogError(ex, "Failed to set Session.yml as read-only."); - } - } - - /// - /// Requests, downloads, and allocates the map. - /// - /// - /// - /// - private static async Task PrepareNewMapAsync(CancellationToken cancellationToken) - { - Status("Fetching random track..."); - - // Randomized URL is constructed with the ToUrl() method. - var requestUrl = Config.Rules.RequestRules.ToUrl(); - - Logger.LogDebug("Requesting generated URL: {url}", requestUrl); - - // HEAD request ensures least overhead - using var randomResponse = await Http.HeadAsync(requestUrl, cancellationToken); - - if (randomResponse.StatusCode == HttpStatusCode.NotFound) - { - // The session is ALWAYS invalid if there's no map that can be found. - // This DOES NOT relate to the lack of maps left that the user hasn't played. - - Logger.LogWarning("No map fulfills the randomization filter."); - - throw new InvalidSessionException(); - } - - randomResponse.EnsureSuccessStatusCode(); // Handles server issues, should normally retry - - - // Following code gathers the track ID from the HEAD response (and ensures everything makes sense) - - if (randomResponse.RequestMessage is null) - { - Logger.LogWarning("Response from the HEAD request does not contain information about the request message. This is odd..."); - return; - } - - if (randomResponse.RequestMessage.RequestUri is null) - { - Logger.LogWarning("Response from the HEAD request does not contain information about the request URI. This is odd..."); - return; - } - - var trackId = randomResponse.RequestMessage.RequestUri.Segments.LastOrDefault(); - - if (trackId is null) - { - Logger.LogWarning("Request URI does not contain any segments. This is very odd..."); - return; - } - - - // With the ID, it is possible to immediately download the track Gbx and process it with GBX.NET - - Status($"Downloading track {trackId}..."); - - var trackGbxUrl = $"https://{randomResponse.RequestMessage.RequestUri.Host}/trackgbx/{trackId}"; - - Logger.LogDebug("Downloading track on {trackGbxUrl}...", trackGbxUrl); - using var trackGbxResponse = await Http.GetAsync(trackGbxUrl, cancellationToken); - trackGbxResponse.EnsureSuccessStatusCode(); - - using var stream = await trackGbxResponse.Content.ReadAsStreamAsync(cancellationToken); - - - // The map is gonna be parsed as it is downloading throughout - - Status("Parsing the map..."); - - if (GameBox.ParseNode(stream) is not CGameCtnChallenge map) - { - Logger.LogWarning("Downloaded file is not a valid Gbx map file!"); - return; - } - - - // Map validation ensures that the player won't receive duplicate maps - // + ensures some additional filters like "No Unlimiter", which cannot be filtered on TMX - - Status("Validating the map..."); - - if (!ValidateMap(map, out string? invalidBlock)) - { - // Attempts another track if invalid - requestAttempt++; - - if (invalidBlock is not null) - { - Status($"{invalidBlock} in {map.Collection}"); - Logger.LogInformation("Map is invalid because {invalidBlock} is not valid for the {env} environment.", invalidBlock, map.Collection); - await Task.Delay(500, cancellationToken); - } - - Status($"Map is invalid (attempt {requestAttempt}/{requestMaxAttempts})."); - - if (requestAttempt >= requestMaxAttempts) - { - Logger.LogWarning("Map is invalid after {MaxAttempts} attempts. Cancelling the session...", requestMaxAttempts); - requestAttempt = 0; - throw new InvalidSessionException(); - } - - throw new MapValidationException(); - } - - requestAttempt = 0; - - // The map is saved to the defined DownloadedDirectoryPath using the FileName provided in ContentDisposition - - Status("Saving the map..."); - - if (DownloadedDirectoryPath is null) - { - throw new UnreachableException("Cannot update autosaves without a valid user data directory path."); - } - - Logger.LogDebug("Ensuring {dir} exists...", DownloadedDirectoryPath); - Directory.CreateDirectory(DownloadedDirectoryPath); // Ensures the directory really exists - - Logger.LogDebug("Preparing the file name..."); - - // Extracts the file name, and if it fails, it uses the MapUid as a fallback - var fileName = trackGbxResponse.Content.Headers.ContentDisposition?.FileName?.Trim('\"') ?? $"{map.MapUid}.Challenge.Gbx"; - - // Validates the file name and fixes it if needed - fileName = ClearFileName(fileName); - - CurrentSessionMapSavePath = Path.Combine(DownloadedDirectoryPath, fileName); - - Logger.LogInformation("Saving the map as {fileName}...", CurrentSessionMapSavePath); - - // WriteAllBytesAsync is used instead of GameBox.Save to ensure 1:1 data of the original map - var trackData = await trackGbxResponse.Content.ReadAsByteArrayAsync(cancellationToken); - - await File.WriteAllBytesAsync(CurrentSessionMapSavePath, trackData, cancellationToken); - - Logger.LogInformation("Map saved successfully!"); - - var tmxLink = randomResponse.RequestMessage.RequestUri.ToString(); - - CurrentSessionMap = new SessionMap(map, randomResponse.Headers.Date ?? DateTimeOffset.Now, tmxLink); // The map should be ready to be played now - - CurrentSessionData?.Maps.Add(new() - { - Name = TextFormatter.Deformat(map.MapName), - Uid = map.MapUid, - TmxLink = tmxLink - }); - - SaveSessionData(); // May not be super necessary? - } - - - - /// - /// Handles the play loop of a map. Throws cancellation exception on session end (not the map end). - /// - /// - private static async Task PlayCurrentSessionMapAsync(CancellationToken cancellationToken) - { - // Hacky last moment validations - - if (CurrentSessionMapSavePath is null) - { - throw new UnreachableException("CurrentSessionMapSavePath is null"); - } - - if (CurrentSessionMap is null) - { - throw new UnreachableException("CurrentSessionMap is null"); - } - - if (CurrentSessionWatch is null) - { - throw new UnreachableException("CurrentSessionWatch is null"); - } - - // Map starts here - - Status("Starting the map..."); - - OpenFileIngame(CurrentSessionMapSavePath); - - CurrentSessionWatch.Start(); - - SkipTokenSource = new CancellationTokenSource(); - MapStarted?.Invoke(); - - Status("Playing the map..."); - - // This loop either softly stops when the map is skipped by the player - // or hardly stops when author medal is received / time limit is reached, End Session was clicked or an exception was thrown in general - - // SkipTokenSource is used within the session to skip a map, while CurrentSessionTokenSource handles the whole session cancellation - - while (!SkipTokenSource.IsCancellationRequested) - { - if (CurrentSessionWatch.Elapsed >= Config.Rules.TimeLimit) // Time limit reached case - { - if (CurrentSessionTokenSource is null) - { - throw new UnreachableException("CurrentSessionTokenSource is null"); - } - - // Will cause the Task.Delay below to throw a cancellation exception - // Code outside of the while loop wont be reached - CurrentSessionTokenSource.Cancel(); - } - - await Task.Delay(20, cancellationToken); - } - - CurrentSessionWatch.Stop(); // Time is paused until the next map starts - - if (isActualSkipCancellation) // When its a manual skip and not an automated skip by author medal receive - { - Status("Skipping the map..."); - - // Apply the rules related to manual map skip - // This part has a scripting potential too if properly implmented - - Skipped(CurrentSessionMap); - - isActualSkipCancellation = false; - } - - Status("Ending the map..."); - - StopTrackingCurrentSessionMap(); - - // Map is no longer tracked at this point - } - - /// - /// Checks if the map hasn't been already played or if it follows current session rules. - /// - /// - /// True if valid, false if not valid. - private static bool ValidateMap(CGameCtnChallenge map, out string? invalidBlock) - { - invalidBlock = null; - - if (AutosaveHeaders.ContainsKey(map.MapUid)) - { - return false; - } - - if (Config.Rules.NoUnlimiter) - { - if (map.Chunks.TryGet(0x3F001000, out _)) - { - return false; - } - - if (OfficialBlocks.TryGetValue(map.Collection, out var officialBlocks)) - { - foreach (var block in map.GetBlocks()) - { - var blockName = block.Name.Trim(); - - if (!officialBlocks.Contains(blockName)) - { - invalidBlock = blockName; - return false; - } - } - } - } - - return true; - } - - private static void StopTrackingCurrentSessionMap() - { - SkipTokenSource = null; - CurrentSessionMap = null; - CurrentSessionMapSavePath = null; - MapEnded?.Invoke(); - } - - /// - /// MANUAL end of session. - /// - /// - public static async Task EndSessionAsync() - { - if (SessionEnding) - { - return; - } - - SessionEnding = true; - - Status("Ending the session..."); - - CurrentSessionTokenSource?.Cancel(); - - if (CurrentSession is null) - { - return; - } - - try - { - await CurrentSession; // Kindly waits until the session considers it was cancelled. ClearCurrentSession is called within it. - } - catch (TaskCanceledException) - { - ClearCurrentSession(); // Just an ensure - } - - SessionEnding = false; - } - - public static Task SkipMapAsync() - { - Status("Requested to skip the map..."); - isActualSkipCancellation = true; - SkipTokenSource?.Cancel(); - return Task.CompletedTask; - } - - public static void OpenFileIngame(string filePath) - { - if (Config.GameDirectory is null) - { - throw new Exception("Game directory is null"); - } - - Logger.LogInformation("Opening {filePath} in TMForever...", filePath); - - var startInfo = new ProcessStartInfo(Path.Combine(Config.GameDirectory, Constants.TmForeverExe), $"/useexedir /singleinst /file=\"{filePath}\"") - { - - }; - - var process = new Process - { - StartInfo = startInfo - }; - - process.Start(); - - try - { - process.WaitForInputIdle(); - } - catch (InvalidOperationException ex) - { - Logger.LogWarning(ex, "Could not wait for input."); - } - } - - public static void OpenAutosaveIngame(string fileName) - { - if (AutosavesDirectoryPath is null) - { - throw new Exception("Cannot open an autosave ingame without a valid user data directory path."); - } - - OpenFileIngame(Path.Combine(AutosavesDirectoryPath, fileName)); - } - - public static void Exit() - { - Logger.LogInformation("Exiting..."); - FlushLog(); - Environment.Exit(0); - } - - public static void ReloadMap() - { - Logger.LogInformation("Reloading the map..."); - - if (CurrentSessionMapSavePath is not null) - { - OpenFileIngame(CurrentSessionMapSavePath); - } - } - - public static void FlushLog() - { - LogWriter.Flush(); - } - - public static async Task FlushLogAsync() - { - await LogWriter.FlushAsync(); - } -} diff --git a/Src/RandomizerTMF.Logic/RandomizerRules.cs b/Src/RandomizerTMF.Logic/RandomizerRules.cs index cf294b5..3a26ba4 100644 --- a/Src/RandomizerTMF.Logic/RandomizerRules.cs +++ b/Src/RandomizerTMF.Logic/RandomizerRules.cs @@ -11,6 +11,6 @@ public class RandomizerRules { Site = ESite.TMNF, PrimaryType = EPrimaryType.Race, - AuthorTimeMax = TimeInt32.FromMinutes(2) + AuthorTimeMax = TimeInt32.FromMinutes(3) }; } diff --git a/Src/RandomizerTMF.Logic/RandomizerTMF.Logic.csproj b/Src/RandomizerTMF.Logic/RandomizerTMF.Logic.csproj index 0d5611b..13b6cdb 100644 --- a/Src/RandomizerTMF.Logic/RandomizerTMF.Logic.csproj +++ b/Src/RandomizerTMF.Logic/RandomizerTMF.Logic.csproj @@ -1,19 +1,29 @@ - + - 1.0.3 + 1.1.0 net7.0 enable enable - + + + + + + - + + + + + PreserveNewest + PreserveNewest diff --git a/Src/RandomizerTMF.Logic/RequestRules.cs b/Src/RandomizerTMF.Logic/RequestRules.cs index 38816f6..792e463 100644 --- a/Src/RandomizerTMF.Logic/RequestRules.cs +++ b/Src/RandomizerTMF.Logic/RequestRules.cs @@ -1,4 +1,4 @@ -using RandomizerTMF.Logic.Exceptions; +using RandomizerTMF.Logic.Services; using System.Collections; using System.Diagnostics; using System.Reflection; @@ -11,6 +11,8 @@ public class RequestRules { private static readonly ESite[] siteValues = Enum.GetValues(); private static readonly EEnvironment[] envValues = Enum.GetValues(); + private static readonly EEnvironment[] sunriseEnvValues = new [] { EEnvironment.Island, EEnvironment.Bay, EEnvironment.Coast }; + private static readonly EEnvironment[] originalEnvValues = new [] { EEnvironment.Desert, EEnvironment.Snow, EEnvironment.Rally }; // Custom rules that are not part of the official API @@ -45,7 +47,7 @@ public class RequestRules public TimeInt32? AuthorTimeMin { get; set; } public TimeInt32? AuthorTimeMax { get; set; } - public string ToUrl() // Not very efficient but does the job done fast enough + public string ToUrl(IRandomGenerator random) // Not very efficient but does the job done fast enough { var b = new StringBuilder("https://"); @@ -54,7 +56,7 @@ public string ToUrl() // Not very efficient but does the job done fast enough .ToArray(); // If Site is Any, then it picks from sites that are valid within environments and cars - var site = GetRandomSite(matchingSites.Length == 0 + var site = GetRandomSite(random, matchingSites.Length == 0 ? siteValues.Where(x => x is not ESite.Any && IsSiteValidWithEnvironments(x) && IsSiteValidWithVehicles(x) @@ -75,12 +77,12 @@ public string ToUrl() // Not very efficient but does the job done fast enough if (EqualEnvironmentDistribution && prop.Name == nameof(Environment)) { - val = GetRandomEnvironmentThroughSet(Environment); + val = GetRandomEnvironmentThroughSet(random, Environment, site); } if (EqualVehicleDistribution && prop.Name == nameof(Vehicle)) { - val = GetRandomEnvironmentThroughSet(Vehicle); + val = GetRandomEnvironmentThroughSet(random, Vehicle, site); } if (val is null || (val is IEnumerable enumerable && !enumerable.Cast().Any())) @@ -188,24 +190,29 @@ private bool IsValidInNations(PropertyInfo prop, object val) return true; } - private static EEnvironment GetRandomEnvironment(HashSet? container) + private static EEnvironment GetRandomEnvironment(IRandomGenerator random, HashSet? container, ESite site) { - if (container is null || container.Count == 0) + if (container is not null && container.Count != 0) { - return (EEnvironment)Random.Shared.Next(0, envValues.Length); // Safe in case of EEnvironment + return container.ElementAt(random.Next(container.Count)); } - return container.ElementAt(Random.Shared.Next(0, container.Count)); + return site switch + { + ESite.Sunrise => sunriseEnvValues[random.Next(sunriseEnvValues.Length)], + ESite.Original => originalEnvValues[random.Next(originalEnvValues.Length)], + _ => (EEnvironment)random.Next(envValues.Length) // Safe in case of EEnvironment + }; } - private static HashSet GetRandomEnvironmentThroughSet(HashSet? container) + private static HashSet GetRandomEnvironmentThroughSet(IRandomGenerator random, HashSet? container, ESite site) { - return new HashSet() { GetRandomEnvironment(container) }; + return new HashSet() { GetRandomEnvironment(random, container, site) }; } - private static ESite GetRandomSite(ESite[] matchingSites) + private static ESite GetRandomSite(IRandomGenerator random, ESite[] matchingSites) { - return matchingSites[Random.Shared.Next(matchingSites.Length)]; + return matchingSites[random.Next(matchingSites.Length)]; } private static string GetSiteUrl(ESite site) => site switch diff --git a/Src/RandomizerTMF.Logic/ServiceCollectionExtensions.cs b/Src/RandomizerTMF.Logic/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..7400277 --- /dev/null +++ b/Src/RandomizerTMF.Logic/ServiceCollectionExtensions.cs @@ -0,0 +1,70 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using RandomizerTMF.Logic.Services; +using System.IO.Abstractions; + +namespace RandomizerTMF.Logic; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddRandomizerEngine(this IServiceCollection services) + { + services.AddSingleton(provider => + { + var logWriter = new StreamWriter(Constants.RandomizerTmfLog, append: true) + { + AutoFlush = true + }; + + return new LoggerToFile(logWriter); + }); + + services.AddSingleton(provider => + { + var socketHandler = new SocketsHttpHandler() + { + PooledConnectionLifetime = TimeSpan.FromMinutes(1), + }; + + var http = new HttpClient(socketHandler); + http.DefaultRequestHeaders.UserAgent.TryParseAdd($"Randomizer TMF {RandomizerEngine.Version}"); + return http; + }); + + services.AddSingleton(); + + services.AddSingleton(provider => + { + return RandomizerConfig.GetOrCreate(provider.GetRequiredService(), provider.GetRequiredService()); + }); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddTransient(); + services.AddSingleton>(provider => () => provider.GetRequiredService()); + + services.AddSingleton(provider => + { + return new FileSystemWatcherWrapper(provider.GetRequiredService()) + { + NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite, + Filter = "*.Replay.gbx", + Path = provider.GetRequiredService().AutosavesDirectoryPath ?? "" + }; + }); + + return services; + } +} diff --git a/Src/RandomizerTMF.Logic/Services/AdditionalData.cs b/Src/RandomizerTMF.Logic/Services/AdditionalData.cs new file mode 100644 index 0000000..d349235 --- /dev/null +++ b/Src/RandomizerTMF.Logic/Services/AdditionalData.cs @@ -0,0 +1,42 @@ +using GBX.NET; +using Microsoft.Extensions.Logging; +using System.IO.Abstractions; + +namespace RandomizerTMF.Logic.Services; + +public interface IAdditionalData +{ + Dictionary> MapSizes { get; } + Dictionary> OfficialBlocks { get; } +} + +public class AdditionalData : IAdditionalData +{ + private readonly IFileSystem fileSystem; + + public Dictionary> OfficialBlocks { get; } + public Dictionary> MapSizes { get; } + + public AdditionalData(ILogger logger, IFileSystem fileSystem) + { + this.fileSystem = fileSystem; + + logger.LogInformation("Loading official blocks..."); + OfficialBlocks = GetOfficialBlocks(); + + logger.LogInformation("Loading map sizes..."); + MapSizes = GetMapSizes(); + } + + private Dictionary> GetOfficialBlocks() + { + using var reader = fileSystem.File.OpenText(Constants.OfficialBlocksYml); + return Yaml.Deserializer.Deserialize>>(reader); + } + + private Dictionary> GetMapSizes() + { + using var reader = fileSystem.File.OpenText(Constants.MapSizesYml); + return Yaml.Deserializer.Deserialize>>(reader); + } +} diff --git a/Src/RandomizerTMF.Logic/Services/AutosaveScanner.cs b/Src/RandomizerTMF.Logic/Services/AutosaveScanner.cs new file mode 100644 index 0000000..b5426f8 --- /dev/null +++ b/Src/RandomizerTMF.Logic/Services/AutosaveScanner.cs @@ -0,0 +1,285 @@ +using GBX.NET.Engines.Game; +using GBX.NET; +using Microsoft.Extensions.Logging; +using System.Collections.Concurrent; +using GBX.NET.Exceptions; +using RandomizerTMF.Logic.Exceptions; +using System.Diagnostics; +using TmEssentials; +using System.IO.Abstractions; + +namespace RandomizerTMF.Logic.Services; + +public interface IAutosaveScanner +{ + ConcurrentDictionary AutosaveDetails { get; } + ConcurrentDictionary AutosaveHeaders { get; } + bool HasAutosavesScanned { get; } + + void ResetAutosaves(); + bool ScanAutosaves(); + void ScanDetailsFromAutosaves(); +} + +public class AutosaveScanner : IAutosaveScanner +{ + private readonly IRandomizerEvents events; + private readonly IFileSystemWatcher watcher; + private readonly IFilePathManager filePathManager; + private readonly IRandomizerConfig config; + private readonly IFileSystem fileSystem; + private readonly IGbxService gbx; + private readonly ILogger logger; + + private bool hasAutosavesScanned; + + /// + /// If the map UIDs of the autosaves have been fully stored to the and dictionaries. + /// This property is required to be true in order to start a new session. It also handles the state of , to catch other autosaves that might be created while the program is running. + /// + public bool HasAutosavesScanned + { + get => hasAutosavesScanned; + internal set + { + hasAutosavesScanned = value; + watcher.EnableRaisingEvents = value; + } + } + + public ConcurrentDictionary AutosaveHeaders { get; } = new(); + public ConcurrentDictionary AutosaveDetails { get; } = new(); + + + public AutosaveScanner(IRandomizerEvents events, + IFileSystemWatcher watcher, + IFilePathManager filePathManager, + IRandomizerConfig config, + IFileSystem fileSystem, + IGbxService gbx, + ILogger logger) + { + this.events = events; + this.watcher = watcher; + this.filePathManager = filePathManager; + this.config = config; + this.fileSystem = fileSystem; + this.gbx = gbx; + this.logger = logger; + + filePathManager.UserDataDirectoryPathUpdated += UserDataDirectoryPathUpdated; + watcher.Changed += OnAutosaveCreatedOrChanged; + } + + internal void UserDataDirectoryPathUpdated() + { + watcher.Path = filePathManager.AutosavesDirectoryPath ?? ""; + ResetAutosaves(); + } + + private static DateTime lastAutosaveUpdate = DateTime.MinValue; + + internal void OnAutosaveCreatedOrChanged(object sender, FileSystemEventArgs e) + { + // Hack to fix the issue of this event sometimes running twice + var lastWriteTime = fileSystem.File.GetLastWriteTime(e.FullPath); + + if (lastWriteTime == lastAutosaveUpdate) + { + return; + } + + lastAutosaveUpdate = lastWriteTime; + // + + var retryCounter = 0; + + CGameCtnReplayRecord replay; + + while (true) + { + try + { + // Any kind of autosave update section + + logger.LogInformation("Analyzing a new file {autosavePath} in autosaves folder...", e.FullPath); + + using var stream = fileSystem.File.OpenRead(e.FullPath); + + if (GameBox.ParseNode(stream) is not CGameCtnReplayRecord r) + { + logger.LogWarning("Found file {file} that is not a replay.", e.FullPath); + return; + } + + if (r.MapInfo is null) + { + logger.LogWarning("Found replay {file} that has no map info.", e.FullPath); + return; + } + + AutosaveHeaders.TryAdd(r.MapInfo.Id, new AutosaveHeader(Path.GetFileName(e.FullPath), r)); + + replay = r; + } + catch (Exception ex) + { + retryCounter++; + + logger.LogError(ex, "Error while analyzing a new file {autosavePath} in autosaves folder (retry {counter}/{maxRetries}).", + e.FullPath, retryCounter, config.ReplayParseFailRetries); + + if (retryCounter >= config.ReplayParseFailRetries) + { + return; + } + + Thread.Sleep(config.ReplayParseFailDelayMs); + + continue; + } + + break; + } + + events.OnAutosaveCreatedOrChanged(e.FullPath, replay); + } + + /// + /// Scans the autosaves, which is required before running the session, to avoid already played maps. + /// + /// True if anything changed. + /// + public bool ScanAutosaves() + { + if (filePathManager.AutosavesDirectoryPath is null) + { + throw new ImportantPropertyNullException("Cannot scan autosaves without a valid user data directory path."); + } + + var anythingChanged = false; + + foreach (var fileName in fileSystem.Directory.EnumerateFiles(filePathManager.AutosavesDirectoryPath).AsParallel()) + { + try + { + if (ProcessAutosaveHeader(fileName)) + { + anythingChanged = true; + } + } + catch (NotAGbxException) + { + // do nothing + } + catch (Exception ex) + { + logger.LogWarning(ex, "Gbx error found in the Autosaves folder when reading the header."); + } + } + + HasAutosavesScanned = true; + + return anythingChanged; + } + + internal bool ProcessAutosaveHeader(string fileName) + { + using var stream = fileSystem.File.OpenRead(fileName); + + if (gbx.ParseHeader(stream) is not CGameCtnReplayRecord replay || replay.MapInfo is null) + { + return false; + } + + return AutosaveHeaders.TryAdd(replay.MapInfo.Id, new AutosaveHeader(Path.GetFileName(fileName), replay)); + } + + /// + /// Scans autosave details (mostly the map inside the replay and getting its info) that are then used to display more detailed information about a list of maps. Only some replay properties are stored to value memory. + /// + /// + public void ScanDetailsFromAutosaves() + { + foreach (var autosaveMapUid in AutosaveHeaders.Keys) + { + try + { + UpdateAutosaveDetail(autosaveMapUid); + } + catch (Exception ex) + { + // this happens in async context, logger may not be safe for that, some errors may get lost + Debug.WriteLine("Exception during autosave detail scan: " + ex); + } + } + } + + internal void UpdateAutosaveDetail(string autosaveMapUid) + { + if (filePathManager.AutosavesDirectoryPath is null) + { + throw new ImportantPropertyNullException("Cannot update autosave details without a valid autosaves directory."); + } + + var autosavePath = Path.Combine(filePathManager.AutosavesDirectoryPath, AutosaveHeaders[autosaveMapUid].FilePath); + + using var stream = fileSystem.File.OpenRead(autosavePath); + + if (gbx.Parse(stream) is not CGameCtnReplayRecord { Time: not null } replay) + { + return; + } + + if (replay.Challenge is null) + { + return; + } + + var mapName = TextFormatter.Deformat(replay.Challenge.MapName); + var mapEnv = (string)replay.Challenge.Collection; + var mapBronzeTime = replay.Challenge.TMObjective_BronzeTime ?? throw new ImportantPropertyNullException("Bronze time is null."); + var mapSilverTime = replay.Challenge.TMObjective_SilverTime ?? throw new ImportantPropertyNullException("Silver time is null."); + var mapGoldTime = replay.Challenge.TMObjective_GoldTime ?? throw new ImportantPropertyNullException("Gold time is null."); + var mapAuthorTime = replay.Challenge.TMObjective_AuthorTime ?? throw new ImportantPropertyNullException("Author time is null."); + var mapAuthorScore = replay.Challenge.AuthorScore ?? throw new ImportantPropertyNullException("AuthorScore is null."); + var mapMode = replay.Challenge.Mode; + var mapCarPure = replay.Challenge.PlayerModel?.Id; + var mapCar = string.IsNullOrEmpty(mapCarPure) ? $"{mapEnv}Car" : mapCarPure; + + mapCar = mapCar switch + { + Constants.AlpineCar => Constants.SnowCar, + Constants.American or Constants.SpeedCar => Constants.DesertCar, + Constants.Rally => Constants.RallyCar, + Constants.SportCar => Constants.IslandCar, + _ => mapCar + }; + + var ghost = replay.GetGhosts(alsoInClips: false).FirstOrDefault(); + + AutosaveDetails[autosaveMapUid] = new( + replay.Time.Value, + Score: ghost?.StuntScore, + Respawns: ghost?.Respawns, + mapName, + mapEnv, + mapCar, + mapBronzeTime, + mapSilverTime, + mapGoldTime, + mapAuthorTime, + mapAuthorScore, + mapMode); + } + + /// + /// Cleans up the autosave storage and, most importantly, restricts the engine from functionalities that require the autosaves (). + /// + public void ResetAutosaves() + { + AutosaveHeaders.Clear(); + AutosaveDetails.Clear(); + HasAutosavesScanned = false; + } +} diff --git a/Src/RandomizerTMF.Logic/Services/DelayService.cs b/Src/RandomizerTMF.Logic/Services/DelayService.cs new file mode 100644 index 0000000..61750bb --- /dev/null +++ b/Src/RandomizerTMF.Logic/Services/DelayService.cs @@ -0,0 +1,14 @@ +namespace RandomizerTMF.Logic.Services; + +public interface IDelayService +{ + Task Delay(int ms, CancellationToken cancellationToken); +} + +public class DelayService : IDelayService +{ + public async Task Delay(int ms, CancellationToken cancellationToken) + { + await Task.Delay(ms, cancellationToken); + } +} diff --git a/Src/RandomizerTMF.Logic/Services/DiscordRichPresence.cs b/Src/RandomizerTMF.Logic/Services/DiscordRichPresence.cs new file mode 100644 index 0000000..8b8e5cd --- /dev/null +++ b/Src/RandomizerTMF.Logic/Services/DiscordRichPresence.cs @@ -0,0 +1,162 @@ +using DiscordRPC; +using System.Text; + +namespace RandomizerTMF.Logic.Services; + +public interface IDiscordRichPresence : IDisposable +{ + void Configuring(); + void Idle(); + void InDashboard(); + void SessionStart(DateTime start); + void SessionPredictEnd(DateTime end); + void SessionMap(string mapName, string imageUrl, string env); + void SessionDetails(string details); + void AddToSessionPredictEnd(TimeSpan pausedTime); + void SessionState(int atCount = 0, int goldCount = 0, int skipCount = 0); + void SessionDefaultAsset(); +} + +internal class DiscordRichPresence : IDiscordRichPresence +{ + private readonly DiscordRpcClient? client; + private readonly IRandomizerConfig config; + + public DiscordRichPresence(DiscordRpcLogger discordLogger, IRandomizerConfig config) + { + this.config = config; + + if (config.DiscordRichPresence.Disable) + { + return; + } + + client = new DiscordRpcClient("1048435107494637618", logger: discordLogger); + client.Initialize(); + } + + public void InDashboard() + { + Default("In dashboard"); + } + + public void Configuring() + { + Default("Configuring the app"); + } + + public void Idle() + { + Default("Idle"); + } + + public void SessionDetails(string details) + { + client?.UpdateDetails(details); + } + + public void SessionStart(DateTime start) + { + client?.UpdateStartTime(start); + } + + public void SessionPredictEnd(DateTime end) + { + client?.UpdateEndTime(end); + } + + public void AddToSessionPredictEnd(TimeSpan addition) + { + if (client?.CurrentPresence.Timestamps.End.HasValue == true) + { + SessionPredictEnd(client.CurrentPresence.Timestamps.End.Value + addition); + } + } + + public void SessionMap(string mapName, string imageUrl, string env) + { + if (config.DiscordRichPresence.Disable) + { + return; + } + + var envRemap = env.ToLower(); + + switch (envRemap) + { + case "snow": envRemap = "alpine"; break; + case "speed": envRemap = "desert"; break; + } + + if (!config.DiscordRichPresence.DisableMapThumbnail) + { + client?.UpdateLargeAsset(imageUrl, mapName); + } + + client?.UpdateSmallAsset(envRemap, env); + } + + public void SessionState(int atCount = 0, int goldCount = 0, int skipCount = 0) + { + client?.UpdateState(BuildState(atCount, goldCount, skipCount)); + } + + internal static string BuildState(int atCount, int goldCount, int skipCount) + { + var builder = new StringBuilder(); + + builder.Append(atCount); + builder.Append(" AT"); + + if (atCount != 1) + { + builder.Append('s'); + } + + builder.Append(", "); + builder.Append(goldCount); + builder.Append(" gold"); + + if (goldCount != 1) + { + builder.Append('s'); + } + + builder.Append(", "); + builder.Append(skipCount); + builder.Append(" skip"); + + if (skipCount != 1) + { + builder.Append('s'); + } + + return builder.ToString(); + } + + public void SessionDefaultAsset() + { + client?.UpdateLargeAsset("primary", ""); + client?.UpdateSmallAsset(); + } + + private void Default(string details) + { + client?.SetPresence(new RichPresence() + { + Details = details, + Timestamps = new Timestamps(DateTime.UtcNow), + Assets = new Assets() + { + LargeImageKey = "primary" + } + }); + } + + public void Dispose() + { + client?.ClearPresence(); + client?.Deinitialize(); + client?.Dispose(); + } +} diff --git a/Src/RandomizerTMF.Logic/Services/FilePathManager.cs b/Src/RandomizerTMF.Logic/Services/FilePathManager.cs new file mode 100644 index 0000000..aa74bce --- /dev/null +++ b/Src/RandomizerTMF.Logic/Services/FilePathManager.cs @@ -0,0 +1,114 @@ +using System.IO.Abstractions; + +namespace RandomizerTMF.Logic.Services; + +public interface IFilePathManager +{ + string? AutosavesDirectoryPath { get; } + string? DownloadedDirectoryPath { get; } + string? TmForeverExeFilePath { get; } + string? TmUnlimiterExeFilePath { get; } + string? UserDataDirectoryPath { get; set; } + + event Action UserDataDirectoryPathUpdated; + + GameDirInspectResult UpdateGameDirectory(string gameDirectoryPath); +} + +public class FilePathManager : IFilePathManager +{ + private readonly IRandomizerConfig config; + private readonly IFileSystem fileSystem; + private string? userDataDirectoryPath; + + /// + /// General directory of the user data. It also sets the , , and path with it. + /// + public string? UserDataDirectoryPath + { + get => userDataDirectoryPath; + set + { + userDataDirectoryPath = value; + + AutosavesDirectoryPath = userDataDirectoryPath is null ? null : Path.Combine(userDataDirectoryPath, Constants.Tracks, Constants.Replays, Constants.Autosaves); + DownloadedDirectoryPath = userDataDirectoryPath is null ? null + : Path.Combine(userDataDirectoryPath, Constants.Tracks, Constants.Challenges, Constants.Downloaded, string.IsNullOrWhiteSpace(config.DownloadedMapsDirectory) + ? Constants.DownloadedMapsDirectory + : config.DownloadedMapsDirectory); + + UserDataDirectoryPathUpdated?.Invoke(); + } + } + + public string? AutosavesDirectoryPath { get; private set; } + public string? DownloadedDirectoryPath { get; private set; } + public static string SessionsDirectoryPath { get; } = Constants.Sessions; + + public string? TmForeverExeFilePath { get; private set; } + public string? TmUnlimiterExeFilePath { get; private set; } + + public event Action? UserDataDirectoryPathUpdated; + + public FilePathManager(IRandomizerConfig config, IFileSystem fileSystem) + { + this.config = config; + this.fileSystem = fileSystem; + } + + public static string ClearFileName(string fileName) + { + return string.Join('_', fileName.Split(Path.GetInvalidFileNameChars())); + } + + public GameDirInspectResult UpdateGameDirectory(string gameDirectoryPath) + { + config.GameDirectory = gameDirectoryPath; + + var nadeoIniFilePath = Path.Combine(gameDirectoryPath, Constants.NadeoIni); + var tmForeverExeFilePath = Path.Combine(gameDirectoryPath, Constants.TmForeverExe); + var tmUnlimiterExeFilePath = Path.Combine(gameDirectoryPath, Constants.TmInifinityExe); + + var nadeoIniException = default(Exception); + var tmForeverExeException = default(Exception); + var tmUnlimiterExeException = default(Exception); + + try + { + var nadeoIni = NadeoIni.Parse(nadeoIniFilePath, fileSystem); + var myDocuments = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + var newUserDataDirectoryPath = Path.Combine(myDocuments, nadeoIni.UserSubDir); + + if (UserDataDirectoryPath != newUserDataDirectoryPath) + { + UserDataDirectoryPath = newUserDataDirectoryPath; + } + } + catch (Exception ex) + { + nadeoIniException = ex; + } + + try + { + using var fs = fileSystem.File.OpenRead(tmForeverExeFilePath); + TmForeverExeFilePath = tmForeverExeFilePath; + } + catch (Exception ex) + { + tmForeverExeException = ex; + } + + try + { + using var fs = fileSystem.File.OpenRead(tmUnlimiterExeFilePath); + TmUnlimiterExeFilePath = tmUnlimiterExeFilePath; + } + catch (Exception ex) + { + tmUnlimiterExeException = ex; + } + + return new GameDirInspectResult(nadeoIniException, tmForeverExeException, tmUnlimiterExeException); + } +} diff --git a/Src/RandomizerTMF.Logic/Services/GbxService.cs b/Src/RandomizerTMF.Logic/Services/GbxService.cs new file mode 100644 index 0000000..859337e --- /dev/null +++ b/Src/RandomizerTMF.Logic/Services/GbxService.cs @@ -0,0 +1,22 @@ +using GBX.NET; + +namespace RandomizerTMF.Logic.Services; + +public interface IGbxService +{ + Node? Parse(Stream stream); + Node? ParseHeader(Stream stream); +} + +public class GbxService : IGbxService +{ + public Node? Parse(Stream stream) + { + return GameBox.ParseNode(stream); + } + + public Node? ParseHeader(Stream stream) + { + return GameBox.ParseNodeHeader(stream); + } +} diff --git a/Src/RandomizerTMF.Logic/Services/MapDownloader.cs b/Src/RandomizerTMF.Logic/Services/MapDownloader.cs new file mode 100644 index 0000000..dd25d5e --- /dev/null +++ b/Src/RandomizerTMF.Logic/Services/MapDownloader.cs @@ -0,0 +1,287 @@ +using GBX.NET.Engines.Game; +using GBX.NET; +using Microsoft.Extensions.Logging; +using RandomizerTMF.Logic.Exceptions; +using System.Diagnostics; +using System.Net; +using TmEssentials; +using System.IO.Abstractions; + +namespace RandomizerTMF.Logic.Services; + +public interface IMapDownloader +{ + Task PrepareNewMapAsync(Session currentSession, CancellationToken cancellationToken); +} + +public class MapDownloader : IMapDownloader +{ + private readonly IRandomizerEvents events; + private readonly IRandomizerConfig config; + private readonly IFilePathManager filePathManager; + private readonly IDiscordRichPresence discord; + private readonly IValidator validator; + private readonly HttpClient http; + private readonly IRandomGenerator random; + private readonly IDelayService delayService; + private readonly IFileSystem fileSystem; + private readonly IGbxService gbxService; + private readonly ILogger logger; + + private readonly int requestMaxAttempts = 10; + private int requestAttempt; + + public MapDownloader(IRandomizerEvents events, + IRandomizerConfig config, + IFilePathManager filePathManager, + IDiscordRichPresence discord, + IValidator validator, + HttpClient http, + IRandomGenerator random, + IDelayService delayService, + IFileSystem fileSystem, + IGbxService gbxService, + ILogger logger) + { + this.events = events; + this.config = config; + this.filePathManager = filePathManager; + this.discord = discord; // After PrepareNewMapAsync refactor no longer needed + this.validator = validator; + this.http = http; + this.random = random; + this.delayService = delayService; + this.fileSystem = fileSystem; + this.gbxService = gbxService; + this.logger = logger; + } + + private void Status(string status) + { + events.OnStatus(status); + } + + /// + /// Requests, downloads, and allocates the map. + /// + /// + /// + /// + /// True if map has been prepared successfully, false if soft error/problem appeared (it's possible to ask for another track). + public async Task PrepareNewMapAsync(Session currentSession, CancellationToken cancellationToken) + { + using var randomResponse = await FetchRandomTrackAsync(cancellationToken); + + // Following code gathers the track ID from the HEAD response (and ensures everything makes sense) + + var requestUri = GetRequestUriFromResponse(randomResponse); + + if (requestUri is null) + { + logger.LogWarning("RequestUri of /trackrandom is null"); + return false; + } + + var trackId = GetTrackIdFromUri(requestUri); + + if (trackId is null) // Cannot really happen + { + logger.LogWarning("TrackId of Uri redirect is null"); + return false; + } + + // With the ID, it is possible to immediately download the track Gbx and process it with GBX.NET + + using var trackGbxResponse = await DownloadMapByTrackIdAsync(requestUri.Host, trackId, cancellationToken); + + var map = await GetMapFromResponseAsync(trackGbxResponse, cancellationToken); + + if (map is null) + { + logger.LogWarning("Map object from /trackgbx is null"); + return false; + } + + // Map validation ensures that the player won't receive duplicate maps + // + ensures some additional filters like "No Unlimiter", which cannot be filtered on TMX + await ValidateMapAsync(map, cancellationToken); + + // The map is saved to the defined DownloadedDirectoryPath using the FileName provided in ContentDisposition + + var mapSavePath = await SaveMapAsync(trackGbxResponse, map.MapUid, cancellationToken); + + var tmxLink = requestUri.ToString(); + + currentSession.Map = new SessionMap(map, randomResponse.Headers.Date ?? DateTimeOffset.Now, tmxLink) // The map should be ready to be played now + { + FilePath = mapSavePath + }; + + var mapName = TextFormatter.Deformat(map.MapName); + + currentSession.Data?.Maps.Add(new() + { + Name = mapName, + Uid = map.MapUid, + TmxLink = tmxLink, + }); + + discord.SessionMap(mapName, $"https://{requestUri.Host}/trackshow/{trackId}/image/1", map.Collection); + + return true; + } + + internal async Task SaveMapAsync(HttpResponseMessage trackGbxResponse, string mapUidFallback, CancellationToken cancellationToken) + { + Status("Saving the map..."); + + if (filePathManager.DownloadedDirectoryPath is null) + { + throw new UnreachableException("Cannot update autosaves without a valid user data directory path."); + } + + logger.LogDebug("Ensuring {dir} exists...", filePathManager.DownloadedDirectoryPath); + fileSystem.Directory.CreateDirectory(filePathManager.DownloadedDirectoryPath); // Ensures the directory really exists + + logger.LogDebug("Preparing the file name..."); + + // Extracts the file name, and if it fails, it uses the MapUid as a fallback + var fileName = trackGbxResponse.Content.Headers.ContentDisposition?.FileName?.Trim('\"') ?? $"{mapUidFallback}.Challenge.Gbx"; + + // Validates the file name and fixes it if needed + fileName = FilePathManager.ClearFileName(fileName); + + var mapSavePath = Path.Combine(filePathManager.DownloadedDirectoryPath, fileName); + + logger.LogInformation("Saving the map as {fileName}...", mapSavePath); + + // WriteAllBytesAsync is used instead of GameBox.Save to ensure 1:1 data of the original map + var trackData = await trackGbxResponse.Content.ReadAsByteArrayAsync(cancellationToken); + + await fileSystem.File.WriteAllBytesAsync(mapSavePath, trackData, cancellationToken); + + logger.LogInformation("Map saved successfully!"); + + return mapSavePath; + } + + internal async Task FetchRandomTrackAsync(CancellationToken cancellationToken) + { + Status("Fetching random track..."); + + // Randomized URL is constructed with the ToUrl() method. + var requestUrl = config.Rules.RequestRules.ToUrl(random); + + logger.LogDebug("Requesting generated URL: {url}", requestUrl); + var randomResponse = await http.HeadAsync(requestUrl, cancellationToken); + + if (randomResponse.StatusCode == HttpStatusCode.NotFound) + { + // The session is ALWAYS invalid if there's no map that can be found. + // This DOES NOT relate to the lack of maps left that the user hasn't played. + + logger.LogWarning("No map fulfills the randomization filter."); + + throw new InvalidSessionException(); + } + + randomResponse.EnsureSuccessStatusCode(); // Handles server issues, should normally retry + + return randomResponse; + } + + internal Uri? GetRequestUriFromResponse(HttpResponseMessage response) + { + if (response.RequestMessage is null) + { + logger.LogWarning("Response from the HEAD request does not contain information about the request message. This is odd..."); + return null; + } + + if (response.RequestMessage.RequestUri is null) + { + logger.LogWarning("Response from the HEAD request does not contain information about the request URI. This is odd..."); + return null; + } + + return response.RequestMessage.RequestUri; + } + + internal string? GetTrackIdFromUri(Uri uri) + { + var trackId = uri.Segments.LastOrDefault(); + + if (trackId is null) + { + logger.LogWarning("Request URI does not contain any segments. This is very odd..."); + return null; + } + + return trackId; + } + + internal async Task DownloadMapByTrackIdAsync(string host, string trackId, CancellationToken cancellationToken) + { + Status($"Downloading track {trackId}..."); + + var trackGbxUrl = $"https://{host}/trackgbx/{trackId}"; + + logger.LogDebug("Downloading track on {trackGbxUrl}...", trackGbxUrl); + + var trackGbxResponse = await http.GetAsync(trackGbxUrl, cancellationToken); + + trackGbxResponse.EnsureSuccessStatusCode(); + + return trackGbxResponse; + } + + private async Task GetMapFromResponseAsync(HttpResponseMessage trackGbxResponse, CancellationToken cancellationToken) + { + using var stream = await trackGbxResponse.Content.ReadAsStreamAsync(cancellationToken); + + // The map is gonna be parsed as it is downloading throughout + + Status("Parsing the map..."); + + if (gbxService.Parse(stream) is CGameCtnChallenge map) + { + return map; + } + + logger.LogWarning("Downloaded file is not a valid Gbx map file!"); + + return null; + } + + internal async Task ValidateMapAsync(CGameCtnChallenge map, CancellationToken cancellationToken) + { + Status("Validating the map..."); + + if (validator.ValidateMap(map, out string? invalidBlock)) + { + requestAttempt = 0; + return; + } + + // Attempts another track if invalid + requestAttempt++; + + if (invalidBlock is not null) + { + Status($"{invalidBlock} in {map.Collection}"); + logger.LogInformation("Map is invalid because {invalidBlock} is not valid for the {env} environment.", invalidBlock, map.Collection); + await delayService.Delay(500, cancellationToken); + } + + Status($"Map is invalid (attempt {requestAttempt}/{requestMaxAttempts})."); + + if (requestAttempt >= requestMaxAttempts) + { + logger.LogWarning("Map is invalid after {MaxAttempts} attempts. Cancelling the session...", requestMaxAttempts); + requestAttempt = 0; + throw new InvalidSessionException(); + } + + throw new MapValidationException(); + } +} diff --git a/Src/RandomizerTMF.Logic/Services/RandomGenerator.cs b/Src/RandomizerTMF.Logic/Services/RandomGenerator.cs new file mode 100644 index 0000000..2ea200c --- /dev/null +++ b/Src/RandomizerTMF.Logic/Services/RandomGenerator.cs @@ -0,0 +1,20 @@ +namespace RandomizerTMF.Logic.Services; + +public interface IRandomGenerator +{ + int Next(int maxValue); + int Next(int minValue, int maxValue); +} + +public class RandomGenerator : IRandomGenerator +{ + public int Next(int maxValue) + { + return Random.Shared.Next(maxValue); // Not testable + } + + public int Next(int minValue, int maxValue) + { + return Random.Shared.Next(minValue, maxValue); // Not testable + } +} diff --git a/Src/RandomizerTMF.Logic/Services/RandomizerConfig.cs b/Src/RandomizerTMF.Logic/Services/RandomizerConfig.cs new file mode 100644 index 0000000..f4353ea --- /dev/null +++ b/Src/RandomizerTMF.Logic/Services/RandomizerConfig.cs @@ -0,0 +1,110 @@ +using Microsoft.Extensions.Logging; +using System.IO.Abstractions; +using System.Reflection; +using YamlDotNet.Core; +using YamlDotNet.Serialization; + +namespace RandomizerTMF.Logic.Services; + +public interface IRandomizerConfig +{ + string? DownloadedMapsDirectory { get; set; } + string? GameDirectory { get; set; } + ModulesConfig Modules { get; set; } + string? ReplayFileFormat { get; set; } + int ReplayParseFailDelayMs { get; set; } + int ReplayParseFailRetries { get; set; } + RandomizerRules Rules { get; set; } + bool DisableAutosaveDetailScan { get; set; } + bool DisableAutoSkip { get; set; } + DiscordRichPresenceConfig DiscordRichPresence { get; set; } + + void Save(); +} + +public class RandomizerConfig : IRandomizerConfig +{ + private readonly ILogger? logger; + private readonly IFileSystem? fileSystem; + + public string? GameDirectory { get; set; } + public string? DownloadedMapsDirectory { get; set; } = Constants.DownloadedMapsDirectory; + + [YamlMember(Order = 998)] + public ModulesConfig Modules { get; set; } = new(); + + [YamlMember(Order = 999)] + public RandomizerRules Rules { get; set; } = new(); + + /// + /// {0} is the map name, {1} is the replay score (example: 9'59''59 in Race/Puzzle or 999_9'59''59 in Platform/Stunts), {2} is the player login. + /// + public string? ReplayFileFormat { get; set; } = Constants.DefaultReplayFileFormat; + + public int ReplayParseFailRetries { get; set; } = 10; + public int ReplayParseFailDelayMs { get; set; } = 50; + public bool DisableAutosaveDetailScan { get; set; } = false; + public bool DisableAutoSkip { get; set; } = false; + + public DiscordRichPresenceConfig DiscordRichPresence { get; set; } = new(); + + public RandomizerConfig() + { + + } + + public RandomizerConfig(ILogger logger, IFileSystem fileSystem) + { + this.logger = logger; + this.fileSystem = fileSystem; + } + + /// + /// This method should be ran only at the start of the randomizer engine. + /// + /// + public static RandomizerConfig GetOrCreate(ILogger logger, IFileSystem fileSystem) + { + var config = default(RandomizerConfig); + + if (fileSystem.File.Exists(Constants.ConfigYml) == true) + { + logger.LogInformation("Config file found, loading..."); + + try + { + using var reader = fileSystem.File.OpenText(Constants.ConfigYml); + config = Yaml.Deserializer.Deserialize(reader); + typeof(RandomizerConfig).GetField(nameof(logger), BindingFlags.Instance | BindingFlags.NonPublic)?.SetValue(config, logger); + typeof(RandomizerConfig).GetField(nameof(fileSystem), BindingFlags.Instance | BindingFlags.NonPublic)?.SetValue(config, fileSystem); + } + catch (YamlException ex) + { + logger.LogWarning(ex.InnerException, "Error while deserializing the config file ({configPath}; [{start}] - [{end}]).", Constants.ConfigYml, ex.Start, ex.End); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Error while deserializing the config file ({configPath}).", Constants.ConfigYml); + } + } + + if (config is null) + { + logger.LogInformation("Config file not found or is corrupted, creating a new one..."); + config = new RandomizerConfig(logger, fileSystem); + } + + config.Save(); + + return config; + } + + public void Save() + { + logger?.LogInformation("Saving the config file..."); + + fileSystem?.File.WriteAllText(Constants.ConfigYml, Yaml.Serializer.Serialize(this)); + + logger?.LogInformation("Config file saved."); + } +} diff --git a/Src/RandomizerTMF.Logic/Services/RandomizerEngine.cs b/Src/RandomizerTMF.Logic/Services/RandomizerEngine.cs new file mode 100644 index 0000000..1651f4c --- /dev/null +++ b/Src/RandomizerTMF.Logic/Services/RandomizerEngine.cs @@ -0,0 +1,138 @@ +using Microsoft.Extensions.Logging; +using System.IO.Abstractions; + +namespace RandomizerTMF.Logic.Services; + +public interface IRandomizerEngine +{ + ISession? CurrentSession { get; } + bool HasSessionRunning { get; } + bool SessionEnding { get; } + + Task EndSessionAsync(); + void Exit(); + void StartSession(); +} + +public class RandomizerEngine : IRandomizerEngine +{ + private readonly IRandomizerConfig config; + private readonly IRandomizerEvents events; + private readonly IDiscordRichPresence discord; + private readonly ILogger logger; + private readonly Func session; + + public static string? Version { get; } = typeof(RandomizerEngine).Assembly.GetName().Version?.ToString(3); + + public ISession? CurrentSession { get; private set; } + public bool HasSessionRunning => CurrentSession is not null; + public bool SessionEnding { get; private set; } + + public RandomizerEngine(IRandomizerConfig config, + IRandomizerEvents events, + IDiscordRichPresence discord, + IFileSystem fileSystem, + ILogger logger, + Func session) + { + this.config = config; + this.events = events; + this.discord = discord; + this.logger = logger; + this.session = session; + + logger.LogInformation("Starting Randomizer Engine..."); + + fileSystem.Directory.CreateDirectory(FilePathManager.SessionsDirectoryPath); + + logger.LogInformation("Predefining LZO algorithm..."); + + GBX.NET.Lzo.SetLzo(typeof(GBX.NET.LZO.MiniLZO)); + + logger.LogInformation("Randomizer TMF initialized."); + + events.MedalUpdate += ScoreChanged; + events.MapSkip += ScoreChanged; + } + + private void ScoreChanged() + { + if (CurrentSession is null) + { + discord.SessionState(); + } + else + { + discord.SessionState(CurrentSession.AuthorMaps.Count, CurrentSession.GoldMaps.Count, CurrentSession.SkippedMaps.Count); + } + } + + /// + /// Starts the randomizer session by creating a new that will handle randomization on different thread from the UI thread. + /// + public void StartSession() + { + if (config.GameDirectory is null) + { + return; + } + + CurrentSession = session(); + CurrentSession.Start(); + } + + /// + /// Does the cleanup of the session so that the new one can be instantiated without issues. + /// + private void ClearCurrentSession() + { + CurrentSession?.Stop(); + CurrentSession = null; + + if (logger is LoggerToFile loggerToFile) + { + loggerToFile.RemoveSessionWriter(); + } + } + + /// + /// MANUAL end of session. + /// + /// + public async Task EndSessionAsync() + { + if (SessionEnding || CurrentSession is null) + { + return; + } + + SessionEnding = true; + + events.OnStatus("Ending the session..."); + + CurrentSession.TokenSource.Cancel(); + + try + { + if (CurrentSession.Task is not null) + { + await CurrentSession.Task; // Kindly waits until the session considers it was cancelled. ClearCurrentSession is called within it. + } + } + catch (TaskCanceledException) + { + + } + + ClearCurrentSession(); + + SessionEnding = false; + } + + public void Exit() + { + logger.LogInformation("Exiting..."); + discord.Dispose(); + Environment.Exit(0); + } +} diff --git a/Src/RandomizerTMF.Logic/Services/RandomizerEvents.cs b/Src/RandomizerTMF.Logic/Services/RandomizerEvents.cs new file mode 100644 index 0000000..e66cc84 --- /dev/null +++ b/Src/RandomizerTMF.Logic/Services/RandomizerEvents.cs @@ -0,0 +1,94 @@ +using GBX.NET.Engines.Game; +using Microsoft.Extensions.Logging; +using TmEssentials; + +namespace RandomizerTMF.Logic.Services; + +public interface IRandomizerEvents +{ + event Action? MapEnded; + event Action? MapSkip; + event Action? MapStarted; + event Action? MedalUpdate; + event Action? Status; + event Action AutosaveCreatedOrChanged; + event Action? FirstMapStarted; + + void OnMapEnded(); + void OnMapSkip(); + void OnMapStarted(SessionMap map); + void OnMedalUpdate(); + void OnStatus(string status); + void OnAutosaveCreatedOrChanged(string fileName, CGameCtnReplayRecord replay); + void OnFirstMapStarted(); + void OnTimeResume(TimeSpan pausedTime); +} + +public class RandomizerEvents : IRandomizerEvents +{ + private readonly IRandomizerConfig config; + private readonly ILogger logger; + private readonly IDiscordRichPresence discord; + + public event Action? Status; + public event Action? MapStarted; + public event Action? MapEnded; + public event Action? MapSkip; + public event Action? MedalUpdate; + public event Action? AutosaveCreatedOrChanged; + public event Action? FirstMapStarted; + + public RandomizerEvents(IRandomizerConfig config, ILogger logger, IDiscordRichPresence discord) + { + this.config = config; + this.logger = logger; + this.discord = discord; + } + + public void OnStatus(string status) + { + // Status kind of logging + // Unlike regular logs, these are shown to the user in a module, while also written to the log file in its own way + logger.LogInformation("STATUS: {status}", status); + + Status?.Invoke(status); + } + + public void OnFirstMapStarted() + { + FirstMapStarted?.Invoke(); + + var now = DateTime.UtcNow; + + discord.SessionStart(now); + discord.SessionPredictEnd(now + config.Rules.TimeLimit); + } + + public void OnMapStarted(SessionMap map) + { + MapStarted?.Invoke(map); + + discord.SessionDetails("Playing " + TextFormatter.Deformat(map.Map.MapName)); + } + + public void OnMapEnded() + { + MapEnded?.Invoke(); + + discord.SessionDefaultAsset(); + discord.SessionDetails("Map ended"); + } + + public void OnMapSkip() => MapSkip?.Invoke(); + public void OnMedalUpdate() => MedalUpdate?.Invoke(); + + public void OnAutosaveCreatedOrChanged(string fileName, CGameCtnReplayRecord replay) + { + AutosaveCreatedOrChanged?.Invoke(fileName, replay); + } + + public void OnTimeResume(TimeSpan pausedTime) + { + discord.AddToSessionPredictEnd(pausedTime); + } +} diff --git a/Src/RandomizerTMF.Logic/Services/TMForever.cs b/Src/RandomizerTMF.Logic/Services/TMForever.cs new file mode 100644 index 0000000..4441503 --- /dev/null +++ b/Src/RandomizerTMF.Logic/Services/TMForever.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.Logging; +using System.Diagnostics; + +namespace RandomizerTMF.Logic.Services; + +public interface ITMForever +{ + void OpenAutosave(string relativeFileName); + void OpenFile(string filePath); +} + +public class TMForever : ITMForever +{ + private readonly IRandomizerConfig config; + private readonly IFilePathManager filePathManager; + private readonly ILogger logger; + + public TMForever(IRandomizerConfig config, IFilePathManager filePathManager, ILogger logger) + { + this.config = config; + this.filePathManager = filePathManager; + this.logger = logger; + } + + public void OpenFile(string filePath) + { + if (config.GameDirectory is null) + { + throw new Exception("Game directory is null"); + } + + logger.LogInformation("Opening {filePath} in TMForever...", filePath); + + var startInfo = new ProcessStartInfo(Path.Combine(config.GameDirectory, Constants.TmForeverExe), $"/useexedir /singleinst /file=\"{filePath}\"") + { + + }; + + var process = new Process + { + StartInfo = startInfo + }; + + process.Start(); + + try + { + process.WaitForInputIdle(); + } + catch (InvalidOperationException ex) + { + logger.LogWarning(ex, "Could not wait for input."); + } + } + + /// + /// Opens the autosave ingame. + /// + /// File name relative to the Autosaves folder. + public void OpenAutosave(string relativeFileName) + { + if (filePathManager.AutosavesDirectoryPath is null) + { + throw new Exception("Cannot open an autosave ingame without a valid user data directory path."); + } + + OpenFile(Path.Combine(filePathManager.AutosavesDirectoryPath, relativeFileName)); + } +} diff --git a/Src/RandomizerTMF.Logic/Services/Validator.cs b/Src/RandomizerTMF.Logic/Services/Validator.cs new file mode 100644 index 0000000..bfb910c --- /dev/null +++ b/Src/RandomizerTMF.Logic/Services/Validator.cs @@ -0,0 +1,210 @@ +using GBX.NET.Engines.Game; +using RandomizerTMF.Logic.Exceptions; + +namespace RandomizerTMF.Logic.Services; + +public interface IValidator +{ + bool ValidateMap(CGameCtnChallenge map, out string? invalidBlock); + void ValidateRequestRules(); + void ValidateRules(); +} + +public class Validator : IValidator +{ + private readonly IAutosaveScanner autosaveScanner; + private readonly IAdditionalData additionalData; + private readonly IRandomizerConfig config; + + public Validator(IAutosaveScanner autosaveScanner, IAdditionalData additionalData, IRandomizerConfig config) + { + this.autosaveScanner = autosaveScanner; + this.additionalData = additionalData; + this.config = config; + } + + /// + /// Validates the session rules. This should be called right before the session start and after loading the modules. + /// + /// + public void ValidateRules() + { + var rules = config.Rules; + + if (rules.TimeLimit == TimeSpan.Zero) + { + throw new RuleValidationException("Time limit cannot be 0:00:00."); + } + + if (rules.TimeLimit > new TimeSpan(9, 59, 59)) + { + throw new RuleValidationException("Time limit cannot be above 9:59:59."); + } + + ValidateRequestRules(); + } + + public void ValidateRequestRules() + { + var requestRules = config.Rules.RequestRules; + + foreach (var primaryType in Enum.GetValues()) + { + if (primaryType is EPrimaryType.Race) + { + continue; + } + + if (requestRules.PrimaryType == primaryType + && (requestRules.Site is ESite.Any + || requestRules.Site.HasFlag(ESite.TMNF) || requestRules.Site.HasFlag(ESite.Nations))) + { + throw new RuleValidationException($"{primaryType} cannot be specifically selected with TMNF or Nations Exchange."); + } + } + + if (requestRules.Environment?.Count > 0) + { + if (requestRules.Site.HasFlag(ESite.Sunrise) + && !requestRules.Environment.Contains(EEnvironment.Island) + && !requestRules.Environment.Contains(EEnvironment.Coast) + && !requestRules.Environment.Contains(EEnvironment.Bay)) + { + throw new RuleValidationException("Island, Coast, or Bay has to be selected when environments are specified and Sunrise Exchange is selected."); + } + + if (requestRules.Site.HasFlag(ESite.Original) + && !requestRules.Environment.Contains(EEnvironment.Snow) + && !requestRules.Environment.Contains(EEnvironment.Desert) + && !requestRules.Environment.Contains(EEnvironment.Rally)) + { + throw new RuleValidationException("Snow, Desert, or Rally has to be selected when environments are specified and Original Exchange is selected."); + } + + if (!requestRules.Environment.Contains(EEnvironment.Stadium) + && (requestRules.Site.HasFlag(ESite.TMNF) || requestRules.Site.HasFlag(ESite.Nations))) + { + throw new RuleValidationException("Stadium has to be selected when environments are specified and TMNF or Nations Exchange is selected."); + } + + if (requestRules.Site.HasFlag(ESite.Sunrise) || requestRules.Site.HasFlag(ESite.Original)) + { + foreach (var env in requestRules.Environment) + { + if (requestRules.Vehicle?.Contains(env) == false) + { + throw new RuleValidationException("Envimix randomization is not allowed when Sunrise or Original Exchange is selected."); + } + } + } + } + + if (requestRules.Vehicle?.Count > 0) + { + if (!requestRules.Vehicle.Contains(EEnvironment.Island) + && !requestRules.Vehicle.Contains(EEnvironment.Coast) + && !requestRules.Vehicle.Contains(EEnvironment.Bay) + && requestRules.Site.HasFlag(ESite.Sunrise)) + { + throw new RuleValidationException("IslandCar, CoastCar, or BayCar has to be selected when cars are specified and Sunrise Exchange is selected."); + } + + if (!requestRules.Vehicle.Contains(EEnvironment.Snow) + && !requestRules.Vehicle.Contains(EEnvironment.Desert) + && !requestRules.Vehicle.Contains(EEnvironment.Rally) + && requestRules.Site.HasFlag(ESite.Original)) + { + throw new RuleValidationException("SnowCar, DesertCar, or RallyCar has to be selected when cars are specified and Original Exchange is selected."); + } + + if (!requestRules.Vehicle.Contains(EEnvironment.Stadium) + && (requestRules.Site.HasFlag(ESite.TMNF) || requestRules.Site.HasFlag(ESite.Nations))) + { + throw new RuleValidationException("StadiumCar has to be selected when cars are specified and TMNF or Nations Exchange is selected."); + } + } + + if (requestRules.EqualEnvironmentDistribution + && requestRules.EqualVehicleDistribution + && requestRules.Site is not ESite.TMUF) + { + throw new RuleValidationException("Equal environment and car distribution combined is only valid with TMUF Exchange."); + } + + if (requestRules.EqualEnvironmentDistribution + && (requestRules.Site is ESite.Any + || requestRules.Site.HasFlag(ESite.TMNF) || requestRules.Site.HasFlag(ESite.Nations))) + { + throw new RuleValidationException("Equal environment distribution is not valid with TMNF or Nations Exchange."); + } + + if (requestRules.EqualVehicleDistribution + && (requestRules.Site is ESite.Any + || requestRules.Site.HasFlag(ESite.TMNF) || requestRules.Site.HasFlag(ESite.Nations))) + { + throw new RuleValidationException("Equal vehicle distribution is not valid with TMNF or Nations Exchange."); + } + } + + /// + /// Checks if the map hasn't been already played or if it follows current session rules. + /// + /// Autosave information. + /// + /// True if valid, false if not valid. + public bool ValidateMap(CGameCtnChallenge map, out string? invalidBlock) + { + invalidBlock = null; + + if (autosaveScanner.AutosaveHeaders.ContainsKey(map.MapUid)) + { + return false; + } + + if (map.ChallengeParameters is null) // I hope TMX validates this though xd + { + return false; + } + + if (config.Rules.NoUnlimiter) + { + if (map.Chunks.TryGet(0x3F001000, out _)) + { + return false; + } + + if (map.Size is null) + { + return false; + } + + if (!additionalData.MapSizes.TryGetValue(map.Collection, out var sizes)) + { + return false; + } + + if (!sizes.Contains(map.Size.Value)) + { + return false; + } + + if (!additionalData.OfficialBlocks.TryGetValue(map.Collection, out var officialBlocks)) + { + return false; + } + + foreach (var block in map.GetBlocks()) + { + var blockName = block.Name.Trim(); + + if (!officialBlocks.Contains(blockName)) + { + invalidBlock = blockName; + return false; + } + } + } + + return true; + } +} diff --git a/Src/RandomizerTMF.Logic/Session.cs b/Src/RandomizerTMF.Logic/Session.cs new file mode 100644 index 0000000..033098b --- /dev/null +++ b/Src/RandomizerTMF.Logic/Session.cs @@ -0,0 +1,451 @@ +using GBX.NET.Engines.Game; +using Microsoft.Extensions.Logging; +using RandomizerTMF.Logic.Exceptions; +using RandomizerTMF.Logic.Services; +using System.Diagnostics; +using System.IO.Abstractions; +using TmEssentials; + +namespace RandomizerTMF.Logic; + +public interface ISession +{ + Dictionary AuthorMaps { get; } + SessionData? Data { get; } + Dictionary GoldMaps { get; } + StreamWriter? LogWriter { get; set; } + SessionMap? Map { get; set; } + Dictionary SkippedMaps { get; } + CancellationTokenSource? SkipTokenSource { get; } + Task? Task { get; } + CancellationTokenSource TokenSource { get; } + Stopwatch Watch { get; } + + bool ReloadMap(); + Task SkipMapAsync(); + void Start(); + void Stop(); +} + +public class Session : ISession +{ + private readonly IRandomizerEvents events; + private readonly IMapDownloader mapDownloader; + private readonly IValidator validator; + private readonly IRandomizerConfig config; + private readonly ITMForever game; + private readonly ILogger logger; + private readonly IFileSystem fileSystem; + + private bool isActualSkipCancellation; + private DateTime watchTemporaryStopTimestamp; + + public Stopwatch Watch { get; } = new(); + public CancellationTokenSource TokenSource { get; } = new(); + + public Task? Task { get; internal set; } + + public CancellationTokenSource? SkipTokenSource { get; private set; } + + public SessionMap? Map { get; set; } + + public SessionData? Data { get; private set; } + + // This "trilogy" handles the storage of played maps. If the player didn't receive at least gold and didn't skip it, it is not counted in the progress. + // It may (?) be better to wrap the CGameCtnChallenge into "CompletedMap" and have status of it being "gold", "author", or "skipped", and better handle that to make it script-friendly. + public Dictionary GoldMaps { get; } = new(); + public Dictionary AuthorMaps { get; } = new(); + public Dictionary SkippedMaps { get; } = new(); + + public StreamWriter? LogWriter { get; set; } + + public Session(IRandomizerEvents events, + IMapDownloader mapDownloader, + IValidator validator, + IRandomizerConfig config, + ITMForever game, + ILogger logger, + IFileSystem fileSystem) + { + this.events = events; + this.mapDownloader = mapDownloader; + this.validator = validator; + this.config = config; + this.game = game; + this.logger = logger; + this.fileSystem = fileSystem; + } + + private void Status(string status) + { + events.OnStatus(status); + } + + public void Start() + { + Task = Task.Run(() => RunSessionSafeAsync(TokenSource.Token), TokenSource.Token); + } + + /// + /// Runs the session in a way it won't ever throw an exception. Clears the session after its end as well. + /// + /// + /// + private async Task RunSessionSafeAsync(CancellationToken cancellationToken) + { + try + { + await RunSessionAsync(cancellationToken); + } + catch (TaskCanceledException) + { + Status("Session ended."); + } + catch (InvalidSessionException) + { + Status("Session ended. No maps found."); + } + catch (Exception ex) + { + Status("Session ended due to error."); + logger.LogError(ex, "Error during session."); + } + finally + { + Stop(); + } + } + + /// + /// Does the actual work during a running session. That this method ends means the session also ends. It does NOT clean up the session after its end. + /// + /// + /// + /// + private async Task RunSessionAsync(CancellationToken cancellationToken) + { + if (config.GameDirectory is null) + { + throw new UnreachableException("Game directory is null"); + } + + validator.ValidateRules(); + + Data = SessionData.Initialize(config, logger, fileSystem); + + LogWriter = fileSystem.File.CreateText(Path.Combine(Data.DirectoryPath, Constants.SessionLog)); + LogWriter.AutoFlush = true; + + if (logger is LoggerToFile loggerToFile) // Maybe needs to be nicer + { + loggerToFile.SetSessionWriter(LogWriter); + } + + while (true) + { + // This try block is used to handle map requests and their HTTP errors, mostly. + + try + { + if (!await mapDownloader.PrepareNewMapAsync(this, cancellationToken)) + { + await Task.Delay(500, cancellationToken); + continue; + } + + Data?.Save(); // May not be super necessary? + } + catch (HttpRequestException) + { + Status("Failed to fetch a track. Retrying..."); + await Task.Delay(1000, cancellationToken); + continue; + } + catch (MapValidationException) + { + logger.LogInformation("Map has not passed the validator, attempting another one..."); + await Task.Delay(500, cancellationToken); + continue; + } + catch (InvalidSessionException) + { + logger.LogWarning("Session is invalid."); + throw; + } + catch (TaskCanceledException) + { + logger.LogInformation("Session terminated during map request."); + throw; + } + catch (Exception ex) + { + Status("Error! Check the log for more details."); + logger.LogError(ex, "An error occurred during map request."); + + await Task.Delay(1000, cancellationToken); + + continue; + } + + await PlayMapAsync(cancellationToken); + + // Map is no longer tracked at this point + } + } + + /// + /// Handles the play loop of a map. Throws cancellation exception on session end (not the map end). + /// + /// + internal async Task PlayMapAsync(CancellationToken cancellationToken) + { + // Hacky last moment validations + + if (Map is null) + { + throw new UnreachableException("Map is null"); + } + + if (Map.FilePath is null) + { + throw new UnreachableException("Map.FilePath is null"); + } + + // Map starts here + + Status("Starting the map..."); + + game.OpenFile(Map.FilePath); + + if (Watch.ElapsedTicks == 0) + { + events.OnFirstMapStarted(); + } + + SkipTokenSource = StartTrackingMap(); + + events.OnMapStarted(Map); // This has to be called after SkipTokenSource is set + + Status("Playing the map..."); + + // This loop either softly stops when the map is skipped by the player + // or hardly stops when author medal is received / time limit is reached, End Session was clicked or an exception was thrown in general + + // SkipTokenSource is used within the session to skip a map, while TokenSource handles the whole session cancellation + + while (!SkipTokenSource.IsCancellationRequested) + { + if (Watch.Elapsed >= config.Rules.TimeLimit) // Time limit reached case + { + if (TokenSource is null) + { + throw new UnreachableException("CurrentSessionTokenSource is null"); + } + + // Will cause the Task.Delay below to throw a cancellation exception + // Code outside of the while loop wont be reached + TokenSource.Cancel(); + } + + await Task.Delay(20, cancellationToken); + } + + Watch.Stop(); // Time is paused until the next map starts + watchTemporaryStopTimestamp = DateTime.UtcNow; + + if (isActualSkipCancellation) // When its a manual skip and not an automated skip by author medal receive + { + Status("Skipping the map..."); + + // Apply the rules related to manual map skip + // This part has a scripting potential too if properly implemented + + SkipManually(Map); + + isActualSkipCancellation = false; + } + + Status("Ending the map..."); + + StopTrackingMap(); + } + + private CancellationTokenSource StartTrackingMap() + { + if (Watch.ElapsedTicks > 0) + { + events.OnTimeResume(DateTime.UtcNow - watchTemporaryStopTimestamp); + } + + Watch.Start(); + + events.AutosaveCreatedOrChanged += AutosaveCreatedOrChangedSafe; + + return new CancellationTokenSource(); + } + + internal void StopTrackingMap() + { + events.AutosaveCreatedOrChanged -= AutosaveCreatedOrChangedSafe; + SkipTokenSource = null; + Map = null; + events.OnMapEnded(); + } + + internal void SkipManually(SessionMap map) + { + // If the player didn't receive a gold/author medal, the skip is counted + if (GoldMaps.ContainsKey(map.MapUid) == false && AuthorMaps.ContainsKey(map.MapUid) == false) + { + SkippedMaps.TryAdd(map.MapUid, map); + map.LastTimestamp = Watch.Elapsed; + Data?.SetMapResult(map, Constants.Skipped); + } + + // In other words, if the player received a gold/author medal, the skip is forgiven + + // MapSkip event is thrown to update the UI + events.OnMapSkip(); + } + + private void AutosaveCreatedOrChangedSafe(string fullPath, CGameCtnReplayRecord replay) + { + try + { + _ = AutosaveCreatedOrChanged(fullPath, replay); + } + catch (Exception ex) + { + Status("Error when checking the autosave..."); + logger.LogError(ex, "Error when checking the autosave {autosavePath}.", fullPath); + } + } + + internal bool AutosaveCreatedOrChanged(string fullPath, CGameCtnReplayRecord replay) + { + // Current session map autosave update section + + if (replay.MapInfo is null) + { + logger.LogWarning("Found autosave {autosavePath} with missing map info.", fullPath); + return false; + } + + if (Map is null) + { + logger.LogWarning("Found autosave {autosavePath} for map {mapUid} while no session is running.", fullPath, replay.MapInfo.Id); + return false; + } + + if (Map.MapInfo != replay.MapInfo) + { + logger.LogWarning("Found autosave {autosavePath} for map {mapUid} while the current session map is {currentSessionMapUid}.", fullPath, replay.MapInfo.Id, Map.MapInfo.Id); + return false; + } + + Status("Copying the autosave..."); + + Data?.UpdateFromAutosave(fullPath, Map, replay, Watch.Elapsed); + + Status("Checking the autosave..."); + + // New autosave from the current map, save it into session for progression reasons + + // The following part has a scriptable potential + // There are different medal rules for each gamemode (and where to look for validating) + + EvaluateAutosave(fullPath, replay); + + Status("Playing the map..."); + + return true; + } + + internal void EvaluateAutosave(string fullPath, CGameCtnReplayRecord replay) + { + _ = Map is null || replay.MapInfo is null ? throw new UnreachableException("Map or Map.MapInfo is null") : ""; + + if (Map.ChallengeParameters.AuthorTime is null) + { + logger.LogWarning("Found autosave {autosavePath} for map {mapName} ({mapUid}) that has no author time.", + fullPath, + TextFormatter.Deformat(Map.Map.MapName).Trim(), + replay.MapInfo.Id); + + if (!config.DisableAutoSkip) + { + SkipTokenSource?.Cancel(); + } + + return; + } + + var ghost = replay.GetGhosts(alsoInClips: false).First(); + + if (Map.IsAuthorMedal(ghost)) + { + AuthorMedalReceived(Map); + + if (!config.DisableAutoSkip) + { + SkipTokenSource?.Cancel(); + } + + return; + } + + if (Map.IsGoldMedal(ghost)) + { + GoldMedalReceived(Map); + } + } + + private void GoldMedalReceived(SessionMap map) + { + GoldMaps.TryAdd(map.MapUid, map); + map.LastTimestamp = Watch.Elapsed; + Data?.SetMapResult(map, Constants.GoldMedal); + + events.OnMedalUpdate(); + } + + private void AuthorMedalReceived(SessionMap map) + { + GoldMaps.Remove(map.MapUid); + AuthorMaps.TryAdd(map.MapUid, map); + map.LastTimestamp = Watch.Elapsed; + Data?.SetMapResult(map, Constants.AuthorMedal); + + events.OnMedalUpdate(); + } + + public void Stop() + { + StopTrackingMap(); + Watch.Stop(); + Data?.SetReadOnlySessionYml(); + LogWriter?.Dispose(); + } + + public Task SkipMapAsync() + { + Status("Requested to skip the map..."); + isActualSkipCancellation = true; + SkipTokenSource?.Cancel(); + return Task.CompletedTask; + } + + public bool ReloadMap() + { + logger.LogInformation("Reloading the map..."); + + if (Map?.FilePath is null) + { + return false; + } + + game.OpenFile(Map.FilePath); + + return true; + } +} diff --git a/Src/RandomizerTMF.Logic/SessionData.cs b/Src/RandomizerTMF.Logic/SessionData.cs index 67f0768..4c4fc99 100644 --- a/Src/RandomizerTMF.Logic/SessionData.cs +++ b/Src/RandomizerTMF.Logic/SessionData.cs @@ -1,27 +1,144 @@ -using YamlDotNet.Serialization; +using GBX.NET.Engines.Game; +using Microsoft.Extensions.Logging; +using RandomizerTMF.Logic.Services; +using System.IO.Abstractions; +using TmEssentials; +using YamlDotNet.Serialization; namespace RandomizerTMF.Logic; public class SessionData { + private readonly IRandomizerConfig config; + private readonly ILogger? logger; + private readonly IFileSystem? fileSystem; + public string? Version { get; set; } public DateTimeOffset StartedAt { get; set; } public RandomizerRules Rules { get; set; } [YamlIgnore] public string StartedAtText => StartedAt.ToString("yyyy-MM-dd HH_mm_ss"); + + [YamlIgnore] + public string DirectoryPath { get; } public List Maps { get; set; } = new(); - public SessionData() : this(null, DateTimeOffset.Now, new()) + public SessionData() : this(version: null, // will be overwriten by deserialization + DateTimeOffset.Now, // will be overwriten by deserialization + new RandomizerConfig(), // unused in read-only state + logger: null, // unused in read-only state + fileSystem: null) // unused in read-only state { - + // This is legit only for read-only use cases and for YAML deserialization! } - public SessionData(string? version, DateTimeOffset startedAt, RandomizerRules rules) + private SessionData(string? version, + DateTimeOffset startedAt, + IRandomizerConfig config, + ILogger? logger, + IFileSystem? fileSystem) { Version = version; StartedAt = startedAt; - Rules = rules; + + this.config = config; + this.logger = logger; + this.fileSystem = fileSystem; + + Rules = config.Rules; + + DirectoryPath = Path.Combine(FilePathManager.SessionsDirectoryPath, StartedAtText); + } + + internal static SessionData Initialize(DateTimeOffset startedAt, IRandomizerConfig config, ILogger logger, IFileSystem fileSystem) + { + var data = new SessionData(RandomizerEngine.Version, startedAt, config, logger, fileSystem); + + fileSystem.Directory.CreateDirectory(data.DirectoryPath); + + data.Save(); + + return data; + } + + public static SessionData Initialize(IRandomizerConfig config, ILogger logger, IFileSystem fileSystem) + { + return Initialize(DateTimeOffset.Now, config, logger, fileSystem); + } + + public void SetMapResult(SessionMap map, string result) + { + var dataMap = Maps.First(x => x.Uid == map.MapUid); + + dataMap.Result = result; + dataMap.LastTimestamp = map.LastTimestamp; + + Save(); + } + + public void Save() + { + logger?.LogInformation("Saving the session data into file..."); + + fileSystem?.File.WriteAllText(Path.Combine(DirectoryPath, Constants.SessionYml), Yaml.Serializer.Serialize(this)); + + logger?.LogInformation("Session data saved."); + } + + internal void InternalSetReadOnlySessionYml() + { + var sessionYmlFile = Path.Combine(DirectoryPath, Constants.SessionYml); + fileSystem?.File.SetAttributes(sessionYmlFile, fileSystem.File.GetAttributes(sessionYmlFile) | FileAttributes.ReadOnly); + } + + public void SetReadOnlySessionYml() + { + try + { + InternalSetReadOnlySessionYml(); + } + catch (Exception ex) + { + logger?.LogError(ex, "Failed to set Session.yml as read-only."); + } + } + + public void UpdateFromAutosave(string fullPath, SessionMap map, CGameCtnReplayRecord replay, TimeSpan elapsed) + { + var score = map.Map.Mode switch + { + CGameCtnChallenge.PlayMode.Stunts => replay.GetGhosts(alsoInClips: false).First().StuntScore + "_", + CGameCtnChallenge.PlayMode.Platform => replay.GetGhosts(alsoInClips: false).First().Respawns + "_", + _ => "" + } + replay.Time.ToTmString(useHundredths: true, useApostrophe: true); + + var mapName = CompiledRegex.SpecialCharRegex().Replace(TextFormatter.Deformat(map.Map.MapName).Trim(), "_"); + + var replayFileFormat = string.IsNullOrWhiteSpace(config.ReplayFileFormat) + ? Constants.DefaultReplayFileFormat + : config.ReplayFileFormat; + + var replayFileName = FilePathManager.ClearFileName(string.Format(replayFileFormat, mapName, score, replay.PlayerLogin)); + + var replaysDir = Path.Combine(DirectoryPath, Constants.Replays); + var replayFilePath = Path.Combine(replaysDir, replayFileName); + + if (fileSystem is not null) + { + fileSystem.Directory.CreateDirectory(replaysDir); + fileSystem.File.Copy(fullPath, replayFilePath, overwrite: true); + } + + Maps.First(x => x.Uid == map.MapUid)? + .Replays + .Add(new() + { + FileName = replayFileName, + Timestamp = elapsed + }); + + Save(); } } diff --git a/Src/RandomizerTMF.Logic/SessionMap.cs b/Src/RandomizerTMF.Logic/SessionMap.cs index eeb6e80..0c79916 100644 --- a/Src/RandomizerTMF.Logic/SessionMap.cs +++ b/Src/RandomizerTMF.Logic/SessionMap.cs @@ -13,8 +13,10 @@ public class SessionMap : ISessionMap public string TmxLink { get; } public TimeSpan? LastTimestamp { get; set; } + + public string? FilePath { get; set; } - public CGameCtnChallengeParameters? ChallengeParameters => Map.ChallengeParameters; + public CGameCtnChallengeParameters ChallengeParameters => Map.ChallengeParameters ?? throw new InvalidOperationException("Map does not have challenge parameters."); public CGameCtnChallenge.PlayMode? Mode => Map.Mode; public string MapUid => Map.MapUid; public Ident MapInfo => Map.MapInfo; @@ -25,4 +27,42 @@ public SessionMap(CGameCtnChallenge map, DateTimeOffset receivedAt, string tmxLi ReceivedAt = receivedAt; TmxLink = tmxLink; } + + internal bool IsAuthorMedal(CGameCtnGhost ghost) + { + switch (Mode) + { + case CGameCtnChallenge.PlayMode.Race: + case CGameCtnChallenge.PlayMode.Puzzle: + return ghost.RaceTime <= ChallengeParameters.AuthorTime; + case CGameCtnChallenge.PlayMode.Platform: + + if (ChallengeParameters.AuthorScore > 0 && ghost.Respawns <= ChallengeParameters.AuthorScore) + { + return true; + } + + if (ghost.Respawns == 0 && ghost.RaceTime <= ChallengeParameters.AuthorTime) + { + return true; + } + + return false; + case CGameCtnChallenge.PlayMode.Stunts: + return ghost.StuntScore >= ChallengeParameters.AuthorScore; + default: + throw new NotSupportedException($"Unsupported gamemode {Mode}."); + } + } + + internal bool IsGoldMedal(CGameCtnGhost ghost) + { + return Mode switch + { + CGameCtnChallenge.PlayMode.Race or CGameCtnChallenge.PlayMode.Puzzle => ghost.RaceTime <= ChallengeParameters.GoldTime, + CGameCtnChallenge.PlayMode.Platform => ghost.Respawns <= ChallengeParameters.GoldTime.GetValueOrDefault().TotalMilliseconds, + CGameCtnChallenge.PlayMode.Stunts => ghost.StuntScore >= ChallengeParameters.GoldTime.GetValueOrDefault().TotalMilliseconds, + _ => throw new NotSupportedException($"Unsupported gamemode {Mode}."), + }; + } } \ No newline at end of file diff --git a/Src/RandomizerTMF.Logic/TypeConverters/Int3Converter.cs b/Src/RandomizerTMF.Logic/TypeConverters/Int3Converter.cs new file mode 100644 index 0000000..abb83d2 --- /dev/null +++ b/Src/RandomizerTMF.Logic/TypeConverters/Int3Converter.cs @@ -0,0 +1,42 @@ +using GBX.NET; +using TmEssentials; +using YamlDotNet.Core; +using YamlDotNet.Core.Events; +using YamlDotNet.Serialization; + +namespace RandomizerTMF.Logic.TypeConverters; + +internal sealed class Int3Converter : IYamlTypeConverter +{ + public bool Accepts(Type type) => type == typeof(Int3) || type == typeof(Int3?); + + public object? ReadYaml(IParser parser, Type type) + { + _ = parser.Consume(); + + var x = int.Parse(parser.Consume().Value); + var y = int.Parse(parser.Consume().Value); + var z = int.Parse(parser.Consume().Value); + + _ = parser.Consume(); + + return new Int3(x, y, z); + } + + public void WriteYaml(IEmitter emitter, object? value, Type type) + { + if (value is null) + { + emitter.Emit(new Scalar("~")); + return; + } + + var val = (Int3)value!; + + emitter.Emit(new SequenceStart(default, default, isImplicit: true, SequenceStyle.Flow)); + emitter.Emit(new Scalar(val.X.ToString())); + emitter.Emit(new Scalar(val.Y.ToString())); + emitter.Emit(new Scalar(val.Z.ToString())); + emitter.Emit(new SequenceEnd()); + } +} diff --git a/Src/RandomizerTMF.Logic/Yaml.cs b/Src/RandomizerTMF.Logic/Yaml.cs new file mode 100644 index 0000000..eadbf74 --- /dev/null +++ b/Src/RandomizerTMF.Logic/Yaml.cs @@ -0,0 +1,43 @@ +using RandomizerTMF.Logic.TypeConverters; +using YamlDotNet.Serialization; + +namespace RandomizerTMF.Logic; + +public static class Yaml +{ + public static IReadOnlyCollection TypeConverters { get; } = new List + { + new DateOnlyConverter(), + new DateTimeOffsetConverter(), + new TimeInt32Converter(), + new Int3Converter() + }; + + public static ISerializer Serializer { get; } = CreateSerializerBuilder().Build(); + public static IDeserializer Deserializer { get; } = CreateDeserializerBuilder().Build(); + + private static SerializerBuilder CreateSerializerBuilder() + { + var builder = new SerializerBuilder(); + + foreach (var typeConverter in TypeConverters) + { + builder = builder.WithTypeConverter(typeConverter); + } + + return builder; + } + + private static DeserializerBuilder CreateDeserializerBuilder() + { + var builder = new DeserializerBuilder() + .IgnoreUnmatchedProperties(); + + foreach (var typeConverter in TypeConverters) + { + builder = builder.WithTypeConverter(typeConverter); + } + + return builder; + } +} diff --git a/Src/RandomizerTMF/App.axaml.cs b/Src/RandomizerTMF/App.axaml.cs index 2352e2b..016b601 100644 --- a/Src/RandomizerTMF/App.axaml.cs +++ b/Src/RandomizerTMF/App.axaml.cs @@ -1,10 +1,11 @@ using Avalonia; using Avalonia.Controls; -using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; -using RandomizerTMF.Logic; +using Microsoft.Extensions.DependencyInjection; +using RandomizerTMF.Logic.Services; using RandomizerTMF.ViewModels; using RandomizerTMF.Views; +using System.Diagnostics; namespace RandomizerTMF { @@ -21,26 +22,35 @@ public override void Initialize() public override void OnFrameworkInitializationCompleted() { - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + if (Program.ServiceProvider is null) { - if (IsValidGameDirectory(RandomizerEngine.Config.GameDirectory)) - { - desktop.MainWindow = new DashboardWindow(); + throw new UnreachableException("Service provider is null"); + } + + if (ApplicationLifetime is Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop) + { + var config = Program.ServiceProvider.GetRequiredService(); + var filePathManager = Program.ServiceProvider.GetRequiredService(); - var viewModel = new DashboardWindowViewModel { Window = desktop.MainWindow }; - desktop.MainWindow.DataContext = viewModel; + Window window; + WindowViewModelBase viewModel; - viewModel.OnInit(); + if (IsValidGameDirectory(filePathManager, config.GameDirectory)) + { + window = Program.ServiceProvider.GetRequiredService(); + viewModel = Program.ServiceProvider.GetRequiredService(); } else { - desktop.MainWindow = new MainWindow(); + window = Program.ServiceProvider.GetRequiredService(); + viewModel = Program.ServiceProvider.GetRequiredService(); + } - var viewModel = new MainWindowViewModel { Window = desktop.MainWindow }; - desktop.MainWindow.DataContext = viewModel; + desktop.MainWindow = window; + viewModel.Window = window; + window.DataContext = viewModel; - viewModel.OnInit(); - } + viewModel.OnInit(); MainWindow = desktop.MainWindow; } @@ -48,9 +58,9 @@ public override void OnFrameworkInitializationCompleted() base.OnFrameworkInitializationCompleted(); } - private bool IsValidGameDirectory(string? gameDirectory) + private bool IsValidGameDirectory(IFilePathManager filePathManager, string? gameDirectory) { - return !string.IsNullOrWhiteSpace(gameDirectory) && RandomizerEngine.UpdateGameDirectory(gameDirectory) is { NadeoIniException: null, TmForeverException: null }; + return !string.IsNullOrWhiteSpace(gameDirectory) && filePathManager.UpdateGameDirectory(gameDirectory) is { NadeoIniException: null, TmForeverException: null }; } } } \ No newline at end of file diff --git a/Src/RandomizerTMF/CompiledRegex.cs b/Src/RandomizerTMF/CompiledRegex.cs new file mode 100644 index 0000000..7d92034 --- /dev/null +++ b/Src/RandomizerTMF/CompiledRegex.cs @@ -0,0 +1,9 @@ +using System.Text.RegularExpressions; + +namespace RandomizerTMF; + +internal partial class CompiledRegex +{ + [GeneratedRegex("[a-z][A-Z]")] + public static partial Regex SentenceCaseRegex(); +} diff --git a/Src/RandomizerTMF/ProcessUtils.cs b/Src/RandomizerTMF/ProcessUtils.cs index 6f95d6b..ec5fff7 100644 --- a/Src/RandomizerTMF/ProcessUtils.cs +++ b/Src/RandomizerTMF/ProcessUtils.cs @@ -2,7 +2,7 @@ namespace RandomizerTMF; -public static class ProcessUtils +internal static class ProcessUtils { public static void OpenUrl(string url) { diff --git a/Src/RandomizerTMF/Program.cs b/Src/RandomizerTMF/Program.cs index bacc85d..9baaeb5 100644 --- a/Src/RandomizerTMF/Program.cs +++ b/Src/RandomizerTMF/Program.cs @@ -1,7 +1,10 @@ using Avalonia; using Avalonia.ReactiveUI; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using RandomizerTMF.Logic; +using RandomizerTMF.ViewModels; +using RandomizerTMF.Views; namespace RandomizerTMF; @@ -9,21 +12,56 @@ internal class Program { internal static string? Version { get; } = typeof(Program).Assembly.GetName().Version?.ToString(3); + internal static ServiceProvider? ServiceProvider { get; private set; } + // Initialization code. Don't use any Avalonia, third-party APIs or any // SynchronizationContext-reliant code before AppMain is called: things aren't initialized // yet and stuff might break. [STAThread] public static void Main(string[] args) { + var services = new ServiceCollection(); + + services.AddRandomizerEngine(); + + services.AddSingleton(); + + services.AddTransient(); + services.AddTransient(); + + services.AddTransient(); + services.AddTransient(); + + services.AddTransient(); + services.AddTransient(); + + services.AddTransient(); + services.AddTransient(); + + services.AddTransient(); + services.AddTransient(); + + services.AddTransient(); + services.AddTransient(); + + services.AddTransient(); + + var provider = services.BuildServiceProvider(); + + var logger = provider.GetRequiredService(); + var updateDetector = provider.GetRequiredService(); + + ServiceProvider = provider; + try { - UpdateDetector.RequestNewUpdateAsync(); + updateDetector.RequestNewUpdateAsync(); BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); } catch (Exception ex) { - RandomizerEngine.Logger.LogError(ex, "Global error"); + logger.LogError(ex, "Global error"); #if DEBUG throw; @@ -31,7 +69,7 @@ public static void Main(string[] args) } finally { - RandomizerEngine.FlushLog(); + // RandomizerEngine.FlushLog(); } } diff --git a/Src/RandomizerTMF/RandomizerTMF.csproj b/Src/RandomizerTMF/RandomizerTMF.csproj index 76a03dc..15101bf 100644 --- a/Src/RandomizerTMF/RandomizerTMF.csproj +++ b/Src/RandomizerTMF/RandomizerTMF.csproj @@ -20,7 +20,7 @@ - 1.0.3 + 1.1.0 true true @@ -56,6 +56,7 @@ + diff --git a/Src/RandomizerTMF/UpdateDetector.cs b/Src/RandomizerTMF/UpdateDetector.cs index 12434e3..0467062 100644 --- a/Src/RandomizerTMF/UpdateDetector.cs +++ b/Src/RandomizerTMF/UpdateDetector.cs @@ -1,22 +1,40 @@ using Microsoft.Extensions.Logging; -using RandomizerTMF.Logic; using RandomizerTMF.Models; using System.Net.Http.Json; namespace RandomizerTMF; -internal static class UpdateDetector +internal interface IUpdateDetector { - public static string? UpdateCheckResult { get; private set; } - public static bool IsNewUpdate { get; private set; } - public static event Action? UpdateChecked; + bool IsNewUpdate { get; } + string? UpdateCheckResult { get; } - public static async void RequestNewUpdateAsync() + event Action? UpdateChecked; + + void RequestNewUpdateAsync(); +} + +internal class UpdateDetector : IUpdateDetector +{ + private readonly HttpClient http; + private readonly ILogger logger; + + public string? UpdateCheckResult { get; private set; } + public bool IsNewUpdate { get; private set; } + public event Action? UpdateChecked; + + public UpdateDetector(HttpClient http, ILogger logger) + { + this.http = http; + this.logger = logger; + } + + public async void RequestNewUpdateAsync() { try { // Handle rate limiting better - var response = await RandomizerEngine.Http.GetAsync("https://api.github.com/repos/bigbang1112/randomizer-tmf/releases"); + var response = await http.GetAsync("https://api.github.com/repos/bigbang1112/randomizer-tmf/releases"); response.EnsureSuccessStatusCode(); @@ -28,8 +46,8 @@ public static async void RequestNewUpdateAsync() return; } - var release = releases[0]; - var version = release.TagName[1..]; + var release = releases.First(r => !r.Prerelease); + var version = release.TagName[1..]; // stips v from vX.X.X if (string.Equals(version, Program.Version)) { @@ -49,7 +67,7 @@ public static async void RequestNewUpdateAsync() } catch (Exception ex) { - RandomizerEngine.Logger.LogError(ex, "Exception when requesting an update."); + logger.LogError(ex, "Exception when requesting an update."); UpdateCheckResult = ex.GetType().Name; } finally diff --git a/Src/RandomizerTMF/ViewLocator.cs b/Src/RandomizerTMF/ViewLocator.cs index 851c190..1e80dcf 100644 --- a/Src/RandomizerTMF/ViewLocator.cs +++ b/Src/RandomizerTMF/ViewLocator.cs @@ -4,7 +4,7 @@ namespace RandomizerTMF; -public class ViewLocator : IDataTemplate +internal class ViewLocator : IDataTemplate { public IControl Build(object data) { diff --git a/Src/RandomizerTMF/ViewModels/AboutWindowViewModel.cs b/Src/RandomizerTMF/ViewModels/AboutWindowViewModel.cs index 8c35130..b5fb741 100644 --- a/Src/RandomizerTMF/ViewModels/AboutWindowViewModel.cs +++ b/Src/RandomizerTMF/ViewModels/AboutWindowViewModel.cs @@ -1,21 +1,24 @@ using ReactiveUI; -using System.Diagnostics; namespace RandomizerTMF.ViewModels; -public class AboutWindowViewModel : WindowWithTopBarViewModelBase +internal class AboutWindowViewModel : WindowWithTopBarViewModelBase { + private readonly IUpdateDetector updateDetector; + public string VersionText => $"version {Program.Version}"; - public string UpdateText => UpdateDetector.UpdateCheckResult ?? "Checking..."; - public bool IsNewUpdate => UpdateDetector.IsNewUpdate; + public string UpdateText => updateDetector.UpdateCheckResult ?? "Checking..."; + public bool IsNewUpdate => updateDetector.IsNewUpdate; - public AboutWindowViewModel() + public AboutWindowViewModel(TopBarViewModel topBarViewModel, IUpdateDetector updateDetector) : base(topBarViewModel) { + this.updateDetector = updateDetector; + TopBarViewModel.Title = "About Randomizer TMF"; TopBarViewModel.MinimizeButtonEnabled = false; - UpdateDetector.UpdateChecked += () => + updateDetector.UpdateChecked += () => { this.RaisePropertyChanged(nameof(UpdateText)); this.RaisePropertyChanged(nameof(IsNewUpdate)); diff --git a/Src/RandomizerTMF/ViewModels/AutosaveDetailsWindowViewModel.cs b/Src/RandomizerTMF/ViewModels/AutosaveDetailsWindowViewModel.cs index 8861ae0..ee7c7ea 100644 --- a/Src/RandomizerTMF/ViewModels/AutosaveDetailsWindowViewModel.cs +++ b/Src/RandomizerTMF/ViewModels/AutosaveDetailsWindowViewModel.cs @@ -1,12 +1,14 @@ using GBX.NET.Engines.Game; -using RandomizerTMF.Logic; +using RandomizerTMF.Logic.Services; using RandomizerTMF.Models; using TmEssentials; namespace RandomizerTMF.ViewModels; -public class AutosaveDetailsWindowViewModel : WindowWithTopBarViewModelBase +internal class AutosaveDetailsWindowViewModel : WindowWithTopBarViewModelBase { + private readonly ITMForever? game; + public AutosaveModel AutosaveModel { get; } public string? AutosaveFilePath { get; } @@ -46,7 +48,7 @@ public class AutosaveDetailsWindowViewModel : WindowWithTopBarViewModelBase /// /// Example view model, should not be normally used. /// - public AutosaveDetailsWindowViewModel() + public AutosaveDetailsWindowViewModel() : base(new TopBarViewModel(null)) { AutosaveModel = new(mapUid: "", new( Time: TimeInt32.Zero, @@ -66,8 +68,13 @@ public AutosaveDetailsWindowViewModel() TopBarViewModel.MinimizeButtonEnabled = false; } - public AutosaveDetailsWindowViewModel(AutosaveModel autosaveModel, string autosaveFilePath) + public AutosaveDetailsWindowViewModel(TopBarViewModel topBarViewModel, + ITMForever game, + AutosaveModel autosaveModel, + string autosaveFilePath) : base(topBarViewModel) { + this.game = game; + AutosaveModel = autosaveModel; AutosaveFilePath = autosaveFilePath; @@ -94,6 +101,6 @@ public void OpenAutosaveIngame() return; } - RandomizerEngine.OpenAutosaveIngame(AutosaveFilePath); + game?.OpenAutosave(AutosaveFilePath); } } diff --git a/Src/RandomizerTMF/ViewModels/ControlModuleWindowViewModel.cs b/Src/RandomizerTMF/ViewModels/ControlModuleWindowViewModel.cs index 0159baa..4914fcf 100644 --- a/Src/RandomizerTMF/ViewModels/ControlModuleWindowViewModel.cs +++ b/Src/RandomizerTMF/ViewModels/ControlModuleWindowViewModel.cs @@ -1,28 +1,35 @@ using Avalonia.Media; using RandomizerTMF.Logic; +using RandomizerTMF.Logic.Services; using RandomizerTMF.Views; using ReactiveUI; namespace RandomizerTMF.ViewModels; -public class ControlModuleWindowViewModel : WindowViewModelBase +internal class ControlModuleWindowViewModel : ModuleWindowViewModelBase { - public string PrimaryButtonText => RandomizerEngine.HasSessionRunning ? "SKIP" : "START"; - public string SecondaryButtonText => RandomizerEngine.HasSessionRunning ? "END SESSION" : "CLOSE"; - public bool PrimaryButtonEnabled => !RandomizerEngine.HasSessionRunning || (RandomizerEngine.HasSessionRunning && CanSkip); - public bool ReloadMapButtonEnabled => RandomizerEngine.HasSessionRunning && CanSkip; // CanSkip is mostly a hack + private readonly IRandomizerEngine engine; + private readonly IRandomizerEvents events; - public IBrush PrimaryButtonBackground => RandomizerEngine.HasSessionRunning ? new SolidColorBrush(new Color(255, 127, 96, 0)) : Brushes.DarkGreen; + public string PrimaryButtonText => engine.HasSessionRunning ? "SKIP" : "START"; + public string SecondaryButtonText => engine.HasSessionRunning ? "END SESSION" : "CLOSE"; + public bool PrimaryButtonEnabled => !engine.HasSessionRunning || (engine.HasSessionRunning && CanSkip); + public bool ReloadMapButtonEnabled => engine.HasSessionRunning && CanSkip; // CanSkip is mostly a hack - public bool CanSkip => RandomizerEngine.SkipTokenSource is not null; + public IBrush PrimaryButtonBackground => engine.HasSessionRunning ? new SolidColorBrush(new Color(255, 127, 96, 0)) : Brushes.DarkGreen; - public ControlModuleWindowViewModel() + public bool CanSkip => engine.CurrentSession?.SkipTokenSource is not null; + + public ControlModuleWindowViewModel(IRandomizerEngine engine, IRandomizerEvents events, IRandomizerConfig config) : base(config) { - RandomizerEngine.MapStarted += RandomizerMapStarted; - RandomizerEngine.MapEnded += RandomizerMapEnded; + this.engine = engine; + this.events = events; + + events.MapStarted += RandomizerMapStarted; + events.MapEnded += RandomizerMapEnded; } - private void RandomizerMapStarted() + private void RandomizerMapStarted(SessionMap map) { this.RaisePropertyChanged(nameof(PrimaryButtonEnabled)); this.RaisePropertyChanged(nameof(ReloadMapButtonEnabled)); @@ -36,13 +43,13 @@ private void RandomizerMapEnded() public async Task PrimaryButtonClick() { - if (RandomizerEngine.HasSessionRunning) + if (engine.CurrentSession is not null) { - await RandomizerEngine.SkipMapAsync(); + await engine.CurrentSession.SkipMapAsync(); return; } - await RandomizerEngine.StartSessionAsync(); + engine.StartSession(); this.RaisePropertyChanged(nameof(PrimaryButtonText)); this.RaisePropertyChanged(nameof(SecondaryButtonText)); @@ -54,7 +61,7 @@ public async Task PrimaryButtonClick() public void ReloadMapButtonClick() { - RandomizerEngine.ReloadMap(); + engine.CurrentSession?.ReloadMap(); } public async Task SecondaryButtonClick() @@ -63,9 +70,9 @@ public async Task SecondaryButtonClick() // If yes, freeze the button - if (RandomizerEngine.HasSessionRunning) + if (engine.HasSessionRunning) { - await RandomizerEngine.EndSessionAsync(); + await engine.EndSessionAsync(); } OpenWindow(); diff --git a/Src/RandomizerTMF/ViewModels/DashboardWindowViewModel.cs b/Src/RandomizerTMF/ViewModels/DashboardWindowViewModel.cs index 314c253..cf115ac 100644 --- a/Src/RandomizerTMF/ViewModels/DashboardWindowViewModel.cs +++ b/Src/RandomizerTMF/ViewModels/DashboardWindowViewModel.cs @@ -1,7 +1,9 @@ using Avalonia.Controls; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using RandomizerTMF.Logic; using RandomizerTMF.Logic.Exceptions; +using RandomizerTMF.Logic.Services; using RandomizerTMF.Models; using RandomizerTMF.Views; using ReactiveUI; @@ -10,14 +12,23 @@ namespace RandomizerTMF.ViewModels; -public class DashboardWindowViewModel : WindowWithTopBarViewModelBase +internal class DashboardWindowViewModel : WindowWithTopBarViewModelBase { private ObservableCollection autosaves = new(); private ObservableCollection sessions = new(); - + private readonly IRandomizerEngine engine; + private readonly IRandomizerConfig config; + private readonly IValidator validator; + private readonly IFilePathManager filePathManager; + private readonly IAutosaveScanner autosaveScanner; + private readonly ITMForever game; + private readonly IUpdateDetector updateDetector; + private readonly IDiscordRichPresence discord; + private readonly ILogger logger; + public RequestRulesControlViewModel RequestRulesControlViewModel { get; set; } - public string? GameDirectory => RandomizerEngine.Config.GameDirectory; + public string? GameDirectory => config.GameDirectory; public ObservableCollection Autosaves { @@ -31,12 +42,33 @@ public ObservableCollection Sessions private set => this.RaiseAndSetIfChanged(ref sessions, value); } - public bool HasAutosavesScanned => RandomizerEngine.HasAutosavesScanned; - public int AutosaveScanCount => RandomizerEngine.AutosaveHeaders.Count; - - public DashboardWindowViewModel() + public bool HasAutosavesScanned => autosaveScanner.HasAutosavesScanned; + public int AutosaveScanCount => autosaveScanner.AutosaveHeaders.Count; + + public DashboardWindowViewModel(TopBarViewModel topBarViewModel, + IRandomizerEngine engine, + IRandomizerConfig config, + IValidator validator, + IFilePathManager filePathManager, + IAutosaveScanner autosaveScanner, + ITMForever game, + IUpdateDetector updateDetector, + IDiscordRichPresence discord, + ILogger logger) : base(topBarViewModel) { - RequestRulesControlViewModel = new(); + this.engine = engine; + this.config = config; + this.validator = validator; + this.filePathManager = filePathManager; + this.autosaveScanner = autosaveScanner; + this.game = game; + this.updateDetector = updateDetector; + this.discord = discord; + this.logger = logger; + + RequestRulesControlViewModel = new(config); + + discord.InDashboard(); } protected internal override void OnInit() @@ -54,7 +86,7 @@ private async void Opened(object? sender, EventArgs e) var anythingChanged = await ScanAutosavesAsync(); - if (anythingChanged) + if (anythingChanged && !config.DisableAutosaveDetailScan) { await UpdateAutosavesWithFullParseAsync(); } @@ -64,7 +96,7 @@ private async void Opened(object? sender, EventArgs e) private async Task ScanSessionsAsync() { - foreach (var dir in Directory.EnumerateDirectories(RandomizerEngine.SessionsDirectoryPath)) + foreach (var dir in Directory.EnumerateDirectories(FilePathManager.SessionsDirectoryPath)) { var sessionYml = Path.Combine(dir, "Session.yml"); @@ -79,12 +111,12 @@ private async Task ScanSessionsAsync() try { var sessionYmlContent = await File.ReadAllTextAsync(sessionYml); - sessionData = RandomizerEngine.YamlDeserializer.Deserialize(sessionYmlContent); + sessionData = Yaml.Deserializer.Deserialize(sessionYmlContent); sessionDataModel = new SessionDataModel(sessionData); } catch (Exception ex) { - RandomizerEngine.Logger.LogError(ex, "Corrupted Session.yml in '{session}'", Path.GetFileName(dir)); + logger.LogError(ex, "Corrupted Session.yml in '{session}'", Path.GetFileName(dir)); continue; } @@ -110,7 +142,7 @@ private async Task ScanAutosavesAsync() { var cts = new CancellationTokenSource(); - var anythingChanged = Task.Run(RandomizerEngine.ScanAutosaves); + var anythingChanged = Task.Run(autosaveScanner.ScanAutosaves); await Task.WhenAny(anythingChanged, Task.Run(async () => { @@ -135,7 +167,7 @@ private async Task UpdateAutosavesWithFullParseAsync() { var cts = new CancellationTokenSource(); - await Task.WhenAny(Task.Run(RandomizerEngine.ScanDetailsFromAutosaves), Task.Run(async () => + await Task.WhenAny(Task.Run(autosaveScanner.ScanDetailsFromAutosaves), Task.Run(async () => { while (true) { @@ -149,14 +181,14 @@ await Task.WhenAny(Task.Run(RandomizerEngine.ScanDetailsFromAutosaves), Task.Run Autosaves = new(GetAutosaveModels()); } - private static IEnumerable GetAutosaveModels() + private IEnumerable GetAutosaveModels() { - return RandomizerEngine.AutosaveDetails.Select(x => new AutosaveModel(x.Key, x.Value)).OrderBy(x => x.Autosave.MapName); + return autosaveScanner.AutosaveDetails.Select(x => new AutosaveModel(x.Key, x.Value)).OrderBy(x => x.Autosave.MapName); } protected override void CloseClick() { - RandomizerEngine.Exit(); + engine.Exit(); } protected override void MinimizeClick() @@ -173,7 +205,7 @@ public void StartModulesClick() { try { - RandomizerEngine.ValidateRules(); + validator.ValidateRules(); } catch (RuleValidationException ex) { @@ -181,20 +213,23 @@ public void StartModulesClick() return; } + discord.Idle(); + discord.SessionState(); + App.Modules = new Window[] { - OpenModule(RandomizerEngine.Config.Modules.Control), - OpenModule(RandomizerEngine.Config.Modules.Status), - OpenModule(RandomizerEngine.Config.Modules.Progress), - OpenModule(RandomizerEngine.Config.Modules.History) + OpenModule(config.Modules.Control), + OpenModule(config.Modules.Status), + OpenModule(config.Modules.Progress), + OpenModule(config.Modules.History) }; Window.Close(); } private static TWindow OpenModule(ModuleConfig config) - where TWindow : Window, new() - where TViewModel : WindowViewModelBase, new() + where TWindow : Window + where TViewModel : WindowViewModelBase { var window = OpenWindow(); @@ -243,8 +278,15 @@ public void SessionDoubleClick(int selectedIndex) } var sessionModel = Sessions[selectedIndex]; + + if (Program.ServiceProvider is null) + { + throw new UnreachableException("ServiceProvider is null"); + } + + var topBarViewModel = Program.ServiceProvider.GetRequiredService(); - OpenDialog(window => new SessionDataViewModel(sessionModel) + OpenDialog(window => new SessionDataViewModel(topBarViewModel, sessionModel) { Window = window }); @@ -259,30 +301,33 @@ public void AutosaveDoubleClick(int selectedIndex) var autosaveModel = Autosaves[selectedIndex]; - if (!RandomizerEngine.AutosaveHeaders.TryGetValue(autosaveModel.MapUid, out AutosaveHeader? autosave)) + if (!autosaveScanner.AutosaveHeaders.TryGetValue(autosaveModel.MapUid, out AutosaveHeader? autosave)) { return; } - OpenDialog(window => new AutosaveDetailsWindowViewModel(autosaveModel, autosave.FilePath) + OpenDialog(window => new AutosaveDetailsWindowViewModel(new TopBarViewModel(updateDetector), game, autosaveModel, autosave.FilePath) { Window = window }); } - public void OpenDownloadedMapsFolderClick() + public bool OpenDownloadedMapsFolderClick() { - if (RandomizerEngine.DownloadedDirectoryPath is not null) + if (!Directory.Exists(filePathManager.DownloadedDirectoryPath)) { - ProcessUtils.OpenDir(RandomizerEngine.DownloadedDirectoryPath + Path.DirectorySeparatorChar); + OpenMessageBox("Directory not found", "Downloaded maps directory has not been yet created."); + + return false; } + + ProcessUtils.OpenDir(filePathManager.DownloadedDirectoryPath + Path.DirectorySeparatorChar); + + return true; } public void OpenSessionsFolderClick() { - if (RandomizerEngine.SessionsDirectoryPath is not null) - { - ProcessUtils.OpenDir(RandomizerEngine.SessionsDirectoryPath + Path.DirectorySeparatorChar); - } + ProcessUtils.OpenDir(FilePathManager.SessionsDirectoryPath + Path.DirectorySeparatorChar); } } diff --git a/Src/RandomizerTMF/ViewModels/HistoryModuleWindowViewModel.cs b/Src/RandomizerTMF/ViewModels/HistoryModuleWindowViewModel.cs index 78e8676ee..e71e847 100644 --- a/Src/RandomizerTMF/ViewModels/HistoryModuleWindowViewModel.cs +++ b/Src/RandomizerTMF/ViewModels/HistoryModuleWindowViewModel.cs @@ -1,13 +1,18 @@ -using RandomizerTMF.Logic; +using Microsoft.Extensions.DependencyInjection; +using RandomizerTMF.Logic.Services; using RandomizerTMF.Models; using RandomizerTMF.Views; using ReactiveUI; using System.Collections.ObjectModel; +using System.Diagnostics; namespace RandomizerTMF.ViewModels; -public class HistoryModuleWindowViewModel : WindowViewModelBase +internal class HistoryModuleWindowViewModel : ModuleWindowViewModelBase { + private readonly IRandomizerEngine engine; + private readonly IRandomizerEvents events; + private ObservableCollection playedMaps = new(); public ObservableCollection PlayedMaps @@ -18,10 +23,13 @@ public ObservableCollection PlayedMaps public bool HasFinishedMaps => PlayedMaps.Count > 0; - public HistoryModuleWindowViewModel() + public HistoryModuleWindowViewModel(IRandomizerEngine engine, IRandomizerEvents events, IRandomizerConfig config) : base(config) { - RandomizerEngine.MedalUpdate += RandomizerPlayedMapUpdate; - RandomizerEngine.MapSkip += RandomizerPlayedMapUpdate; + this.engine = engine; + this.events = events; + + events.MedalUpdate += RandomizerPlayedMapUpdate; + events.MapSkip += RandomizerPlayedMapUpdate; } private void RandomizerPlayedMapUpdate() @@ -32,17 +40,22 @@ private void RandomizerPlayedMapUpdate() private IEnumerable EnumerateCurrentSessionMaps() { - foreach (var map in RandomizerEngine.CurrentSessionAuthorMaps) + if (engine.CurrentSession is null) + { + yield break; + } + + foreach (var map in engine.CurrentSession.AuthorMaps) { yield return new PlayedMapModel(map.Value, EResult.AuthorMedal); } - foreach (var map in RandomizerEngine.CurrentSessionGoldMaps) + foreach (var map in engine.CurrentSession.GoldMaps) { yield return new PlayedMapModel(map.Value, EResult.GoldMedal); } - foreach (var map in RandomizerEngine.CurrentSessionSkippedMaps) + foreach (var map in engine.CurrentSession.SkippedMaps) { yield return new PlayedMapModel(map.Value, EResult.Skipped); } @@ -55,7 +68,14 @@ public void MapDoubleClick(PlayedMapModel? selectedItem) return; } - OpenDialog(window => new SessionMapViewModel(selectedItem) + if (Program.ServiceProvider is null) + { + throw new UnreachableException("Program.ServiceProvider is null"); + } + + var topBarViewModel = Program.ServiceProvider.GetRequiredService(); + + OpenDialog(window => new SessionMapViewModel(topBarViewModel, selectedItem) { Window = window }); diff --git a/Src/RandomizerTMF/ViewModels/MainWindowViewModel.cs b/Src/RandomizerTMF/ViewModels/MainWindowViewModel.cs index 5658f36..6942004 100644 --- a/Src/RandomizerTMF/ViewModels/MainWindowViewModel.cs +++ b/Src/RandomizerTMF/ViewModels/MainWindowViewModel.cs @@ -1,16 +1,20 @@ using Avalonia.Controls; using Avalonia.Media; using RandomizerTMF.Logic; +using RandomizerTMF.Logic.Services; using RandomizerTMF.Views; using ReactiveUI; namespace RandomizerTMF.ViewModels; -public class MainWindowViewModel : WindowWithTopBarViewModelBase +internal class MainWindowViewModel : WindowWithTopBarViewModelBase { private string? gameDirectory; private string? userDirectory; private GameDirInspectResult? nadeoIni; + private readonly IRandomizerEngine engine; + private readonly IRandomizerConfig config; + private readonly IFilePathManager filePathManager; public string? GameDirectory { @@ -65,19 +69,29 @@ public GameDirInspectResult? NadeoIni public bool IsSaveAndProceedEnabled => NadeoIni is not null && NadeoIni.NadeoIniException is null && NadeoIni.TmForeverException is null && NadeoIni.TmUnlimiterException is null or FileNotFoundException; - public MainWindowViewModel() + public MainWindowViewModel(TopBarViewModel topBarViewModel, + IRandomizerEngine engine, + IRandomizerConfig config, + IFilePathManager filePathManager, + IDiscordRichPresence discord) : base(topBarViewModel) { - if (!string.IsNullOrWhiteSpace(RandomizerEngine.Config.GameDirectory)) + this.engine = engine; + this.config = config; + this.filePathManager = filePathManager; + + if (!string.IsNullOrWhiteSpace(config.GameDirectory)) { - GameDirectory = RandomizerEngine.Config.GameDirectory; - NadeoIni = RandomizerEngine.UpdateGameDirectory(RandomizerEngine.Config.GameDirectory); - UserDirectory = RandomizerEngine.UserDataDirectoryPath; + GameDirectory = config.GameDirectory; + NadeoIni = filePathManager.UpdateGameDirectory(config.GameDirectory); + UserDirectory = filePathManager.UserDataDirectoryPath; } + + discord.Configuring(); } protected override void CloseClick() { - RandomizerEngine.Exit(); + engine.Exit(); } protected override void MinimizeClick() @@ -97,14 +111,14 @@ public async Task SelectGameDirectoryClick() GameDirectory = dir; - NadeoIni = RandomizerEngine.UpdateGameDirectory(dir); + NadeoIni = filePathManager.UpdateGameDirectory(dir); - UserDirectory = NadeoIni.NadeoIniException is null ? RandomizerEngine.UserDataDirectoryPath : null; + UserDirectory = NadeoIni.NadeoIniException is null ? filePathManager.UserDataDirectoryPath : null; } public void SaveAndProceedClick() { - RandomizerEngine.SaveConfig(); + config.Save(); SwitchWindowTo(); } diff --git a/Src/RandomizerTMF/ViewModels/MessageWindowViewModel.cs b/Src/RandomizerTMF/ViewModels/MessageWindowViewModel.cs index 87b3ec9..831bf68 100644 --- a/Src/RandomizerTMF/ViewModels/MessageWindowViewModel.cs +++ b/Src/RandomizerTMF/ViewModels/MessageWindowViewModel.cs @@ -2,7 +2,7 @@ namespace RandomizerTMF.ViewModels; -public class MessageWindowViewModel : WindowWithTopBarViewModelBase +internal class MessageWindowViewModel : WindowWithTopBarViewModelBase { private string content = ""; @@ -12,7 +12,7 @@ public string Content set => this.RaiseAndSetIfChanged(ref content, value); } - public MessageWindowViewModel() + public MessageWindowViewModel(TopBarViewModel topBarViewModel) : base(topBarViewModel) { TopBarViewModel.MinimizeButtonEnabled = false; } diff --git a/Src/RandomizerTMF/ViewModels/ModuleWindowViewModelBase.cs b/Src/RandomizerTMF/ViewModels/ModuleWindowViewModelBase.cs new file mode 100644 index 0000000..8376445 --- /dev/null +++ b/Src/RandomizerTMF/ViewModels/ModuleWindowViewModelBase.cs @@ -0,0 +1,26 @@ +using RandomizerTMF.Logic; +using RandomizerTMF.Logic.Services; + +namespace RandomizerTMF.ViewModels; + +internal class ModuleWindowViewModelBase : WindowViewModelBase +{ + private readonly IRandomizerConfig config; + + public ModuleWindowViewModelBase(IRandomizerConfig config) + { + this.config = config; + } + + internal void OnClosing(Func func, int x, int y, double width, double height) + { + var module = func(config.Modules); + + module.X = x; + module.Y = y; + module.Width = Convert.ToInt32(width); + module.Height = Convert.ToInt32(height); + + config.Save(); + } +} diff --git a/Src/RandomizerTMF/ViewModels/ProgressModuleWindowViewModel.cs b/Src/RandomizerTMF/ViewModels/ProgressModuleWindowViewModel.cs index b555000..9629e96 100644 --- a/Src/RandomizerTMF/ViewModels/ProgressModuleWindowViewModel.cs +++ b/Src/RandomizerTMF/ViewModels/ProgressModuleWindowViewModel.cs @@ -1,20 +1,25 @@ using Avalonia.Media; -using RandomizerTMF.Logic; +using RandomizerTMF.Logic.Services; using ReactiveUI; namespace RandomizerTMF.ViewModels; -public class ProgressModuleWindowViewModel : WindowViewModelBase +internal class ProgressModuleWindowViewModel : ModuleWindowViewModelBase { - public int AuthorMedalCount => RandomizerEngine.CurrentSessionAuthorMaps.Count; - public int GoldMedalCount => RandomizerEngine.CurrentSessionGoldMaps.Count; - public int SkipCount => RandomizerEngine.CurrentSessionSkippedMaps.Count; + private readonly IRandomizerEngine engine; + + public int AuthorMedalCount => engine.CurrentSession?.AuthorMaps.Count ?? 0; + public int GoldMedalCount => engine.CurrentSession?.GoldMaps.Count ?? 0; + public int SkipCount => engine.CurrentSession?.SkippedMaps.Count ?? 0; public IBrush SkipColor => SkipCount == 0 ? Brushes.LightGreen : Brushes.Orange; + public string SkipText => SkipCount == 1 ? "SKIP" : "SKIPS"; + + public ProgressModuleWindowViewModel(IRandomizerEngine engine, IRandomizerEvents events, IRandomizerConfig config) : base(config) + { + this.engine = engine; - public ProgressModuleWindowViewModel() - { - RandomizerEngine.MedalUpdate += RandomizerMedalUpdate; - RandomizerEngine.MapSkip += RandomizerMapSkip; + events.MedalUpdate += RandomizerMedalUpdate; + events.MapSkip += RandomizerMapSkip; } private void RandomizerMedalUpdate() @@ -27,5 +32,6 @@ private void RandomizerMapSkip() { this.RaisePropertyChanged(nameof(SkipCount)); this.RaisePropertyChanged(nameof(SkipColor)); + this.RaisePropertyChanged(nameof(SkipText)); } } diff --git a/Src/RandomizerTMF/ViewModels/RequestRulesControlViewModel.cs b/Src/RandomizerTMF/ViewModels/RequestRulesControlViewModel.cs index cd79914..6fc11cc 100644 --- a/Src/RandomizerTMF/ViewModels/RequestRulesControlViewModel.cs +++ b/Src/RandomizerTMF/ViewModels/RequestRulesControlViewModel.cs @@ -1,697 +1,705 @@ using RandomizerTMF.Logic; +using RandomizerTMF.Logic.Services; using ReactiveUI; using TmEssentials; namespace RandomizerTMF.ViewModels; -public class RequestRulesControlViewModel : WindowViewModelBase +internal class RequestRulesControlViewModel : WindowViewModelBase { + private readonly IRandomizerConfig config; + + public RequestRulesControlViewModel(IRandomizerConfig config) + { + this.config = config; + } + public bool IsSiteTMNFChecked { - get => RandomizerEngine.Config.Rules.RequestRules.Site.HasFlag(ESite.TMNF); + get => config.Rules.RequestRules.Site.HasFlag(ESite.TMNF); set { - if (value) RandomizerEngine.Config.Rules.RequestRules.Site |= ESite.TMNF; - else RandomizerEngine.Config.Rules.RequestRules.Site &= ~ESite.TMNF; + if (value) config.Rules.RequestRules.Site |= ESite.TMNF; + else config.Rules.RequestRules.Site &= ~ESite.TMNF; this.RaisePropertyChanged(nameof(IsSiteTMNFChecked)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool IsSiteTMUFChecked { - get => RandomizerEngine.Config.Rules.RequestRules.Site.HasFlag(ESite.TMUF); + get => config.Rules.RequestRules.Site.HasFlag(ESite.TMUF); set { - if (value) RandomizerEngine.Config.Rules.RequestRules.Site |= ESite.TMUF; - else RandomizerEngine.Config.Rules.RequestRules.Site &= ~ESite.TMUF; + if (value) config.Rules.RequestRules.Site |= ESite.TMUF; + else config.Rules.RequestRules.Site &= ~ESite.TMUF; this.RaisePropertyChanged(nameof(IsSiteTMUFChecked)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool IsSiteNationsChecked { - get => RandomizerEngine.Config.Rules.RequestRules.Site.HasFlag(ESite.Nations); + get => config.Rules.RequestRules.Site.HasFlag(ESite.Nations); set { - if (value) RandomizerEngine.Config.Rules.RequestRules.Site |= ESite.Nations; - else RandomizerEngine.Config.Rules.RequestRules.Site &= ~ESite.Nations; + if (value) config.Rules.RequestRules.Site |= ESite.Nations; + else config.Rules.RequestRules.Site &= ~ESite.Nations; this.RaisePropertyChanged(nameof(IsSiteNationsChecked)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool IsSiteSunriseChecked { - get => RandomizerEngine.Config.Rules.RequestRules.Site.HasFlag(ESite.Sunrise); + get => config.Rules.RequestRules.Site.HasFlag(ESite.Sunrise); set { - if (value) RandomizerEngine.Config.Rules.RequestRules.Site |= ESite.Sunrise; - else RandomizerEngine.Config.Rules.RequestRules.Site &= ~ESite.Sunrise; + if (value) config.Rules.RequestRules.Site |= ESite.Sunrise; + else config.Rules.RequestRules.Site &= ~ESite.Sunrise; this.RaisePropertyChanged(nameof(IsSiteSunriseChecked)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool IsSiteOriginalChecked { - get => RandomizerEngine.Config.Rules.RequestRules.Site.HasFlag(ESite.Original); + get => config.Rules.RequestRules.Site.HasFlag(ESite.Original); set { - if (value) RandomizerEngine.Config.Rules.RequestRules.Site |= ESite.Original; - else RandomizerEngine.Config.Rules.RequestRules.Site &= ~ESite.Original; + if (value) config.Rules.RequestRules.Site |= ESite.Original; + else config.Rules.RequestRules.Site &= ~ESite.Original; this.RaisePropertyChanged(nameof(IsSiteOriginalChecked)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool IsPrimaryTypeRaceChecked { - get => RandomizerEngine.Config.Rules.RequestRules.PrimaryType is EPrimaryType.Race; + get => config.Rules.RequestRules.PrimaryType is EPrimaryType.Race; set { - if (value) RandomizerEngine.Config.Rules.RequestRules.PrimaryType = EPrimaryType.Race; - else RandomizerEngine.Config.Rules.RequestRules.PrimaryType = null; + if (value) config.Rules.RequestRules.PrimaryType = EPrimaryType.Race; + else config.Rules.RequestRules.PrimaryType = null; this.RaisePropertyChanged(nameof(IsPrimaryTypeRaceChecked)); this.RaisePropertyChanged(nameof(IsPrimaryTypePlatformChecked)); this.RaisePropertyChanged(nameof(IsPrimaryTypeStuntsChecked)); this.RaisePropertyChanged(nameof(IsPrimaryTypePuzzleChecked)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool IsPrimaryTypePlatformChecked { - get => RandomizerEngine.Config.Rules.RequestRules.PrimaryType is EPrimaryType.Platform; + get => config.Rules.RequestRules.PrimaryType is EPrimaryType.Platform; set { - if (value) RandomizerEngine.Config.Rules.RequestRules.PrimaryType = EPrimaryType.Platform; - else RandomizerEngine.Config.Rules.RequestRules.PrimaryType = null; + if (value) config.Rules.RequestRules.PrimaryType = EPrimaryType.Platform; + else config.Rules.RequestRules.PrimaryType = null; this.RaisePropertyChanged(nameof(IsPrimaryTypeRaceChecked)); this.RaisePropertyChanged(nameof(IsPrimaryTypePlatformChecked)); this.RaisePropertyChanged(nameof(IsPrimaryTypeStuntsChecked)); this.RaisePropertyChanged(nameof(IsPrimaryTypePuzzleChecked)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool IsPrimaryTypeStuntsChecked { - get => RandomizerEngine.Config.Rules.RequestRules.PrimaryType is EPrimaryType.Stunts; + get => config.Rules.RequestRules.PrimaryType is EPrimaryType.Stunts; set { - if (value) RandomizerEngine.Config.Rules.RequestRules.PrimaryType = EPrimaryType.Stunts; - else RandomizerEngine.Config.Rules.RequestRules.PrimaryType = null; + if (value) config.Rules.RequestRules.PrimaryType = EPrimaryType.Stunts; + else config.Rules.RequestRules.PrimaryType = null; this.RaisePropertyChanged(nameof(IsPrimaryTypeRaceChecked)); this.RaisePropertyChanged(nameof(IsPrimaryTypePlatformChecked)); this.RaisePropertyChanged(nameof(IsPrimaryTypeStuntsChecked)); this.RaisePropertyChanged(nameof(IsPrimaryTypePuzzleChecked)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool IsPrimaryTypePuzzleChecked { - get => RandomizerEngine.Config.Rules.RequestRules.PrimaryType is EPrimaryType.Puzzle; + get => config.Rules.RequestRules.PrimaryType is EPrimaryType.Puzzle; set { - if (value) RandomizerEngine.Config.Rules.RequestRules.PrimaryType = EPrimaryType.Puzzle; - else RandomizerEngine.Config.Rules.RequestRules.PrimaryType = null; + if (value) config.Rules.RequestRules.PrimaryType = EPrimaryType.Puzzle; + else config.Rules.RequestRules.PrimaryType = null; this.RaisePropertyChanged(nameof(IsPrimaryTypeRaceChecked)); this.RaisePropertyChanged(nameof(IsPrimaryTypePlatformChecked)); this.RaisePropertyChanged(nameof(IsPrimaryTypeStuntsChecked)); this.RaisePropertyChanged(nameof(IsPrimaryTypePuzzleChecked)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool IsEnvironmentSnowChecked { - get => RandomizerEngine.Config.Rules.RequestRules.Environment?.Contains(EEnvironment.Snow) == true; + get => config.Rules.RequestRules.Environment?.Contains(EEnvironment.Snow) == true; set { - RandomizerEngine.Config.Rules.RequestRules.Environment ??= new(); + config.Rules.RequestRules.Environment ??= new(); - if (value) RandomizerEngine.Config.Rules.RequestRules.Environment.Add(EEnvironment.Snow); - else RandomizerEngine.Config.Rules.RequestRules.Environment.Remove(EEnvironment.Snow); + if (value) config.Rules.RequestRules.Environment.Add(EEnvironment.Snow); + else config.Rules.RequestRules.Environment.Remove(EEnvironment.Snow); - if (RandomizerEngine.Config.Rules.RequestRules.Environment.Count == 0) + if (config.Rules.RequestRules.Environment.Count == 0) { - RandomizerEngine.Config.Rules.RequestRules.Environment = null; + config.Rules.RequestRules.Environment = null; } this.RaisePropertyChanged(nameof(IsEnvironmentSnowChecked)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool IsEnvironmentDesertChecked { - get => RandomizerEngine.Config.Rules.RequestRules.Environment?.Contains(EEnvironment.Desert) == true; + get => config.Rules.RequestRules.Environment?.Contains(EEnvironment.Desert) == true; set { - RandomizerEngine.Config.Rules.RequestRules.Environment ??= new(); + config.Rules.RequestRules.Environment ??= new(); - if (value) RandomizerEngine.Config.Rules.RequestRules.Environment.Add(EEnvironment.Desert); - else RandomizerEngine.Config.Rules.RequestRules.Environment.Remove(EEnvironment.Desert); + if (value) config.Rules.RequestRules.Environment.Add(EEnvironment.Desert); + else config.Rules.RequestRules.Environment.Remove(EEnvironment.Desert); - if (RandomizerEngine.Config.Rules.RequestRules.Environment.Count == 0) + if (config.Rules.RequestRules.Environment.Count == 0) { - RandomizerEngine.Config.Rules.RequestRules.Environment = null; + config.Rules.RequestRules.Environment = null; } this.RaisePropertyChanged(nameof(IsEnvironmentDesertChecked)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool IsEnvironmentRallyChecked { - get => RandomizerEngine.Config.Rules.RequestRules.Environment?.Contains(EEnvironment.Rally) == true; + get => config.Rules.RequestRules.Environment?.Contains(EEnvironment.Rally) == true; set { - RandomizerEngine.Config.Rules.RequestRules.Environment ??= new(); + config.Rules.RequestRules.Environment ??= new(); - if (value) RandomizerEngine.Config.Rules.RequestRules.Environment.Add(EEnvironment.Rally); - else RandomizerEngine.Config.Rules.RequestRules.Environment.Remove(EEnvironment.Rally); + if (value) config.Rules.RequestRules.Environment.Add(EEnvironment.Rally); + else config.Rules.RequestRules.Environment.Remove(EEnvironment.Rally); - if (RandomizerEngine.Config.Rules.RequestRules.Environment.Count == 0) + if (config.Rules.RequestRules.Environment.Count == 0) { - RandomizerEngine.Config.Rules.RequestRules.Environment = null; + config.Rules.RequestRules.Environment = null; } this.RaisePropertyChanged(nameof(IsEnvironmentRallyChecked)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool IsEnvironmentIslandChecked { - get => RandomizerEngine.Config.Rules.RequestRules.Environment?.Contains(EEnvironment.Island) == true; + get => config.Rules.RequestRules.Environment?.Contains(EEnvironment.Island) == true; set { - RandomizerEngine.Config.Rules.RequestRules.Environment ??= new(); + config.Rules.RequestRules.Environment ??= new(); - if (value) RandomizerEngine.Config.Rules.RequestRules.Environment.Add(EEnvironment.Island); - else RandomizerEngine.Config.Rules.RequestRules.Environment.Remove(EEnvironment.Island); + if (value) config.Rules.RequestRules.Environment.Add(EEnvironment.Island); + else config.Rules.RequestRules.Environment.Remove(EEnvironment.Island); - if (RandomizerEngine.Config.Rules.RequestRules.Environment.Count == 0) + if (config.Rules.RequestRules.Environment.Count == 0) { - RandomizerEngine.Config.Rules.RequestRules.Environment = null; + config.Rules.RequestRules.Environment = null; } this.RaisePropertyChanged(nameof(IsEnvironmentIslandChecked)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool IsEnvironmentCoastChecked { - get => RandomizerEngine.Config.Rules.RequestRules.Environment?.Contains(EEnvironment.Coast) == true; + get => config.Rules.RequestRules.Environment?.Contains(EEnvironment.Coast) == true; set { - RandomizerEngine.Config.Rules.RequestRules.Environment ??= new(); + config.Rules.RequestRules.Environment ??= new(); - if (value) RandomizerEngine.Config.Rules.RequestRules.Environment.Add(EEnvironment.Coast); - else RandomizerEngine.Config.Rules.RequestRules.Environment.Remove(EEnvironment.Coast); + if (value) config.Rules.RequestRules.Environment.Add(EEnvironment.Coast); + else config.Rules.RequestRules.Environment.Remove(EEnvironment.Coast); - if (RandomizerEngine.Config.Rules.RequestRules.Environment.Count == 0) + if (config.Rules.RequestRules.Environment.Count == 0) { - RandomizerEngine.Config.Rules.RequestRules.Environment = null; + config.Rules.RequestRules.Environment = null; } this.RaisePropertyChanged(nameof(IsEnvironmentCoastChecked)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool IsEnvironmentBayChecked { - get => RandomizerEngine.Config.Rules.RequestRules.Environment?.Contains(EEnvironment.Bay) == true; + get => config.Rules.RequestRules.Environment?.Contains(EEnvironment.Bay) == true; set { - RandomizerEngine.Config.Rules.RequestRules.Environment ??= new(); + config.Rules.RequestRules.Environment ??= new(); - if (value) RandomizerEngine.Config.Rules.RequestRules.Environment.Add(EEnvironment.Bay); - else RandomizerEngine.Config.Rules.RequestRules.Environment.Remove(EEnvironment.Bay); + if (value) config.Rules.RequestRules.Environment.Add(EEnvironment.Bay); + else config.Rules.RequestRules.Environment.Remove(EEnvironment.Bay); - if (RandomizerEngine.Config.Rules.RequestRules.Environment.Count == 0) + if (config.Rules.RequestRules.Environment.Count == 0) { - RandomizerEngine.Config.Rules.RequestRules.Environment = null; + config.Rules.RequestRules.Environment = null; } this.RaisePropertyChanged(nameof(IsEnvironmentBayChecked)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool IsEnvironmentStadiumChecked { - get => RandomizerEngine.Config.Rules.RequestRules.Environment?.Contains(EEnvironment.Stadium) == true; + get => config.Rules.RequestRules.Environment?.Contains(EEnvironment.Stadium) == true; set { - RandomizerEngine.Config.Rules.RequestRules.Environment ??= new(); + config.Rules.RequestRules.Environment ??= new(); - if (value) RandomizerEngine.Config.Rules.RequestRules.Environment.Add(EEnvironment.Stadium); - else RandomizerEngine.Config.Rules.RequestRules.Environment.Remove(EEnvironment.Stadium); + if (value) config.Rules.RequestRules.Environment.Add(EEnvironment.Stadium); + else config.Rules.RequestRules.Environment.Remove(EEnvironment.Stadium); - if (RandomizerEngine.Config.Rules.RequestRules.Environment.Count == 0) + if (config.Rules.RequestRules.Environment.Count == 0) { - RandomizerEngine.Config.Rules.RequestRules.Environment = null; + config.Rules.RequestRules.Environment = null; } this.RaisePropertyChanged(nameof(IsEnvironmentStadiumChecked)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool IsVehicleSnowChecked { - get => RandomizerEngine.Config.Rules.RequestRules.Vehicle?.Contains(EEnvironment.Snow) == true; + get => config.Rules.RequestRules.Vehicle?.Contains(EEnvironment.Snow) == true; set { - RandomizerEngine.Config.Rules.RequestRules.Vehicle ??= new(); + config.Rules.RequestRules.Vehicle ??= new(); - if (value) RandomizerEngine.Config.Rules.RequestRules.Vehicle.Add(EEnvironment.Snow); - else RandomizerEngine.Config.Rules.RequestRules.Vehicle.Remove(EEnvironment.Snow); + if (value) config.Rules.RequestRules.Vehicle.Add(EEnvironment.Snow); + else config.Rules.RequestRules.Vehicle.Remove(EEnvironment.Snow); - if (RandomizerEngine.Config.Rules.RequestRules.Vehicle.Count == 0) + if (config.Rules.RequestRules.Vehicle.Count == 0) { - RandomizerEngine.Config.Rules.RequestRules.Vehicle = null; + config.Rules.RequestRules.Vehicle = null; } this.RaisePropertyChanged(nameof(IsVehicleSnowChecked)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool IsVehicleDesertChecked { - get => RandomizerEngine.Config.Rules.RequestRules.Vehicle?.Contains(EEnvironment.Desert) == true; + get => config.Rules.RequestRules.Vehicle?.Contains(EEnvironment.Desert) == true; set { - RandomizerEngine.Config.Rules.RequestRules.Vehicle ??= new(); + config.Rules.RequestRules.Vehicle ??= new(); - if (value) RandomizerEngine.Config.Rules.RequestRules.Vehicle.Add(EEnvironment.Desert); - else RandomizerEngine.Config.Rules.RequestRules.Vehicle.Remove(EEnvironment.Desert); + if (value) config.Rules.RequestRules.Vehicle.Add(EEnvironment.Desert); + else config.Rules.RequestRules.Vehicle.Remove(EEnvironment.Desert); - if (RandomizerEngine.Config.Rules.RequestRules.Vehicle.Count == 0) + if (config.Rules.RequestRules.Vehicle.Count == 0) { - RandomizerEngine.Config.Rules.RequestRules.Vehicle = null; + config.Rules.RequestRules.Vehicle = null; } this.RaisePropertyChanged(nameof(IsVehicleDesertChecked)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool IsVehicleRallyChecked { - get => RandomizerEngine.Config.Rules.RequestRules.Vehicle?.Contains(EEnvironment.Rally) == true; + get => config.Rules.RequestRules.Vehicle?.Contains(EEnvironment.Rally) == true; set { - RandomizerEngine.Config.Rules.RequestRules.Vehicle ??= new(); + config.Rules.RequestRules.Vehicle ??= new(); - if (value) RandomizerEngine.Config.Rules.RequestRules.Vehicle.Add(EEnvironment.Rally); - else RandomizerEngine.Config.Rules.RequestRules.Vehicle.Remove(EEnvironment.Rally); + if (value) config.Rules.RequestRules.Vehicle.Add(EEnvironment.Rally); + else config.Rules.RequestRules.Vehicle.Remove(EEnvironment.Rally); - if (RandomizerEngine.Config.Rules.RequestRules.Vehicle.Count == 0) + if (config.Rules.RequestRules.Vehicle.Count == 0) { - RandomizerEngine.Config.Rules.RequestRules.Vehicle = null; + config.Rules.RequestRules.Vehicle = null; } this.RaisePropertyChanged(nameof(IsVehicleRallyChecked)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool IsVehicleIslandChecked { - get => RandomizerEngine.Config.Rules.RequestRules.Vehicle?.Contains(EEnvironment.Island) == true; + get => config.Rules.RequestRules.Vehicle?.Contains(EEnvironment.Island) == true; set { - RandomizerEngine.Config.Rules.RequestRules.Vehicle ??= new(); + config.Rules.RequestRules.Vehicle ??= new(); - if (value) RandomizerEngine.Config.Rules.RequestRules.Vehicle.Add(EEnvironment.Island); - else RandomizerEngine.Config.Rules.RequestRules.Vehicle.Remove(EEnvironment.Island); + if (value) config.Rules.RequestRules.Vehicle.Add(EEnvironment.Island); + else config.Rules.RequestRules.Vehicle.Remove(EEnvironment.Island); - if (RandomizerEngine.Config.Rules.RequestRules.Vehicle.Count == 0) + if (config.Rules.RequestRules.Vehicle.Count == 0) { - RandomizerEngine.Config.Rules.RequestRules.Vehicle = null; + config.Rules.RequestRules.Vehicle = null; } this.RaisePropertyChanged(nameof(IsVehicleIslandChecked)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool IsVehicleCoastChecked { - get => RandomizerEngine.Config.Rules.RequestRules.Vehicle?.Contains(EEnvironment.Coast) == true; + get => config.Rules.RequestRules.Vehicle?.Contains(EEnvironment.Coast) == true; set { - RandomizerEngine.Config.Rules.RequestRules.Vehicle ??= new(); + config.Rules.RequestRules.Vehicle ??= new(); - if (value) RandomizerEngine.Config.Rules.RequestRules.Vehicle.Add(EEnvironment.Coast); - else RandomizerEngine.Config.Rules.RequestRules.Vehicle.Remove(EEnvironment.Coast); + if (value) config.Rules.RequestRules.Vehicle.Add(EEnvironment.Coast); + else config.Rules.RequestRules.Vehicle.Remove(EEnvironment.Coast); - if (RandomizerEngine.Config.Rules.RequestRules.Vehicle.Count == 0) + if (config.Rules.RequestRules.Vehicle.Count == 0) { - RandomizerEngine.Config.Rules.RequestRules.Vehicle = null; + config.Rules.RequestRules.Vehicle = null; } this.RaisePropertyChanged(nameof(IsVehicleCoastChecked)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool IsVehicleBayChecked { - get => RandomizerEngine.Config.Rules.RequestRules.Vehicle?.Contains(EEnvironment.Bay) == true; + get => config.Rules.RequestRules.Vehicle?.Contains(EEnvironment.Bay) == true; set { - RandomizerEngine.Config.Rules.RequestRules.Vehicle ??= new(); + config.Rules.RequestRules.Vehicle ??= new(); - if (value) RandomizerEngine.Config.Rules.RequestRules.Vehicle.Add(EEnvironment.Bay); - else RandomizerEngine.Config.Rules.RequestRules.Vehicle.Remove(EEnvironment.Bay); + if (value) config.Rules.RequestRules.Vehicle.Add(EEnvironment.Bay); + else config.Rules.RequestRules.Vehicle.Remove(EEnvironment.Bay); - if (RandomizerEngine.Config.Rules.RequestRules.Vehicle.Count == 0) + if (config.Rules.RequestRules.Vehicle.Count == 0) { - RandomizerEngine.Config.Rules.RequestRules.Vehicle = null; + config.Rules.RequestRules.Vehicle = null; } this.RaisePropertyChanged(nameof(IsVehicleBayChecked)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool IsVehicleStadiumChecked { - get => RandomizerEngine.Config.Rules.RequestRules.Vehicle?.Contains(EEnvironment.Stadium) == true; + get => config.Rules.RequestRules.Vehicle?.Contains(EEnvironment.Stadium) == true; set { - RandomizerEngine.Config.Rules.RequestRules.Vehicle ??= new(); + config.Rules.RequestRules.Vehicle ??= new(); - if (value) RandomizerEngine.Config.Rules.RequestRules.Vehicle.Add(EEnvironment.Stadium); - else RandomizerEngine.Config.Rules.RequestRules.Vehicle.Remove(EEnvironment.Stadium); + if (value) config.Rules.RequestRules.Vehicle.Add(EEnvironment.Stadium); + else config.Rules.RequestRules.Vehicle.Remove(EEnvironment.Stadium); - if (RandomizerEngine.Config.Rules.RequestRules.Vehicle.Count == 0) + if (config.Rules.RequestRules.Vehicle.Count == 0) { - RandomizerEngine.Config.Rules.RequestRules.Vehicle = null; + config.Rules.RequestRules.Vehicle = null; } this.RaisePropertyChanged(nameof(IsVehicleStadiumChecked)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool IsDifficultyBeginnerChecked { - get => RandomizerEngine.Config.Rules.RequestRules.Difficulty?.Contains(EDifficulty.Beginner) == true; + get => config.Rules.RequestRules.Difficulty?.Contains(EDifficulty.Beginner) == true; set { - RandomizerEngine.Config.Rules.RequestRules.Difficulty ??= new(); + config.Rules.RequestRules.Difficulty ??= new(); - if (value) RandomizerEngine.Config.Rules.RequestRules.Difficulty.Add(EDifficulty.Beginner); - else RandomizerEngine.Config.Rules.RequestRules.Difficulty.Remove(EDifficulty.Beginner); + if (value) config.Rules.RequestRules.Difficulty.Add(EDifficulty.Beginner); + else config.Rules.RequestRules.Difficulty.Remove(EDifficulty.Beginner); - if (RandomizerEngine.Config.Rules.RequestRules.Difficulty.Count == 0) + if (config.Rules.RequestRules.Difficulty.Count == 0) { - RandomizerEngine.Config.Rules.RequestRules.Difficulty = null; + config.Rules.RequestRules.Difficulty = null; } this.RaisePropertyChanged(nameof(IsDifficultyBeginnerChecked)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool IsDifficultyIntermediateChecked { - get => RandomizerEngine.Config.Rules.RequestRules.Difficulty?.Contains(EDifficulty.Intermediate) == true; + get => config.Rules.RequestRules.Difficulty?.Contains(EDifficulty.Intermediate) == true; set { - RandomizerEngine.Config.Rules.RequestRules.Difficulty ??= new(); + config.Rules.RequestRules.Difficulty ??= new(); - if (value) RandomizerEngine.Config.Rules.RequestRules.Difficulty.Add(EDifficulty.Intermediate); - else RandomizerEngine.Config.Rules.RequestRules.Difficulty.Remove(EDifficulty.Intermediate); + if (value) config.Rules.RequestRules.Difficulty.Add(EDifficulty.Intermediate); + else config.Rules.RequestRules.Difficulty.Remove(EDifficulty.Intermediate); - if (RandomizerEngine.Config.Rules.RequestRules.Difficulty.Count == 0) + if (config.Rules.RequestRules.Difficulty.Count == 0) { - RandomizerEngine.Config.Rules.RequestRules.Difficulty = null; + config.Rules.RequestRules.Difficulty = null; } this.RaisePropertyChanged(nameof(IsDifficultyIntermediateChecked)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool IsDifficultyExpertChecked { - get => RandomizerEngine.Config.Rules.RequestRules.Difficulty?.Contains(EDifficulty.Expert) == true; + get => config.Rules.RequestRules.Difficulty?.Contains(EDifficulty.Expert) == true; set { - RandomizerEngine.Config.Rules.RequestRules.Difficulty ??= new(); + config.Rules.RequestRules.Difficulty ??= new(); - if (value) RandomizerEngine.Config.Rules.RequestRules.Difficulty.Add(EDifficulty.Expert); - else RandomizerEngine.Config.Rules.RequestRules.Difficulty.Remove(EDifficulty.Expert); + if (value) config.Rules.RequestRules.Difficulty.Add(EDifficulty.Expert); + else config.Rules.RequestRules.Difficulty.Remove(EDifficulty.Expert); - if (RandomizerEngine.Config.Rules.RequestRules.Difficulty.Count == 0) + if (config.Rules.RequestRules.Difficulty.Count == 0) { - RandomizerEngine.Config.Rules.RequestRules.Difficulty = null; + config.Rules.RequestRules.Difficulty = null; } this.RaisePropertyChanged(nameof(IsDifficultyExpertChecked)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool IsDifficultyLunaticChecked { - get => RandomizerEngine.Config.Rules.RequestRules.Difficulty?.Contains(EDifficulty.Lunatic) == true; + get => config.Rules.RequestRules.Difficulty?.Contains(EDifficulty.Lunatic) == true; set { - RandomizerEngine.Config.Rules.RequestRules.Difficulty ??= new(); + config.Rules.RequestRules.Difficulty ??= new(); - if (value) RandomizerEngine.Config.Rules.RequestRules.Difficulty.Add(EDifficulty.Lunatic); - else RandomizerEngine.Config.Rules.RequestRules.Difficulty.Remove(EDifficulty.Lunatic); + if (value) config.Rules.RequestRules.Difficulty.Add(EDifficulty.Lunatic); + else config.Rules.RequestRules.Difficulty.Remove(EDifficulty.Lunatic); - if (RandomizerEngine.Config.Rules.RequestRules.Difficulty.Count == 0) + if (config.Rules.RequestRules.Difficulty.Count == 0) { - RandomizerEngine.Config.Rules.RequestRules.Difficulty = null; + config.Rules.RequestRules.Difficulty = null; } this.RaisePropertyChanged(nameof(IsDifficultyLunaticChecked)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool IsRouteSingleChecked { - get => RandomizerEngine.Config.Rules.RequestRules.Routes?.Contains(ERoutes.Single) == true; + get => config.Rules.RequestRules.Routes?.Contains(ERoutes.Single) == true; set { - RandomizerEngine.Config.Rules.RequestRules.Routes ??= new(); + config.Rules.RequestRules.Routes ??= new(); - if (value) RandomizerEngine.Config.Rules.RequestRules.Routes.Add(ERoutes.Single); - else RandomizerEngine.Config.Rules.RequestRules.Routes.Remove(ERoutes.Single); + if (value) config.Rules.RequestRules.Routes.Add(ERoutes.Single); + else config.Rules.RequestRules.Routes.Remove(ERoutes.Single); - if (RandomizerEngine.Config.Rules.RequestRules.Routes.Count == 0) + if (config.Rules.RequestRules.Routes.Count == 0) { - RandomizerEngine.Config.Rules.RequestRules.Routes = null; + config.Rules.RequestRules.Routes = null; } this.RaisePropertyChanged(nameof(IsRouteSingleChecked)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool IsRouteMultiChecked { - get => RandomizerEngine.Config.Rules.RequestRules.Routes?.Contains(ERoutes.Multi) == true; + get => config.Rules.RequestRules.Routes?.Contains(ERoutes.Multi) == true; set { - RandomizerEngine.Config.Rules.RequestRules.Routes ??= new(); + config.Rules.RequestRules.Routes ??= new(); - if (value) RandomizerEngine.Config.Rules.RequestRules.Routes.Add(ERoutes.Multi); - else RandomizerEngine.Config.Rules.RequestRules.Routes.Remove(ERoutes.Multi); + if (value) config.Rules.RequestRules.Routes.Add(ERoutes.Multi); + else config.Rules.RequestRules.Routes.Remove(ERoutes.Multi); - if (RandomizerEngine.Config.Rules.RequestRules.Routes.Count == 0) + if (config.Rules.RequestRules.Routes.Count == 0) { - RandomizerEngine.Config.Rules.RequestRules.Routes = null; + config.Rules.RequestRules.Routes = null; } this.RaisePropertyChanged(nameof(IsRouteMultiChecked)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool IsRouteSymmetricChecked { - get => RandomizerEngine.Config.Rules.RequestRules.Routes?.Contains(ERoutes.Symmetric) == true; + get => config.Rules.RequestRules.Routes?.Contains(ERoutes.Symmetric) == true; set { - RandomizerEngine.Config.Rules.RequestRules.Routes ??= new(); + config.Rules.RequestRules.Routes ??= new(); - if (value) RandomizerEngine.Config.Rules.RequestRules.Routes.Add(ERoutes.Symmetric); - else RandomizerEngine.Config.Rules.RequestRules.Routes.Remove(ERoutes.Symmetric); + if (value) config.Rules.RequestRules.Routes.Add(ERoutes.Symmetric); + else config.Rules.RequestRules.Routes.Remove(ERoutes.Symmetric); - if (RandomizerEngine.Config.Rules.RequestRules.Routes.Count == 0) + if (config.Rules.RequestRules.Routes.Count == 0) { - RandomizerEngine.Config.Rules.RequestRules.Routes = null; + config.Rules.RequestRules.Routes = null; } this.RaisePropertyChanged(nameof(IsRouteSymmetricChecked)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool IsMoodSunriseChecked { - get => RandomizerEngine.Config.Rules.RequestRules.Mood?.Contains(EMood.Sunrise) == true; + get => config.Rules.RequestRules.Mood?.Contains(EMood.Sunrise) == true; set { - RandomizerEngine.Config.Rules.RequestRules.Mood ??= new(); + config.Rules.RequestRules.Mood ??= new(); - if (value) RandomizerEngine.Config.Rules.RequestRules.Mood.Add(EMood.Sunrise); - else RandomizerEngine.Config.Rules.RequestRules.Mood.Remove(EMood.Sunrise); + if (value) config.Rules.RequestRules.Mood.Add(EMood.Sunrise); + else config.Rules.RequestRules.Mood.Remove(EMood.Sunrise); - if (RandomizerEngine.Config.Rules.RequestRules.Mood.Count == 0) + if (config.Rules.RequestRules.Mood.Count == 0) { - RandomizerEngine.Config.Rules.RequestRules.Mood = null; + config.Rules.RequestRules.Mood = null; } this.RaisePropertyChanged(nameof(IsMoodSunriseChecked)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool IsMoodDayChecked { - get => RandomizerEngine.Config.Rules.RequestRules.Mood?.Contains(EMood.Day) == true; + get => config.Rules.RequestRules.Mood?.Contains(EMood.Day) == true; set { - RandomizerEngine.Config.Rules.RequestRules.Mood ??= new(); + config.Rules.RequestRules.Mood ??= new(); - if (value) RandomizerEngine.Config.Rules.RequestRules.Mood.Add(EMood.Day); - else RandomizerEngine.Config.Rules.RequestRules.Mood.Remove(EMood.Day); + if (value) config.Rules.RequestRules.Mood.Add(EMood.Day); + else config.Rules.RequestRules.Mood.Remove(EMood.Day); - if (RandomizerEngine.Config.Rules.RequestRules.Mood.Count == 0) + if (config.Rules.RequestRules.Mood.Count == 0) { - RandomizerEngine.Config.Rules.RequestRules.Mood = null; + config.Rules.RequestRules.Mood = null; } this.RaisePropertyChanged(nameof(IsMoodDayChecked)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool IsMoodSunsetChecked { - get => RandomizerEngine.Config.Rules.RequestRules.Mood?.Contains(EMood.Sunset) == true; + get => config.Rules.RequestRules.Mood?.Contains(EMood.Sunset) == true; set { - RandomizerEngine.Config.Rules.RequestRules.Mood ??= new(); + config.Rules.RequestRules.Mood ??= new(); - if (value) RandomizerEngine.Config.Rules.RequestRules.Mood.Add(EMood.Sunset); - else RandomizerEngine.Config.Rules.RequestRules.Mood.Remove(EMood.Sunset); + if (value) config.Rules.RequestRules.Mood.Add(EMood.Sunset); + else config.Rules.RequestRules.Mood.Remove(EMood.Sunset); - if (RandomizerEngine.Config.Rules.RequestRules.Mood.Count == 0) + if (config.Rules.RequestRules.Mood.Count == 0) { - RandomizerEngine.Config.Rules.RequestRules.Mood = null; + config.Rules.RequestRules.Mood = null; } this.RaisePropertyChanged(nameof(IsMoodSunsetChecked)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool IsMoodNightChecked { - get => RandomizerEngine.Config.Rules.RequestRules.Mood?.Contains(EMood.Night) == true; + get => config.Rules.RequestRules.Mood?.Contains(EMood.Night) == true; set { - RandomizerEngine.Config.Rules.RequestRules.Mood ??= new(); + config.Rules.RequestRules.Mood ??= new(); - if (value) RandomizerEngine.Config.Rules.RequestRules.Mood.Add(EMood.Night); - else RandomizerEngine.Config.Rules.RequestRules.Mood.Remove(EMood.Night); + if (value) config.Rules.RequestRules.Mood.Add(EMood.Night); + else config.Rules.RequestRules.Mood.Remove(EMood.Night); - if (RandomizerEngine.Config.Rules.RequestRules.Mood.Count == 0) + if (config.Rules.RequestRules.Mood.Count == 0) { - RandomizerEngine.Config.Rules.RequestRules.Mood = null; + config.Rules.RequestRules.Mood = null; } this.RaisePropertyChanged(nameof(IsMoodNightChecked)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public string? MapName { - get => RandomizerEngine.Config.Rules.RequestRules.Name; + get => config.Rules.RequestRules.Name; set { - RandomizerEngine.Config.Rules.RequestRules.Name = string.IsNullOrWhiteSpace(value) ? null : value; + config.Rules.RequestRules.Name = string.IsNullOrWhiteSpace(value) ? null : value; this.RaisePropertyChanged(nameof(MapName)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public string? MapAuthor { - get => RandomizerEngine.Config.Rules.RequestRules.Author; + get => config.Rules.RequestRules.Author; set { - RandomizerEngine.Config.Rules.RequestRules.Author = string.IsNullOrWhiteSpace(value) ? null : value; + config.Rules.RequestRules.Author = string.IsNullOrWhiteSpace(value) ? null : value; this.RaisePropertyChanged(nameof(MapAuthor)); - RandomizerEngine.SaveConfig(); + config.Save(); } } @@ -700,14 +708,14 @@ public string? MapAuthor public int TagIndex { - get => RandomizerEngine.Config.Rules.RequestRules.Tag.HasValue ? (int)RandomizerEngine.Config.Rules.RequestRules.Tag + 1 : 0; + get => config.Rules.RequestRules.Tag.HasValue ? (int)config.Rules.RequestRules.Tag + 1 : 0; set { - RandomizerEngine.Config.Rules.RequestRules.Tag = value <= 0 ? null : (ETag)(value - 1); + config.Rules.RequestRules.Tag = value <= 0 ? null : (ETag)(value - 1); this.RaisePropertyChanged(nameof(TagIndex)); - RandomizerEngine.SaveConfig(); + config.Save(); } } @@ -716,198 +724,198 @@ public int TagIndex public int LbTypeIndex { - get => RandomizerEngine.Config.Rules.RequestRules.LbType.HasValue ? (int)RandomizerEngine.Config.Rules.RequestRules.LbType + 1 : 0; + get => config.Rules.RequestRules.LbType.HasValue ? (int)config.Rules.RequestRules.LbType + 1 : 0; set { - RandomizerEngine.Config.Rules.RequestRules.LbType = value <= 0 ? null : (ELbType)(value - 1); + config.Rules.RequestRules.LbType = value <= 0 ? null : (ELbType)(value - 1); this.RaisePropertyChanged(nameof(LbTypeIndex)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public int TimeLimitHour { - get => RandomizerEngine.Config.Rules.TimeLimit.Hours; + get => config.Rules.TimeLimit.Hours; set { - RandomizerEngine.Config.Rules.TimeLimit = new TimeSpan(value, - RandomizerEngine.Config.Rules.TimeLimit.Minutes, - RandomizerEngine.Config.Rules.TimeLimit.Seconds); + config.Rules.TimeLimit = new TimeSpan(value, + config.Rules.TimeLimit.Minutes, + config.Rules.TimeLimit.Seconds); this.RaisePropertyChanged(nameof(TimeLimitHour)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public int TimeLimitMinute { - get => RandomizerEngine.Config.Rules.TimeLimit.Minutes; + get => config.Rules.TimeLimit.Minutes; set { - RandomizerEngine.Config.Rules.TimeLimit = new TimeSpan(RandomizerEngine.Config.Rules.TimeLimit.Hours, + config.Rules.TimeLimit = new TimeSpan(config.Rules.TimeLimit.Hours, value, - RandomizerEngine.Config.Rules.TimeLimit.Seconds); + config.Rules.TimeLimit.Seconds); this.RaisePropertyChanged(nameof(TimeLimitMinute)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public int TimeLimitSecond { - get => RandomizerEngine.Config.Rules.TimeLimit.Seconds; + get => config.Rules.TimeLimit.Seconds; set { - RandomizerEngine.Config.Rules.TimeLimit = new TimeSpan(RandomizerEngine.Config.Rules.TimeLimit.Hours, - RandomizerEngine.Config.Rules.TimeLimit.Minutes, + config.Rules.TimeLimit = new TimeSpan(config.Rules.TimeLimit.Hours, + config.Rules.TimeLimit.Minutes, value); this.RaisePropertyChanged(nameof(TimeLimitSecond)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public int MinATMinute { - get => RandomizerEngine.Config.Rules.RequestRules.AuthorTimeMin.GetValueOrDefault().Minutes; + get => config.Rules.RequestRules.AuthorTimeMin.GetValueOrDefault().Minutes; set { - RandomizerEngine.Config.Rules.RequestRules.AuthorTimeMin = new TimeInt32(0, 0, value, - RandomizerEngine.Config.Rules.RequestRules.AuthorTimeMin.GetValueOrDefault().Seconds, - RandomizerEngine.Config.Rules.RequestRules.AuthorTimeMin.GetValueOrDefault().Milliseconds); + config.Rules.RequestRules.AuthorTimeMin = new TimeInt32(0, 0, value, + config.Rules.RequestRules.AuthorTimeMin.GetValueOrDefault().Seconds, + config.Rules.RequestRules.AuthorTimeMin.GetValueOrDefault().Milliseconds); this.RaisePropertyChanged(nameof(MinATMinute)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public int MinATSecond { - get => RandomizerEngine.Config.Rules.RequestRules.AuthorTimeMin.GetValueOrDefault().Seconds; + get => config.Rules.RequestRules.AuthorTimeMin.GetValueOrDefault().Seconds; set { - RandomizerEngine.Config.Rules.RequestRules.AuthorTimeMin = new TimeInt32(0, 0, - RandomizerEngine.Config.Rules.RequestRules.AuthorTimeMin.GetValueOrDefault().Minutes, + config.Rules.RequestRules.AuthorTimeMin = new TimeInt32(0, 0, + config.Rules.RequestRules.AuthorTimeMin.GetValueOrDefault().Minutes, value, - RandomizerEngine.Config.Rules.RequestRules.AuthorTimeMin.GetValueOrDefault().Milliseconds); + config.Rules.RequestRules.AuthorTimeMin.GetValueOrDefault().Milliseconds); this.RaisePropertyChanged(nameof(MinATSecond)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public int MinATMillisecond { - get => RandomizerEngine.Config.Rules.RequestRules.AuthorTimeMin.GetValueOrDefault().Milliseconds / 10; + get => config.Rules.RequestRules.AuthorTimeMin.GetValueOrDefault().Milliseconds / 10; set { - RandomizerEngine.Config.Rules.RequestRules.AuthorTimeMin = new TimeInt32(0, 0, - RandomizerEngine.Config.Rules.RequestRules.AuthorTimeMin.GetValueOrDefault().Minutes, - RandomizerEngine.Config.Rules.RequestRules.AuthorTimeMin.GetValueOrDefault().Seconds, + config.Rules.RequestRules.AuthorTimeMin = new TimeInt32(0, 0, + config.Rules.RequestRules.AuthorTimeMin.GetValueOrDefault().Minutes, + config.Rules.RequestRules.AuthorTimeMin.GetValueOrDefault().Seconds, value * 10); this.RaisePropertyChanged(nameof(MinATMillisecond)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool MinATEnabled { - get => RandomizerEngine.Config.Rules.RequestRules.AuthorTimeMin is not null; + get => config.Rules.RequestRules.AuthorTimeMin is not null; set { - RandomizerEngine.Config.Rules.RequestRules.AuthorTimeMin = value ? TimeInt32.Zero : null; + config.Rules.RequestRules.AuthorTimeMin = value ? TimeInt32.Zero : null; this.RaisePropertyChanged(nameof(MinATEnabled)); this.RaisePropertyChanged(nameof(MinATMinute)); this.RaisePropertyChanged(nameof(MinATSecond)); this.RaisePropertyChanged(nameof(MinATMillisecond)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public int MaxATMinute { - get => RandomizerEngine.Config.Rules.RequestRules.AuthorTimeMax.GetValueOrDefault().Minutes; + get => config.Rules.RequestRules.AuthorTimeMax.GetValueOrDefault().Minutes; set { - RandomizerEngine.Config.Rules.RequestRules.AuthorTimeMax = new TimeInt32(0, 0, value, - RandomizerEngine.Config.Rules.RequestRules.AuthorTimeMax.GetValueOrDefault().Seconds, - RandomizerEngine.Config.Rules.RequestRules.AuthorTimeMax.GetValueOrDefault().Milliseconds); + config.Rules.RequestRules.AuthorTimeMax = new TimeInt32(0, 0, value, + config.Rules.RequestRules.AuthorTimeMax.GetValueOrDefault().Seconds, + config.Rules.RequestRules.AuthorTimeMax.GetValueOrDefault().Milliseconds); this.RaisePropertyChanged(nameof(MaxATMinute)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public int MaxATSecond { - get => RandomizerEngine.Config.Rules.RequestRules.AuthorTimeMax.GetValueOrDefault().Seconds; + get => config.Rules.RequestRules.AuthorTimeMax.GetValueOrDefault().Seconds; set { - RandomizerEngine.Config.Rules.RequestRules.AuthorTimeMax = new TimeInt32(0, 0, - RandomizerEngine.Config.Rules.RequestRules.AuthorTimeMax.GetValueOrDefault().Minutes, + config.Rules.RequestRules.AuthorTimeMax = new TimeInt32(0, 0, + config.Rules.RequestRules.AuthorTimeMax.GetValueOrDefault().Minutes, value, - RandomizerEngine.Config.Rules.RequestRules.AuthorTimeMax.GetValueOrDefault().Milliseconds); + config.Rules.RequestRules.AuthorTimeMax.GetValueOrDefault().Milliseconds); this.RaisePropertyChanged(nameof(MaxATSecond)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public int MaxATMillisecond { - get => RandomizerEngine.Config.Rules.RequestRules.AuthorTimeMax.GetValueOrDefault().Milliseconds / 10; + get => config.Rules.RequestRules.AuthorTimeMax.GetValueOrDefault().Milliseconds / 10; set { - RandomizerEngine.Config.Rules.RequestRules.AuthorTimeMax = new TimeInt32(0, 0, - RandomizerEngine.Config.Rules.RequestRules.AuthorTimeMax.GetValueOrDefault().Minutes, - RandomizerEngine.Config.Rules.RequestRules.AuthorTimeMax.GetValueOrDefault().Seconds, + config.Rules.RequestRules.AuthorTimeMax = new TimeInt32(0, 0, + config.Rules.RequestRules.AuthorTimeMax.GetValueOrDefault().Minutes, + config.Rules.RequestRules.AuthorTimeMax.GetValueOrDefault().Seconds, value * 10); this.RaisePropertyChanged(nameof(MaxATMillisecond)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool MaxATEnabled { - get => RandomizerEngine.Config.Rules.RequestRules.AuthorTimeMax is not null; + get => config.Rules.RequestRules.AuthorTimeMax is not null; set { - RandomizerEngine.Config.Rules.RequestRules.AuthorTimeMax = value ? TimeInt32.Zero : null; + config.Rules.RequestRules.AuthorTimeMax = value ? TimeInt32.Zero : null; this.RaisePropertyChanged(nameof(MaxATEnabled)); this.RaisePropertyChanged(nameof(MaxATMinute)); this.RaisePropertyChanged(nameof(MaxATSecond)); this.RaisePropertyChanged(nameof(MaxATMillisecond)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public DateTimeOffset? UploadedAfter { - get => RandomizerEngine.Config.Rules.RequestRules.UploadedAfter?.ToDateTime(new()); + get => config.Rules.RequestRules.UploadedAfter?.ToDateTime(new()); set { - RandomizerEngine.Config.Rules.RequestRules.UploadedAfter = value.HasValue ? DateOnly.FromDateTime(value.Value.DateTime) : null; + config.Rules.RequestRules.UploadedAfter = value.HasValue ? DateOnly.FromDateTime(value.Value.DateTime) : null; this.RaisePropertyChanged(nameof(UploadedAfter)); - RandomizerEngine.SaveConfig(); + config.Save(); } } @@ -915,40 +923,40 @@ public DateTimeOffset? UploadedAfter public DateTimeOffset? UploadedBefore { - get => RandomizerEngine.Config.Rules.RequestRules.UploadedBefore?.ToDateTime(new()); + get => config.Rules.RequestRules.UploadedBefore?.ToDateTime(new()); set { - RandomizerEngine.Config.Rules.RequestRules.UploadedBefore = value.HasValue ? DateOnly.FromDateTime(value.Value.DateTime) : null; + config.Rules.RequestRules.UploadedBefore = value.HasValue ? DateOnly.FromDateTime(value.Value.DateTime) : null; this.RaisePropertyChanged(nameof(UploadedBefore)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool EqualEnvDistribution { - get => RandomizerEngine.Config.Rules.RequestRules.EqualEnvironmentDistribution; + get => config.Rules.RequestRules.EqualEnvironmentDistribution; set { - RandomizerEngine.Config.Rules.RequestRules.EqualEnvironmentDistribution = value; + config.Rules.RequestRules.EqualEnvironmentDistribution = value; this.RaisePropertyChanged(nameof(EqualEnvDistribution)); - RandomizerEngine.SaveConfig(); + config.Save(); } } public bool EqualVehicleDistribution { - get => RandomizerEngine.Config.Rules.RequestRules.EqualVehicleDistribution; + get => config.Rules.RequestRules.EqualVehicleDistribution; set { - RandomizerEngine.Config.Rules.RequestRules.EqualVehicleDistribution = value; + config.Rules.RequestRules.EqualVehicleDistribution = value; this.RaisePropertyChanged(nameof(EqualVehicleDistribution)); - RandomizerEngine.SaveConfig(); + config.Save(); } } diff --git a/Src/RandomizerTMF/ViewModels/SessionDataViewModel.cs b/Src/RandomizerTMF/ViewModels/SessionDataViewModel.cs index 2423f9d..83c86a0 100644 --- a/Src/RandomizerTMF/ViewModels/SessionDataViewModel.cs +++ b/Src/RandomizerTMF/ViewModels/SessionDataViewModel.cs @@ -1,16 +1,16 @@ using GBX.NET.Engines.Game; using RandomizerTMF.Logic; +using RandomizerTMF.Logic.Services; using RandomizerTMF.Models; using ReactiveUI; using System.Collections; using System.Collections.ObjectModel; using System.Reflection; -using System.Text.RegularExpressions; using TmEssentials; namespace RandomizerTMF.ViewModels; -public partial class SessionDataViewModel : WindowWithTopBarViewModelBase +internal class SessionDataViewModel : WindowWithTopBarViewModelBase { private ObservableCollection replays = new(); private SessionDataMapModel? selectedMap; @@ -63,7 +63,7 @@ public SessionDataMapModel? SelectedMap /// /// Should be only used for window preview /// - public SessionDataViewModel() : this(new(new() + public SessionDataViewModel() : this(new(), new(new() { StartedAt = DateTimeOffset.Now, Maps = new List() @@ -92,7 +92,7 @@ public SessionDataViewModel() : this(new(new() TopBarViewModel.Title = "Session"; } - public SessionDataViewModel(SessionDataModel model) + public SessionDataViewModel(TopBarViewModel topBarViewModel, SessionDataModel model) : base(topBarViewModel) { Model = model; Rules = ConstructRules(); @@ -175,31 +175,22 @@ private static void AddRuleString(IList rules, PropertyInfo prop, object owner) public void OpenSessionFolderClick() { - if (RandomizerEngine.SessionsDirectoryPath is not null) - { - ProcessUtils.OpenDir(Path.Combine(RandomizerEngine.SessionsDirectoryPath, Model.Data.StartedAtText) + Path.DirectorySeparatorChar); - } + ProcessUtils.OpenDir(Path.Combine(FilePathManager.SessionsDirectoryPath, Model.Data.StartedAtText) + Path.DirectorySeparatorChar); } public void OpenReplaysFolderClick() { - if (RandomizerEngine.SessionsDirectoryPath is not null) - { - var replaysDir = Path.Combine(RandomizerEngine.SessionsDirectoryPath, Model.Data.StartedAtText, Constants.Replays); + var replaysDir = Path.Combine(FilePathManager.SessionsDirectoryPath, Model.Data.StartedAtText, Constants.Replays); - if (Directory.Exists(replaysDir)) - { - ProcessUtils.OpenDir(replaysDir + Path.DirectorySeparatorChar); - } + if (Directory.Exists(replaysDir)) + { + ProcessUtils.OpenDir(replaysDir + Path.DirectorySeparatorChar); } } - [GeneratedRegex("[a-z][A-Z]")] - private static partial Regex SentenceCaseRegex(); - // THX https://stackoverflow.com/a/1211435/3923447 private static string ToSentenceCase(string str) { - return SentenceCaseRegex().Replace(str, m => $"{m.Value[0]} {char.ToLower(m.Value[1])}"); + return CompiledRegex.SentenceCaseRegex().Replace(str, m => $"{m.Value[0]} {char.ToLower(m.Value[1])}"); } } diff --git a/Src/RandomizerTMF/ViewModels/SessionMapViewModel.cs b/Src/RandomizerTMF/ViewModels/SessionMapViewModel.cs index 396e9ff..b0375a9 100644 --- a/Src/RandomizerTMF/ViewModels/SessionMapViewModel.cs +++ b/Src/RandomizerTMF/ViewModels/SessionMapViewModel.cs @@ -1,21 +1,20 @@ using RandomizerTMF.Models; -using System.Diagnostics; namespace RandomizerTMF.ViewModels; -public class SessionMapViewModel : WindowWithTopBarViewModelBase +internal class SessionMapViewModel : WindowWithTopBarViewModelBase { public PlayedMapModel Model { get; } /// /// Should be used only for previews /// - public SessionMapViewModel() : this(null!) + public SessionMapViewModel() : this(new(null), null!) { } - public SessionMapViewModel(PlayedMapModel model) + public SessionMapViewModel(TopBarViewModel topBarViewModel, PlayedMapModel model) : base(topBarViewModel) { Model = model; diff --git a/Src/RandomizerTMF/ViewModels/StatusModuleWindowViewModel.cs b/Src/RandomizerTMF/ViewModels/StatusModuleWindowViewModel.cs index a186597..2cc0ce7 100644 --- a/Src/RandomizerTMF/ViewModels/StatusModuleWindowViewModel.cs +++ b/Src/RandomizerTMF/ViewModels/StatusModuleWindowViewModel.cs @@ -1,15 +1,19 @@ using RandomizerTMF.Logic; +using RandomizerTMF.Logic.Services; using ReactiveUI; namespace RandomizerTMF.ViewModels; -public class StatusModuleWindowViewModel : WindowViewModelBase +internal class StatusModuleWindowViewModel : ModuleWindowViewModelBase { private Task? updateTimeTask; private CancellationTokenSource? updateTimeCancellationTokenSource; private string statusText = "Idle"; private float timeOpacity = 1f; + private readonly IRandomizerEngine engine; + private readonly IRandomizerEvents events; + private readonly IRandomizerConfig config; public TimeSpan Time { get; private set; } public string TimeText => Time.ToString("h':'mm':'ss"); @@ -26,13 +30,17 @@ public float TimeOpacity private set => this.RaiseAndSetIfChanged(ref timeOpacity, value); } - public StatusModuleWindowViewModel() + public StatusModuleWindowViewModel(IRandomizerEngine engine, IRandomizerEvents events, IRandomizerConfig config) : base(config) { - Time = RandomizerEngine.Config.Rules.TimeLimit; + this.engine = engine; + this.events = events; + this.config = config; + + Time = config.Rules.TimeLimit; - RandomizerEngine.MapStarted += RandomizerMapStarted; - RandomizerEngine.MapEnded += RandomizerMapEnded; - RandomizerEngine.Status += RandomizerStatus; + events.MapStarted += RandomizerMapStarted; + events.MapEnded += RandomizerMapEnded; + events.Status += RandomizerStatus; } private void RandomizerStatus(string status) @@ -40,16 +48,16 @@ private void RandomizerStatus(string status) StatusText = status; } - private void RandomizerMapStarted() + private void RandomizerMapStarted(SessionMap map) { updateTimeCancellationTokenSource = new CancellationTokenSource(); updateTimeTask = Task.Run(async () => { while (true) { - if (RandomizerEngine.CurrentSessionWatch is not null) + if (engine.CurrentSession?.Watch is not null) { - Time = RandomizerEngine.Config.Rules.TimeLimit - RandomizerEngine.CurrentSessionWatch.Elapsed; + Time = config.Rules.TimeLimit - engine.CurrentSession.Watch.Elapsed; this.RaisePropertyChanged(nameof(TimeText)); } diff --git a/Src/RandomizerTMF/ViewModels/TopBarViewModel.cs b/Src/RandomizerTMF/ViewModels/TopBarViewModel.cs index a066ab2..4c99dd0 100644 --- a/Src/RandomizerTMF/ViewModels/TopBarViewModel.cs +++ b/Src/RandomizerTMF/ViewModels/TopBarViewModel.cs @@ -1,4 +1,5 @@ using Avalonia.Controls; +using Microsoft.Extensions.DependencyInjection; using RandomizerTMF.Logic; using RandomizerTMF.Views; using ReactiveUI; @@ -6,8 +7,10 @@ namespace RandomizerTMF.ViewModels; -public class TopBarViewModel : ViewModelBase +internal class TopBarViewModel : ViewModelBase { + private readonly IUpdateDetector? updateDetector; + private string? title = Constants.Title; private bool minimizeButtonEnabled = true; @@ -23,7 +26,7 @@ public bool MinimizeButtonEnabled set => this.RaiseAndSetIfChanged(ref minimizeButtonEnabled, value); } - public bool IsNewUpdate => UpdateDetector.IsNewUpdate; + public bool IsNewUpdate => updateDetector?.IsNewUpdate ?? false; public static string? Version => Program.Version; public static string? VersionTooltip => $"About Randomizer TMF {Program.Version}"; @@ -34,9 +37,14 @@ public bool MinimizeButtonEnabled public event Action? CloseClick; public event Action? MinimizeClick; - public TopBarViewModel() + public TopBarViewModel(IUpdateDetector? updateDetector = null) { - UpdateDetector.UpdateChecked += () => this.RaisePropertyChanged(nameof(IsNewUpdate)); + this.updateDetector = updateDetector; + + if (updateDetector is not null) + { + updateDetector.UpdateChecked += () => this.RaisePropertyChanged(nameof(IsNewUpdate)); + } } public void OnCloseClick() @@ -56,8 +64,16 @@ public void DonateClick() public void VersionClick() { + if (Program.ServiceProvider is null) + { + throw new UnreachableException("ServiceProvider is null"); + } + + var topBarViewModel = Program.ServiceProvider.GetRequiredService(); + var updateDetector = Program.ServiceProvider.GetRequiredService(); + var window = new AboutWindow(); - var viewModel = new AboutWindowViewModel() { Window = window }; + var viewModel = new AboutWindowViewModel(topBarViewModel, updateDetector) { Window = window }; viewModel.OnInit(); window.DataContext = viewModel; window.ShowDialog(WindowOwner ?? App.MainWindow); // The parent window diff --git a/Src/RandomizerTMF/ViewModels/ViewModelBase.cs b/Src/RandomizerTMF/ViewModels/ViewModelBase.cs index 26bd397..ce9d62a 100644 --- a/Src/RandomizerTMF/ViewModels/ViewModelBase.cs +++ b/Src/RandomizerTMF/ViewModels/ViewModelBase.cs @@ -2,7 +2,7 @@ namespace RandomizerTMF.ViewModels; -public class ViewModelBase : ReactiveObject +internal class ViewModelBase : ReactiveObject { } diff --git a/Src/RandomizerTMF/ViewModels/WindowViewModelBase.cs b/Src/RandomizerTMF/ViewModels/WindowViewModelBase.cs index 297ef54..4b42b42 100644 --- a/Src/RandomizerTMF/ViewModels/WindowViewModelBase.cs +++ b/Src/RandomizerTMF/ViewModels/WindowViewModelBase.cs @@ -1,26 +1,34 @@ using Avalonia.Controls; +using Microsoft.Extensions.DependencyInjection; using RandomizerTMF.Views; +using System.Diagnostics; namespace RandomizerTMF.ViewModels; -public class WindowViewModelBase : ViewModelBase +internal class WindowViewModelBase : ViewModelBase { - public Window Window { get; init; } = default!; + public Window Window { get; internal set; } = default!; public void SwitchWindowTo() - where TWindow : Window, new() - where TViewModel : WindowViewModelBase, new() + where TWindow : Window + where TViewModel : WindowViewModelBase { _ = OpenWindow(); Window.Close(); } public static TWindow OpenWindow() - where TWindow : Window, new() - where TViewModel : WindowViewModelBase, new() + where TWindow : Window + where TViewModel : WindowViewModelBase { - var window = new TWindow(); - var viewModel = new TViewModel() { Window = window }; + if (Program.ServiceProvider is null) + { + throw new UnreachableException("ServiceProvider is null"); + } + + var window = Program.ServiceProvider.GetRequiredService(); + var viewModel = Program.ServiceProvider.GetRequiredService(); + viewModel.Window = window; viewModel.OnInit(); window.DataContext = viewModel; window.Show(); @@ -52,8 +60,15 @@ public TWindow OpenDialog(Func viewModelF public MessageWindow OpenMessageBox(string title, string content) { + if (Program.ServiceProvider is null) + { + throw new UnreachableException("ServiceProvider is null"); + } + + var topBarViewModel = Program.ServiceProvider.GetRequiredService(); + var window = new MessageWindow() { Title = title }; - var viewModel = new MessageWindowViewModel() { Window = window, Content = content }; + var viewModel = new MessageWindowViewModel(topBarViewModel) { Window = window, Content = content }; viewModel.OnInit(); window.DataContext = viewModel; window.ShowDialog(Window); // The parent window diff --git a/Src/RandomizerTMF/ViewModels/WindowWithTopBarViewModelBase.cs b/Src/RandomizerTMF/ViewModels/WindowWithTopBarViewModelBase.cs index 512fc27..6370f68 100644 --- a/Src/RandomizerTMF/ViewModels/WindowWithTopBarViewModelBase.cs +++ b/Src/RandomizerTMF/ViewModels/WindowWithTopBarViewModelBase.cs @@ -1,12 +1,12 @@ namespace RandomizerTMF.ViewModels; -public class WindowWithTopBarViewModelBase : WindowViewModelBase +internal class WindowWithTopBarViewModelBase : WindowViewModelBase { public TopBarViewModel TopBarViewModel { get; set; } - public WindowWithTopBarViewModelBase() + public WindowWithTopBarViewModelBase(TopBarViewModel topBarViewModel) { - TopBarViewModel = new(); + TopBarViewModel = topBarViewModel; TopBarViewModel.CloseClick += CloseClick; TopBarViewModel.MinimizeClick += MinimizeClick; } diff --git a/Src/RandomizerTMF/Views/ControlModuleWindow.axaml.cs b/Src/RandomizerTMF/Views/ControlModuleWindow.axaml.cs index b02c466..afa455f 100644 --- a/Src/RandomizerTMF/Views/ControlModuleWindow.axaml.cs +++ b/Src/RandomizerTMF/Views/ControlModuleWindow.axaml.cs @@ -1,5 +1,6 @@ using Avalonia.Controls; using RandomizerTMF.Logic; +using RandomizerTMF.ViewModels; namespace RandomizerTMF.Views { @@ -8,16 +9,9 @@ public partial class ControlModuleWindow : Window public ControlModuleWindow() { InitializeComponent(); - - Closing += (_, _) => - { - RandomizerEngine.Config.Modules.Control.X = Convert.ToInt32(Position.X); - RandomizerEngine.Config.Modules.Control.Y = Convert.ToInt32(Position.Y); - RandomizerEngine.Config.Modules.Control.Width = Convert.ToInt32(Width); - RandomizerEngine.Config.Modules.Control.Height = Convert.ToInt32(Height); - RandomizerEngine.SaveConfig(); - }; + Closing += (_, _) => (DataContext as ModuleWindowViewModelBase)? + .OnClosing(x => x.Control, Position.X, Position.Y, Width, Height); Deactivated += (_, _) => { Topmost = false; Topmost = true; }; } diff --git a/Src/RandomizerTMF/Views/HistoryModuleWindow.axaml.cs b/Src/RandomizerTMF/Views/HistoryModuleWindow.axaml.cs index e295f71..98c3e7b 100644 --- a/Src/RandomizerTMF/Views/HistoryModuleWindow.axaml.cs +++ b/Src/RandomizerTMF/Views/HistoryModuleWindow.axaml.cs @@ -11,15 +11,8 @@ public HistoryModuleWindow() { InitializeComponent(); - Closing += (_, _) => - { - RandomizerEngine.Config.Modules.History.X = Convert.ToInt32(Position.X); - RandomizerEngine.Config.Modules.History.Y = Convert.ToInt32(Position.Y); - RandomizerEngine.Config.Modules.History.Width = Convert.ToInt32(Width); - RandomizerEngine.Config.Modules.History.Height = Convert.ToInt32(Height); - - RandomizerEngine.SaveConfig(); - }; + Closing += (_, _) => (DataContext as ModuleWindowViewModelBase)? + .OnClosing(x => x.History, Position.X, Position.Y, Width, Height); Deactivated += (_, _) => { Topmost = false; Topmost = true; }; } diff --git a/Src/RandomizerTMF/Views/MainWindow.axaml.cs b/Src/RandomizerTMF/Views/MainWindow.axaml.cs index ebfe78d..7f41466 100644 --- a/Src/RandomizerTMF/Views/MainWindow.axaml.cs +++ b/Src/RandomizerTMF/Views/MainWindow.axaml.cs @@ -5,7 +5,7 @@ namespace RandomizerTMF.Views; -public partial class MainWindow : ReactiveWindow, IStyleable +internal partial class MainWindow : ReactiveWindow, IStyleable { public MainWindow() { diff --git a/Src/RandomizerTMF/Views/ProgressModuleWindow.axaml b/Src/RandomizerTMF/Views/ProgressModuleWindow.axaml index 067f1cc..f3958e3 100644 --- a/Src/RandomizerTMF/Views/ProgressModuleWindow.axaml +++ b/Src/RandomizerTMF/Views/ProgressModuleWindow.axaml @@ -77,6 +77,7 @@ VerticalAlignment="Top" FontSize="24" FontWeight="Bold" - Foreground="{Binding SkipColor}">SKIPS + Foreground="{Binding SkipColor}" + Text="{Binding SkipText}"/> diff --git a/Src/RandomizerTMF/Views/ProgressModuleWindow.axaml.cs b/Src/RandomizerTMF/Views/ProgressModuleWindow.axaml.cs index 803ebee..a32b06b 100644 --- a/Src/RandomizerTMF/Views/ProgressModuleWindow.axaml.cs +++ b/Src/RandomizerTMF/Views/ProgressModuleWindow.axaml.cs @@ -1,5 +1,6 @@ using Avalonia.Controls; using RandomizerTMF.Logic; +using RandomizerTMF.ViewModels; namespace RandomizerTMF.Views { @@ -9,15 +10,8 @@ public ProgressModuleWindow() { InitializeComponent(); - Closing += (_, _) => - { - RandomizerEngine.Config.Modules.Progress.X = Convert.ToInt32(Position.X); - RandomizerEngine.Config.Modules.Progress.Y = Convert.ToInt32(Position.Y); - RandomizerEngine.Config.Modules.Progress.Width = Convert.ToInt32(Width); - RandomizerEngine.Config.Modules.Progress.Height = Convert.ToInt32(Height); - - RandomizerEngine.SaveConfig(); - }; + Closing += (_, _) => (DataContext as ModuleWindowViewModelBase)? + .OnClosing(x => x.Progress, Position.X, Position.Y, Width, Height); Deactivated += (_, _) => { Topmost = false; Topmost = true; }; } diff --git a/Src/RandomizerTMF/Views/StatusModuleWindow.axaml.cs b/Src/RandomizerTMF/Views/StatusModuleWindow.axaml.cs index 6c93e9f..a668351 100644 --- a/Src/RandomizerTMF/Views/StatusModuleWindow.axaml.cs +++ b/Src/RandomizerTMF/Views/StatusModuleWindow.axaml.cs @@ -1,5 +1,6 @@ using Avalonia.Controls; using RandomizerTMF.Logic; +using RandomizerTMF.ViewModels; namespace RandomizerTMF.Views { @@ -8,16 +9,9 @@ public partial class StatusModuleWindow : Window public StatusModuleWindow() { InitializeComponent(); - - Closing += (_, _) => - { - RandomizerEngine.Config.Modules.Status.X = Convert.ToInt32(Position.X); - RandomizerEngine.Config.Modules.Status.Y = Convert.ToInt32(Position.Y); - RandomizerEngine.Config.Modules.Status.Width = Convert.ToInt32(Width); - RandomizerEngine.Config.Modules.Status.Height = Convert.ToInt32(Height); - - RandomizerEngine.SaveConfig(); - }; + + Closing += (_, _) => (DataContext as ModuleWindowViewModelBase)? + .OnClosing(x => x.Status, Position.X, Position.Y, Width, Height); Deactivated += (_, _) => { Topmost = false; Topmost = true; }; } diff --git a/Tests/RandomizerTMF.Logic.Tests/Files/Randomizer TMF test track.Challenge.Gbx b/Tests/RandomizerTMF.Logic.Tests/Files/Randomizer TMF test track.Challenge.Gbx new file mode 100644 index 0000000..0fbd087 Binary files /dev/null and b/Tests/RandomizerTMF.Logic.Tests/Files/Randomizer TMF test track.Challenge.Gbx differ diff --git a/Tests/RandomizerTMF.Logic.Tests/Files/petrp_Randomizer TMF test track.Replay.gbx b/Tests/RandomizerTMF.Logic.Tests/Files/petrp_Randomizer TMF test track.Replay.gbx new file mode 100644 index 0000000..9b531cc Binary files /dev/null and b/Tests/RandomizerTMF.Logic.Tests/Files/petrp_Randomizer TMF test track.Replay.gbx differ diff --git a/Tests/RandomizerTMF.Logic.Tests/NodeInstance.cs b/Tests/RandomizerTMF.Logic.Tests/NodeInstance.cs new file mode 100644 index 0000000..c8b0492 --- /dev/null +++ b/Tests/RandomizerTMF.Logic.Tests/NodeInstance.cs @@ -0,0 +1,12 @@ +using GBX.NET; +using System.Diagnostics; + +namespace RandomizerTMF.Logic.Tests; + +internal static class NodeInstance +{ + public static T Create() where T : Node + { + return (T)(Activator.CreateInstance(typeof(T), nonPublic: true) ?? throw new UnreachableException()); + } +} diff --git a/Tests/RandomizerTMF.Logic.Tests/RandomizerTMF.Logic.Tests.csproj b/Tests/RandomizerTMF.Logic.Tests/RandomizerTMF.Logic.Tests.csproj new file mode 100644 index 0000000..cb953fb --- /dev/null +++ b/Tests/RandomizerTMF.Logic.Tests/RandomizerTMF.Logic.Tests.csproj @@ -0,0 +1,41 @@ + + + + net7.0 + enable + enable + + false + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/Tests/RandomizerTMF.Logic.Tests/Unit/AutosaveDetailsTests.cs b/Tests/RandomizerTMF.Logic.Tests/Unit/AutosaveDetailsTests.cs new file mode 100644 index 0000000..d630d69 --- /dev/null +++ b/Tests/RandomizerTMF.Logic.Tests/Unit/AutosaveDetailsTests.cs @@ -0,0 +1,155 @@ +using TmEssentials; + +using static GBX.NET.Engines.Game.CGameCtnChallenge; + +namespace RandomizerTMF.Logic.Tests.Unit; + +public class AutosaveDetailsTests +{ + [Theory] + [InlineData( false, PlayMode.Race, 60000, 40000, 35000, 32000, 300, 60001, 110, 2)] + [InlineData( true, PlayMode.Race, 60000, 40000, 35000, 32000, 300, 60000, 110, 2)] + [InlineData( true, PlayMode.Race, 60000, 40000, 35000, 32000, 300, 50000, 130, 1)] + [InlineData( true, PlayMode.Race, 60000, 40000, 35000, 32000, 300, 30000, 0, 0)] + [InlineData( false, PlayMode.Puzzle, 60000, 40000, 35000, 32000, 300, 60001, 110, 2)] + [InlineData( true, PlayMode.Puzzle, 60000, 40000, 35000, 32000, 300, 60000, 110, 2)] + [InlineData( true, PlayMode.Puzzle, 60000, 40000, 35000, 32000, 300, 50000, 130, 1)] + [InlineData( true, PlayMode.Puzzle, 60000, 40000, 35000, 32000, 300, 30000, 0, 0)] + [InlineData( true, PlayMode.Platform, 6, 4, 2, 32000, 1, 30000, 10, 3)] + [InlineData( true, PlayMode.Platform, 6, 4, 2, 32000, 1, 35000, 4, 6)] + [InlineData( false, PlayMode.Platform, 6, 4, 2, 32000, 1, 30000, 10, 7)] + [InlineData( true, PlayMode.Stunts, 200, 400, 600, 32000, 650, 30000, 250, 2)] + [InlineData( true, PlayMode.Stunts, 200, 400, 600, 32000, 650, 30000, 200, 2)] + [InlineData( false, PlayMode.Stunts, 200, 400, 600, 32000, 650, 30000, 199, 2)] + [InlineData( false, PlayMode.Script, 200, 400, 600, 32000, 650, 30000, 200, 2)] + public void HasBronzeMedal_ReturnsCorrectBool(bool expected, PlayMode mode, int bronze, int silver, int gold, int authorTime, int authorScore, int timeMs, int stuntScore, int respawns) + { + // Arrange + var details = new AutosaveDetails( + Time: new TimeInt32(timeMs), + Score: stuntScore, + Respawns: respawns, + MapName: null, + MapEnvironment: null, + MapCar: null, + MapBronzeTime: new TimeInt32(bronze), + MapSilverTime: new TimeInt32(silver), + MapGoldTime: new TimeInt32(gold), + MapAuthorTime: new TimeInt32(authorTime), + MapAuthorScore: authorScore, + MapMode: mode); + + Assert.Equal(expected, actual: details.HasBronzeMedal); + } + + [Theory] + [InlineData( false, PlayMode.Race, 60000, 40000, 35000, 32000, 300, 40001, 110, 2)] + [InlineData( true, PlayMode.Race, 60000, 40000, 35000, 32000, 300, 40000, 110, 2)] + [InlineData( true, PlayMode.Race, 60000, 40000, 35000, 32000, 300, 30000, 130, 1)] + [InlineData( true, PlayMode.Race, 60000, 40000, 35000, 32000, 300, 30000, 0, 0)] + [InlineData( false, PlayMode.Puzzle, 60000, 40000, 35000, 32000, 300, 40001, 110, 2)] + [InlineData( true, PlayMode.Puzzle, 60000, 40000, 35000, 32000, 300, 40000, 110, 2)] + [InlineData( true, PlayMode.Puzzle, 60000, 40000, 35000, 32000, 300, 30000, 130, 1)] + [InlineData( true, PlayMode.Puzzle, 60000, 40000, 35000, 32000, 300, 30000, 0, 0)] + [InlineData( true, PlayMode.Platform, 6, 4, 2, 32000, 1, 30000, 10, 3)] + [InlineData( true, PlayMode.Platform, 6, 4, 2, 32000, 1, 35000, 4, 4)] + [InlineData( false, PlayMode.Platform, 6, 4, 2, 32000, 1, 30000, 10, 5)] + [InlineData( true, PlayMode.Stunts, 200, 400, 600, 32000, 650, 30000, 550, 2)] + [InlineData( true, PlayMode.Stunts, 200, 400, 600, 32000, 650, 30000, 400, 2)] + [InlineData( false, PlayMode.Stunts, 200, 400, 600, 32000, 650, 30000, 399, 2)] + [InlineData( false, PlayMode.Script, 200, 400, 600, 32000, 650, 30000, 400, 2)] + public void HasSilverMedal_ReturnsCorrectBool(bool expected, PlayMode mode, int bronze, int silver, int gold, int authorTime, int authorScore, int timeMs, int stuntScore, int respawns) + { + // Arrange + var details = new AutosaveDetails( + Time: new TimeInt32(timeMs), + Score: stuntScore, + Respawns: respawns, + MapName: null, + MapEnvironment: null, + MapCar: null, + MapBronzeTime: new TimeInt32(bronze), + MapSilverTime: new TimeInt32(silver), + MapGoldTime: new TimeInt32(gold), + MapAuthorTime: new TimeInt32(authorTime), + MapAuthorScore: authorScore, + MapMode: mode); + + Assert.Equal(expected, actual: details.HasSilverMedal); + } + + [Theory] + [InlineData( false, PlayMode.Race, 60000, 40000, 35000, 32000, 300, 35001, 110, 2)] + [InlineData( true, PlayMode.Race, 60000, 40000, 35000, 32000, 300, 35000, 110, 2)] + [InlineData( true, PlayMode.Race, 60000, 40000, 35000, 32000, 300, 30000, 130, 1)] + [InlineData( true, PlayMode.Race, 60000, 40000, 35000, 32000, 300, 30000, 0, 0)] + [InlineData( false, PlayMode.Puzzle, 60000, 40000, 35000, 32000, 300, 35001, 110, 2)] + [InlineData( true, PlayMode.Puzzle, 60000, 40000, 35000, 32000, 300, 35000, 110, 2)] + [InlineData( true, PlayMode.Puzzle, 60000, 40000, 35000, 32000, 300, 30000, 130, 1)] + [InlineData( true, PlayMode.Puzzle, 60000, 40000, 35000, 32000, 300, 30000, 0, 0)] + [InlineData( true, PlayMode.Platform, 6, 4, 2, 32000, 1, 30000, 10, 0)] + [InlineData( true, PlayMode.Platform, 6, 4, 2, 32000, 1, 35000, 4, 2)] + [InlineData( false, PlayMode.Platform, 6, 4, 2, 32000, 1, 30000, 10, 3)] + [InlineData( true, PlayMode.Stunts, 200, 400, 600, 32000, 650, 30000, 850, 2)] + [InlineData( true, PlayMode.Stunts, 200, 400, 600, 32000, 650, 30000, 600, 2)] + [InlineData( false, PlayMode.Stunts, 200, 400, 600, 32000, 650, 30000, 599, 2)] + [InlineData( false, PlayMode.Script, 200, 400, 600, 32000, 650, 30000, 602, 2)] + public void HasGoldMedal_ReturnsCorrectBool(bool expected, PlayMode mode, int bronze, int silver, int gold, int authorTime, int authorScore, int timeMs, int stuntScore, int respawns) + { + // Arrange + var details = new AutosaveDetails( + Time: new TimeInt32(timeMs), + Score: stuntScore, + Respawns: respawns, + MapName: null, + MapEnvironment: null, + MapCar: null, + MapBronzeTime: new TimeInt32(bronze), + MapSilverTime: new TimeInt32(silver), + MapGoldTime: new TimeInt32(gold), + MapAuthorTime: new TimeInt32(authorTime), + MapAuthorScore: authorScore, + MapMode: mode); + + Assert.Equal(expected, actual: details.HasGoldMedal); + } + + [Theory] + [InlineData( false, PlayMode.Race, 60000, 40000, 35000, 32000, 300, 32001, 110, 2)] + [InlineData( true, PlayMode.Race, 60000, 40000, 35000, 32000, 300, 32000, 110, 2)] + [InlineData( true, PlayMode.Race, 60000, 40000, 35000, 32000, 300, 30000, 130, 1)] + [InlineData( true, PlayMode.Race, 60000, 40000, 35000, 32000, 300, 30000, 0, 0)] + [InlineData( false, PlayMode.Puzzle, 60000, 40000, 35000, 32000, 300, 32001, 110, 2)] + [InlineData( true, PlayMode.Puzzle, 60000, 40000, 35000, 32000, 300, 32000, 110, 2)] + [InlineData( true, PlayMode.Puzzle, 60000, 40000, 35000, 32000, 300, 30000, 130, 1)] + [InlineData( true, PlayMode.Puzzle, 60000, 40000, 35000, 32000, 300, 30000, 0, 0)] + [InlineData( true, PlayMode.Platform, 6, 4, 2, 32000, 1, 30000, 10, 0)] + [InlineData( true, PlayMode.Platform, 6, 4, 2, 32000, 1, 35000, 4, 1)] + [InlineData( false, PlayMode.Platform, 6, 4, 2, 32000, 1, 30000, 10, 2)] + [InlineData( false, PlayMode.Platform, 6, 4, 2, 32000, 0, 34000, 10, 0)] + [InlineData( false, PlayMode.Platform, 6, 4, 2, 32000, 0, 32001, 10, 0)] + [InlineData( true, PlayMode.Platform, 6, 4, 2, 32000, 0, 32000, 10, 0)] + [InlineData( true, PlayMode.Stunts, 200, 400, 600, 32000, 650, 30000, 700, 2)] + [InlineData( true, PlayMode.Stunts, 200, 400, 600, 32000, 650, 30000, 650, 2)] + [InlineData( false, PlayMode.Stunts, 200, 400, 600, 32000, 650, 30000, 649, 2)] + [InlineData( false, PlayMode.Script, 200, 400, 600, 32000, 650, 30000, 602, 2)] + public void HasAuthorMedal_ReturnsCorrectBool(bool expected, PlayMode mode, int bronze, int silver, int gold, int authorTime, int authorScore, int timeMs, int stuntScore, int respawns) + { + // Arrange + var details = new AutosaveDetails( + Time: new TimeInt32(timeMs), + Score: stuntScore, + Respawns: respawns, + MapName: null, + MapEnvironment: null, + MapCar: null, + MapBronzeTime: new TimeInt32(bronze), + MapSilverTime: new TimeInt32(silver), + MapGoldTime: new TimeInt32(gold), + MapAuthorTime: new TimeInt32(authorTime), + MapAuthorScore: authorScore, + MapMode: mode); + + Assert.Equal(expected, actual: details.HasAuthorMedal); + } +} diff --git a/Tests/RandomizerTMF.Logic.Tests/Unit/Services/AdditionalDataTests.cs b/Tests/RandomizerTMF.Logic.Tests/Unit/Services/AdditionalDataTests.cs new file mode 100644 index 0000000..5a3fbf1 --- /dev/null +++ b/Tests/RandomizerTMF.Logic.Tests/Unit/Services/AdditionalDataTests.cs @@ -0,0 +1,30 @@ +using GBX.NET; +using Microsoft.Extensions.Logging; +using Moq; +using RandomizerTMF.Logic.Services; +using System.IO.Abstractions.TestingHelpers; + +namespace RandomizerTMF.Logic.Tests.Unit.Services; + +public class AdditionalDataTests +{ + [Fact] + public void Constructor() + { + var logger = new Mock(); + var fileSystem = new MockFileSystem(new Dictionary + { + { "OfficialBlocks.yml", new MockFileData("Rally:\n- SomeBlock") }, + { "MapSizes.yml", new MockFileData("Rally:\n- [1, 2, 3]") } + }); + var additionalData = new AdditionalData(logger.Object, fileSystem); + + var officialBlocks = additionalData.OfficialBlocks; + var mapSizes = additionalData.MapSizes; + + Assert.NotEmpty(officialBlocks); + Assert.NotEmpty(mapSizes); + Assert.Equal(expected: "SomeBlock", actual: officialBlocks["Rally"].First()); + Assert.Equal(expected: new Int3(1, 2, 3), actual: mapSizes["Rally"].First()); + } +} diff --git a/Tests/RandomizerTMF.Logic.Tests/Unit/Services/AutosaveScannerTests.cs b/Tests/RandomizerTMF.Logic.Tests/Unit/Services/AutosaveScannerTests.cs new file mode 100644 index 0000000..de6f1ca --- /dev/null +++ b/Tests/RandomizerTMF.Logic.Tests/Unit/Services/AutosaveScannerTests.cs @@ -0,0 +1,640 @@ +using GBX.NET; +using GBX.NET.Engines.Game; +using Microsoft.Extensions.Logging; +using Moq; +using RandomizerTMF.Logic.Exceptions; +using RandomizerTMF.Logic.Services; +using System.IO.Abstractions; +using System.IO.Abstractions.TestingHelpers; +using System.Reflection; +using TmEssentials; +using YamlDotNet.Core; + +namespace RandomizerTMF.Logic.Tests.Unit.Services; + +public class AutosaveScannerTests +{ + private readonly char slash = Path.DirectorySeparatorChar; + + [Fact] + public void HasAutosavesScanned_Get_DefaultEqualsFalse() + { + // Arrange + var events = Mock.Of(); + var watcher = Mock.Of(); + var gbx = Mock.Of(); + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(); + var filePathManager = new FilePathManager(config, fileSystem); + var logger = Mock.Of(); + + var service = new AutosaveScanner(events, watcher, filePathManager, config, fileSystem, gbx, logger); + + // Act & Assert + Assert.False(service.HasAutosavesScanned); + } + + [Fact] + public void HasAutosavesScanned_Set_EnablesRaisingEvents() + { + // Arrange + var events = Mock.Of(); + var watcher = Mock.Of(); + var gbx = Mock.Of(); + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(); + var filePathManager = new FilePathManager(config, fileSystem); + var logger = Mock.Of(); + + var service = new AutosaveScanner(events, watcher, filePathManager, config, fileSystem, gbx, logger); + + // Act + service.HasAutosavesScanned = true; + + // Assert + Assert.True(service.HasAutosavesScanned); + Assert.True(watcher.EnableRaisingEvents); + } + + [Fact] + public void UserDataDirectoryPathUpdated_UserDataDirectoryPathSet_SetsWatcherPath() + { + // Arrange + var events = Mock.Of(); + var watcher = Mock.Of(); + var gbx = Mock.Of(); + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(); + var filePathManager = new FilePathManager(config, fileSystem) + { + UserDataDirectoryPath = $"C:{slash}UserData" + }; + var logger = Mock.Of(); + + var expected = $"C:{slash}UserData{slash}Tracks{slash}Replays{slash}Autosaves"; + + var service = new AutosaveScanner(events, watcher, filePathManager, config, fileSystem, gbx, logger); + + // Act + service.UserDataDirectoryPathUpdated(); + + // Assert + Assert.Equal(expected, actual: watcher.Path); + } + + [Fact] + public void UserDataDirectoryPathUpdated_UserDataDirectoryPathNull_SetsWatcherPath() + { + // Arrange + var events = Mock.Of(); + var watcher = Mock.Of(); + var gbx = Mock.Of(); + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(); + var filePathManager = new FilePathManager(config, fileSystem); + var logger = Mock.Of(); + + var expected = ""; + + var service = new AutosaveScanner(events, watcher, filePathManager, config, fileSystem, gbx, logger); + + // Act + service.UserDataDirectoryPathUpdated(); + + // Assert + Assert.Equal(expected, actual: watcher.Path); + } + + [Fact] + public void ResetAutosaves_ResetsAutosaveData() + { + // Arrange + var events = Mock.Of(); + var watcher = Mock.Of(); + var gbx = Mock.Of(); + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(); + var filePathManager = new FilePathManager(config, fileSystem); + var logger = Mock.Of(); + + var scanner = new AutosaveScanner(events, watcher, filePathManager, config, fileSystem, gbx, logger); + scanner.AutosaveHeaders.TryAdd("uid", new AutosaveHeader("C:/Replay.Gbx", NodeInstance.Create())); + scanner.AutosaveHeaders.TryAdd("uid2", new AutosaveHeader("C:/Replay2.Gbx", NodeInstance.Create())); + scanner.AutosaveDetails.TryAdd("uid", new AutosaveDetails(TimeInt32.Zero, null, null, null, null, null, TimeInt32.Zero, TimeInt32.Zero, TimeInt32.Zero, TimeInt32.Zero, 0, null)); + scanner.AutosaveDetails.TryAdd("uid2", new AutosaveDetails(TimeInt32.Zero, null, null, null, null, null, TimeInt32.Zero, TimeInt32.Zero, TimeInt32.Zero, TimeInt32.Zero, 0, null)); + scanner.HasAutosavesScanned = true; + + // Act + scanner.ResetAutosaves(); + + // Assert + Assert.False(scanner.HasAutosavesScanned); + Assert.Empty(scanner.AutosaveHeaders); + Assert.Empty(scanner.AutosaveDetails); + } + + [Fact] + public void ScanAutosaves_NullAutosavesDirectoryPath_Throws() + { + // Arrange + var events = Mock.Of(); + var watcher = Mock.Of(); + var gbx = Mock.Of(); + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(); + var filePathManager = new FilePathManager(config, fileSystem); + var logger = Mock.Of(); + + var service = new AutosaveScanner(events, watcher, filePathManager, config, fileSystem, gbx, logger); + + // Act & Assert + Assert.Throws(() => service.ScanAutosaves()); + } + + [Fact] + public void ProcessAutosaveHeader_GbxIsNotReplay_ReturnsFalse() + { + // Arrange + var events = Mock.Of(); + var watcher = Mock.Of(); + var mockGbx = new Mock(); + mockGbx.Setup(x => x.ParseHeader(It.IsAny())).Returns(NodeInstance.Create()); + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(new Dictionary + { + { "Challenge.Gbx", new MockFileData(Array.Empty()) } + }); + var filePathManager = new FilePathManager(config, fileSystem); + var logger = Mock.Of(); + + var scanner = new AutosaveScanner(events, watcher, filePathManager, config, fileSystem, mockGbx.Object, logger); + + // Act + var result = scanner.ProcessAutosaveHeader("Challenge.Gbx"); + + // Assert + Assert.False(result); + } + + [Fact] + public void ProcessAutosaveHeader_ReplayHasNullMapInfo_ReturnsFalse() + { + // Arrange + var events = Mock.Of(); + var watcher = Mock.Of(); + var mockGbx = new Mock(); + mockGbx.Setup(x => x.ParseHeader(It.IsAny())).Returns(NodeInstance.Create()); + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(new Dictionary + { + { "Replay.Gbx", new MockFileData(Array.Empty()) } + }); + var filePathManager = new FilePathManager(config, fileSystem); + var logger = Mock.Of(); + + var scanner = new AutosaveScanner(events, watcher, filePathManager, config, fileSystem, mockGbx.Object, logger); + + // Act + var result = scanner.ProcessAutosaveHeader("Replay.Gbx"); + + // Assert + Assert.False(result); + } + + [Fact] + public void ProcessAutosaveHeader_ReplayMapUidAlreadyStored_ReturnsFalse() + { + // Arrange + var events = Mock.Of(); + var watcher = Mock.Of(); + + var replay = NodeInstance.Create(); + typeof(CGameCtnReplayRecord).GetField("mapInfo", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(replay, new Ident("uid", "Stadium", "bigbang1112")); + + var mockGbx = new Mock(); + mockGbx.Setup(x => x.ParseHeader(It.IsAny())).Returns(replay); + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(new Dictionary + { + { "Replay.Gbx", new MockFileData(Array.Empty()) } + }); + var filePathManager = new FilePathManager(config, fileSystem); + var logger = Mock.Of(); + + var scanner = new AutosaveScanner(events, watcher, filePathManager, config, fileSystem, mockGbx.Object, logger); + scanner.AutosaveHeaders.TryAdd("uid", new AutosaveHeader("Replay.Gbx", NodeInstance.Create())); + + // Act + var result = scanner.ProcessAutosaveHeader("Replay.Gbx"); + + // Assert + Assert.False(result); + } + + [Fact] + public void ProcessAutosaveHeader_ReplayMapUidFresh_ReturnsTrue() + { + // Arrange + var events = Mock.Of(); + var watcher = Mock.Of(); + + var replay = NodeInstance.Create(); + typeof(CGameCtnReplayRecord).GetField("mapInfo", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(replay, new Ident("uid", "Stadium", "bigbang1112")); + + var mockGbx = new Mock(); + mockGbx.Setup(x => x.ParseHeader(It.IsAny())).Returns(replay); + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(new Dictionary + { + { "Replay.Gbx", new MockFileData(Array.Empty()) } + }); + var filePathManager = new FilePathManager(config, fileSystem); + var logger = Mock.Of(); + + var scanner = new AutosaveScanner(events, watcher, filePathManager, config, fileSystem, mockGbx.Object, logger); + + // Act + var result = scanner.ProcessAutosaveHeader("Replay.Gbx"); + + // Assert + Assert.True(result); + Assert.True(scanner.AutosaveHeaders.ContainsKey("uid")); + } + + [Fact] + public void UpdateAutosaveDetail_AutosavesDirectoryPathIsNull_Throws() + { + // Arrange + var events = Mock.Of(); + var watcher = Mock.Of(); + var gbx = Mock.Of(); + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(); + var filePathManager = new FilePathManager(config, fileSystem); + var logger = Mock.Of(); + + var scanner = new AutosaveScanner(events, watcher, filePathManager, config, fileSystem, gbx, logger); + + // Act & Assert + Assert.Throws(() => scanner.UpdateAutosaveDetail("uid")); + } + + [Fact] + public void UpdateAutosaveDetail_AutosaveHeadersDoesNotContainFileName_Throws() + { + // Arrange + var events = Mock.Of(); + var watcher = Mock.Of(); + var gbx = Mock.Of(); + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(); + var filePathManager = new FilePathManager(config, fileSystem) + { + UserDataDirectoryPath = $"C:{slash}UserData" + }; + var logger = Mock.Of(); + + var scanner = new AutosaveScanner(events, watcher, filePathManager, config, fileSystem, gbx, logger); + + // Act & Assert + Assert.Throws(() => scanner.UpdateAutosaveDetail("uid")); + } + + [Fact] + public void UpdateAutosaveDetail_GbxIsNotReplay_AutosaveDetailNotAdded() + { + // Arrange + var events = Mock.Of(); + var watcher = Mock.Of(); + var mockGbx = new Mock(); + mockGbx.Setup(x => x.Parse(It.IsAny())).Returns(NodeInstance.Create()); + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(new Dictionary + { + { $"C:{slash}UserData{slash}Tracks{slash}Replays{slash}Autosaves{slash}Replay.Gbx", new MockFileData(Array.Empty()) } + }); + var filePathManager = new FilePathManager(config, fileSystem) + { + UserDataDirectoryPath = $"C:{slash}UserData" + }; + var logger = Mock.Of(); + + var scanner = new AutosaveScanner(events, watcher, filePathManager, config, fileSystem, mockGbx.Object, logger); + scanner.AutosaveHeaders.TryAdd("uid", new AutosaveHeader("Replay.Gbx", NodeInstance.Create())); + + // Act + scanner.UpdateAutosaveDetail("uid"); + + // Assert + Assert.Empty(scanner.AutosaveDetails); + } + + [Fact] + public void UpdateAutosaveDetail_ReplayHasNullTime_AutosaveDetailNotAdded() + { + // Arrange + var events = Mock.Of(); + var watcher = Mock.Of(); + var mockGbx = new Mock(); + var replay = NodeInstance.Create(); + mockGbx.Setup(x => x.Parse(It.IsAny())).Returns(replay); + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(new Dictionary + { + { $"C:{slash}UserData{slash}Tracks{slash}Replays{slash}Autosaves{slash}Replay.Gbx", new MockFileData(Array.Empty()) } + }); + var filePathManager = new FilePathManager(config, fileSystem) + { + UserDataDirectoryPath = $"C:{slash}UserData" + }; + var logger = Mock.Of(); + + var scanner = new AutosaveScanner(events, watcher, filePathManager, config, fileSystem, mockGbx.Object, logger); + scanner.AutosaveHeaders.TryAdd("uid", new AutosaveHeader("Replay.Gbx", NodeInstance.Create())); + + // Act + scanner.UpdateAutosaveDetail("uid"); + + // Assert + Assert.Null(replay.Time); // making sure test tests what it should even tho its a mock + Assert.Empty(scanner.AutosaveDetails); + } + + [Fact] + public void UpdateAutosaveDetail_ReplayHasNullChallenge_AutosaveDetailNotAdded() + { + // Arrange + var events = Mock.Of(); + var watcher = Mock.Of(); + + var replay = NodeInstance.Create(); + typeof(CGameCtnReplayRecord).GetField("time", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(replay, new TimeInt32(690)); + var mockGbx = new Mock(); + mockGbx.Setup(x => x.Parse(It.IsAny())).Returns(replay); + + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(new Dictionary + { + { $"C:{slash}UserData{slash}Tracks{slash}Replays{slash}Autosaves{slash}Replay.Gbx", new MockFileData(Array.Empty()) } + }); + var filePathManager = new FilePathManager(config, fileSystem) + { + UserDataDirectoryPath = $"C:{slash}UserData" + }; + var logger = Mock.Of(); + + var scanner = new AutosaveScanner(events, watcher, filePathManager, config, fileSystem, mockGbx.Object, logger); + scanner.AutosaveHeaders.TryAdd("uid", new AutosaveHeader("Replay.Gbx", NodeInstance.Create())); + + // Act + scanner.UpdateAutosaveDetail("uid"); + + // Assert + Assert.Null(replay.Challenge); // making sure test tests what it should even tho its a mock + Assert.Empty(scanner.AutosaveDetails); + } + + private AutosaveScanner Arrange_UpdateAutosaveDetail(CGameCtnChallenge map) + { + var events = Mock.Of(); + var watcher = Mock.Of(); + var replay = NodeInstance.Create(); + typeof(CGameCtnReplayRecord).GetField("time", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(replay, new TimeInt32(690)); + typeof(CGameCtnReplayRecord).GetField("challengeData", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(replay, Array.Empty()); + typeof(CGameCtnReplayRecord).GetField("challenge", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(replay, map); + var mockGbx = new Mock(); + mockGbx.Setup(x => x.Parse(It.IsAny())).Returns(replay); + + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(new Dictionary + { + { $"C:{slash}UserData{slash}Tracks{slash}Replays{slash}Autosaves{slash}Replay.Gbx", new MockFileData(Array.Empty()) } + }); + var filePathManager = new FilePathManager(config, fileSystem) + { + UserDataDirectoryPath = $"C:{slash}UserData" + }; + var logger = Mock.Of(); + + var scanner = new AutosaveScanner(events, watcher, filePathManager, config, fileSystem, mockGbx.Object, logger); + scanner.AutosaveHeaders.TryAdd("uid", new AutosaveHeader("Replay.Gbx", NodeInstance.Create())); + + return scanner; + } + + [Fact] + public void UpdateAutosaveDetail_ReplayMapBronzeTimeNull_Throws() + { + // Arrange + var map = NodeInstance.Create(); + map.TMObjective_AuthorTime = new TimeInt32(300); + map.TMObjective_GoldTime = new TimeInt32(400); + map.TMObjective_SilverTime = new TimeInt32(500); + map.AuthorScore = 69; + + var scanner = Arrange_UpdateAutosaveDetail(map); + + // Act & Assert + Assert.Throws(() => scanner.UpdateAutosaveDetail("uid")); + Assert.Null(map.TMObjective_BronzeTime); // making sure test tests what it should even tho its a mock + } + + [Fact] + public void UpdateAutosaveDetail_ReplayMapSilverTimeNull_Throws() + { + // Arrange + var map = NodeInstance.Create(); + map.TMObjective_AuthorTime = new TimeInt32(300); + map.TMObjective_GoldTime = new TimeInt32(400); + map.TMObjective_BronzeTime = new TimeInt32(500); + map.AuthorScore = 69; + + var scanner = Arrange_UpdateAutosaveDetail(map); + + // Act & Assert + Assert.Throws(() => scanner.UpdateAutosaveDetail("uid")); + Assert.Null(map.TMObjective_SilverTime); // making sure test tests what it should even tho its a mock + } + + [Fact] + public void UpdateAutosaveDetail_ReplayMapGoldTimeNull_Throws() + { + // Arrange + var map = NodeInstance.Create(); + map.TMObjective_AuthorTime = new TimeInt32(300); + map.TMObjective_SilverTime = new TimeInt32(400); + map.TMObjective_BronzeTime = new TimeInt32(500); + map.AuthorScore = 69; + + var scanner = Arrange_UpdateAutosaveDetail(map); + + // Act & Assert + Assert.Throws(() => scanner.UpdateAutosaveDetail("uid")); + Assert.Null(map.TMObjective_GoldTime); // making sure test tests what it should even tho its a mock + } + + [Fact] + public void UpdateAutosaveDetail_ReplayMapAuthorTimeNull_Throws() + { + // Arrange + var map = NodeInstance.Create(); + map.TMObjective_GoldTime = new TimeInt32(300); + map.TMObjective_SilverTime = new TimeInt32(400); + map.TMObjective_BronzeTime = new TimeInt32(500); + map.AuthorScore = 69; + + var scanner = Arrange_UpdateAutosaveDetail(map); + + // Act & Assert + Assert.Throws(() => scanner.UpdateAutosaveDetail("uid")); + Assert.Null(map.TMObjective_AuthorTime); // making sure test tests what it should even tho its a mock + } + + [Fact] + public void UpdateAutosaveDetail_ReplayMapAuthorScoreNull_Throws() + { + // Arrange + var map = NodeInstance.Create(); + map.TMObjective_AuthorTime = new TimeInt32(200); + map.TMObjective_GoldTime = new TimeInt32(300); + map.TMObjective_SilverTime = new TimeInt32(400); + map.TMObjective_BronzeTime = new TimeInt32(500); + + var scanner = Arrange_UpdateAutosaveDetail(map); + + // Act & Assert + Assert.Throws(() => scanner.UpdateAutosaveDetail("uid")); + Assert.Null(map.AuthorScore); // making sure test tests what it should even tho its a mock + } + + [Theory] + [InlineData("AlpineCar", "SnowCar")] + [InlineData("SnowCar", "SnowCar")] + [InlineData("American", "DesertCar")] + [InlineData("SpeedCar", "DesertCar")] + [InlineData("DesertCar", "DesertCar")] + [InlineData("Rally", "RallyCar")] + [InlineData("RallyCar", "RallyCar")] + [InlineData("SportCar", "IslandCar")] + [InlineData("IslandCar", "IslandCar")] + [InlineData("BayCar", "BayCar")] + [InlineData("CoastCar", "CoastCar")] + [InlineData("StadiumCar", "StadiumCar")] + public void UpdateAutosaveDetail_ExpectedMapCarName_AddsAutosaveDetailWithExpectedMapCarName(string givenCar, string expectedCar) + { + // Arrange + var map = NodeInstance.Create(); + map.TMObjective_AuthorTime = new TimeInt32(200); + map.TMObjective_GoldTime = new TimeInt32(300); + map.TMObjective_SilverTime = new TimeInt32(400); + map.TMObjective_BronzeTime = new TimeInt32(500); + map.AuthorScore = 69; + map.PlayerModel = new Ident(givenCar, "Vehicles", "Nadeo"); + + var scanner = Arrange_UpdateAutosaveDetail(map); + + // Act + scanner.UpdateAutosaveDetail("uid"); + + // Assert + Assert.NotEmpty(scanner.AutosaveDetails); + Assert.Equal(expectedCar, actual: scanner.AutosaveDetails["uid"].MapCar); + } + + [Fact] + public void UpdateAutosaveDetail_NoPlayerModel_AddsAutosaveDetailWithExpectedMapCarName() + { + // Arrange + var map = NodeInstance.Create(); + map.TMObjective_AuthorTime = new TimeInt32(200); + map.TMObjective_GoldTime = new TimeInt32(300); + map.TMObjective_SilverTime = new TimeInt32(400); + map.TMObjective_BronzeTime = new TimeInt32(500); + map.AuthorScore = 69; + map.Collection = "Stadium"; + + var scanner = Arrange_UpdateAutosaveDetail(map); + + // Act + scanner.UpdateAutosaveDetail("uid"); + + // Assert + Assert.NotEmpty(scanner.AutosaveDetails); + Assert.Equal(expected: "StadiumCar", actual: scanner.AutosaveDetails["uid"].MapCar); + } + + [Theory] + [InlineData(null, "StadiumCar")] + [InlineData("", "StadiumCar")] + public void UpdateAutosaveDetail_NoPlayerModelId_AddsAutosaveDetailWithExpectedMapCarName(string givenCar, string expectedCar) + { + // Arrange + var map = NodeInstance.Create(); + map.TMObjective_AuthorTime = new TimeInt32(200); + map.TMObjective_GoldTime = new TimeInt32(300); + map.TMObjective_SilverTime = new TimeInt32(400); + map.TMObjective_BronzeTime = new TimeInt32(500); + map.AuthorScore = 69; + map.Collection = "Stadium"; + map.PlayerModel = new Ident(givenCar, "Vehicles", "Nadeo"); + + var scanner = Arrange_UpdateAutosaveDetail(map); + + // Act + scanner.UpdateAutosaveDetail("uid"); + + // Assert + Assert.NotEmpty(scanner.AutosaveDetails); + Assert.Equal(expectedCar, actual: scanner.AutosaveDetails["uid"].MapCar); + } + + [Fact] + public void UpdateAutosaveDetail_HasGhost_AddsAutosaveDetailWithStuntsScoreAndRespawns() + { + // Arrange + var map = NodeInstance.Create(); + map.TMObjective_AuthorTime = new TimeInt32(200); + map.TMObjective_GoldTime = new TimeInt32(300); + map.TMObjective_SilverTime = new TimeInt32(400); + map.TMObjective_BronzeTime = new TimeInt32(500); + map.AuthorScore = 69; + map.Collection = "Stadium"; + map.PlayerModel = new Ident("StadiumCar", "Vehicles", "Nadeo"); + + var ghost = NodeInstance.Create(); + ghost.StuntScore = 69; + ghost.Respawns = 69; + + var events = Mock.Of(); + var watcher = Mock.Of(); + var replay = NodeInstance.Create(); + typeof(CGameCtnReplayRecord).GetField("time", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(replay, new TimeInt32(690)); + typeof(CGameCtnReplayRecord).GetField("challengeData", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(replay, Array.Empty()); + typeof(CGameCtnReplayRecord).GetField("challenge", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(replay, map); + typeof(CGameCtnReplayRecord).GetField("ghosts", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(replay, new CGameCtnGhost[] { ghost }); + var mockGbx = new Mock(); + mockGbx.Setup(x => x.Parse(It.IsAny())).Returns(replay); + + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(new Dictionary + { + { $"C:{slash}UserData{slash}Tracks{slash}Replays{slash}Autosaves{slash}Replay.Gbx", new MockFileData(Array.Empty()) } + }); + var filePathManager = new FilePathManager(config, fileSystem) + { + UserDataDirectoryPath = $"C:{slash}UserData" + }; + var logger = Mock.Of(); + + var scanner = new AutosaveScanner(events, watcher, filePathManager, config, fileSystem, mockGbx.Object, logger); + scanner.AutosaveHeaders.TryAdd("uid", new AutosaveHeader("Replay.Gbx", NodeInstance.Create())); + + // Act + scanner.UpdateAutosaveDetail("uid"); + + // Assert + Assert.NotEmpty(scanner.AutosaveDetails); + Assert.Equal(expected: ghost.StuntScore, actual: scanner.AutosaveDetails["uid"].Score); + Assert.Equal(expected: ghost.Respawns, actual: scanner.AutosaveDetails["uid"].Respawns); + } +} diff --git a/Tests/RandomizerTMF.Logic.Tests/Unit/Services/DiscordRichPresenceTests.cs b/Tests/RandomizerTMF.Logic.Tests/Unit/Services/DiscordRichPresenceTests.cs new file mode 100644 index 0000000..e890a3a --- /dev/null +++ b/Tests/RandomizerTMF.Logic.Tests/Unit/Services/DiscordRichPresenceTests.cs @@ -0,0 +1,25 @@ +using RandomizerTMF.Logic.Services; + +namespace RandomizerTMF.Logic.Tests.Unit.Services; + +public class DiscordRichPresenceTests +{ + [Fact] + public void BuildState_Singular() + { + var actual = DiscordRichPresence.BuildState(1, 1, 1); + + Assert.Equal(expected: $"1 AT, 1 gold, 1 skip", actual); + } + + [Theory] + [InlineData(2, 3, 4)] + [InlineData(42, 69, 420)] + [InlineData(5, -5, -8)] + public void BuildState_Plural(int atCount, int goldCount, int skipCount) + { + var actual = DiscordRichPresence.BuildState(atCount, goldCount, skipCount); + + Assert.Equal(expected: $"{atCount} ATs, {goldCount} golds, {skipCount} skips", actual); + } +} diff --git a/Tests/RandomizerTMF.Logic.Tests/Unit/Services/FilePathManagerTests.cs b/Tests/RandomizerTMF.Logic.Tests/Unit/Services/FilePathManagerTests.cs new file mode 100644 index 0000000..732e8e0 --- /dev/null +++ b/Tests/RandomizerTMF.Logic.Tests/Unit/Services/FilePathManagerTests.cs @@ -0,0 +1,279 @@ +using RandomizerTMF.Logic.Services; +using System.IO.Abstractions.TestingHelpers; + +namespace RandomizerTMF.Logic.Tests.Unit.Services; + +public class FilePathManagerTests +{ + private readonly char slash = Path.DirectorySeparatorChar; + private readonly string userDataPath = @$"C:{Path.DirectorySeparatorChar}Test{Path.DirectorySeparatorChar}UserData"; + + [Fact] + public void UserDataDirectoryPath_GetSetIsEqual() + { + // Arrange + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(); + var manager = new FilePathManager(config, fileSystem); + + // Act + manager.UserDataDirectoryPath = userDataPath; + + // Assert + Assert.Equal(userDataPath, manager.UserDataDirectoryPath); + } + + [Fact] + public void UserDataDirectoryPath_AutosavesDirectoryPathIsCorrect() + { + // Arrange + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(); + var manager = new FilePathManager(config, fileSystem); + + var expectedAutosavesPath = @$"C:{slash}Test{slash}UserData{slash}Tracks{slash}Replays{slash}Autosaves"; + + // Act + manager.UserDataDirectoryPath = userDataPath; + + // Assert + Assert.Equal(expectedAutosavesPath, manager.AutosavesDirectoryPath); + } + + [Fact] + public void UserDataDirectoryPath_DownloadedMapsDirectoryNull_DownloadedDirectoryPathIsCorrect() + { + // Arrange + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(); + var manager = new FilePathManager(config, fileSystem); + + var expectedDownloadedPath = @$"C:{slash}Test{slash}UserData{slash}Tracks{slash}Challenges{slash}Downloaded{slash}_RandomizerTMF"; + + // Act + manager.UserDataDirectoryPath = userDataPath; + + // Assert + Assert.Equal(expectedDownloadedPath, manager.DownloadedDirectoryPath); + } + + [Fact] + public void UserDataDirectoryPath_DownloadedMapsDirectoryEmpty_DownloadedDirectoryPathIsCorrect() + { + // Arrange + var config = new RandomizerConfig + { + DownloadedMapsDirectory = "" + }; + var fileSystem = new MockFileSystem(); + var manager = new FilePathManager(config, fileSystem); + + var expectedDownloadedPath = @$"C:{slash}Test{slash}UserData{slash}Tracks{slash}Challenges{slash}Downloaded{slash}_RandomizerTMF"; + + // Act + manager.UserDataDirectoryPath = userDataPath; + + // Assert + Assert.Equal(expectedDownloadedPath, manager.DownloadedDirectoryPath); + } + + [Fact] + public void UserDataDirectoryPath_DownloadedMapsDirectoryNotNull_DownloadedDirectoryPathIsCorrect() + { + // Arrange + var config = new RandomizerConfig + { + DownloadedMapsDirectory = "CustomDirectory" + }; + var fileSystem = new MockFileSystem(); + var manager = new FilePathManager(config, fileSystem); + + var expectedDownloadedPath = @$"C:{slash}Test{slash}UserData{slash}Tracks{slash}Challenges{slash}Downloaded{slash}CustomDirectory"; + + // Act + manager.UserDataDirectoryPath = userDataPath; + + // Assert + Assert.Equal(expectedDownloadedPath, manager.DownloadedDirectoryPath); + } + + [Fact] + public void UserDataDirectoryPath_UpdatedEvent() + { + // Arrange + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(); + var manager = new FilePathManager(config, fileSystem); + + var eventRaised = false; + manager.UserDataDirectoryPathUpdated += () => eventRaised = true; + + // Act + manager.UserDataDirectoryPath = userDataPath; + + // Assert + Assert.True(eventRaised); + } + + [Fact] + public void UserDataDirectoryPath_SetNull_SomeDirectoryPathsAreNull() + { + // Arrange + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(); + var manager = new FilePathManager(config, fileSystem); + + // Act + manager.UserDataDirectoryPath = null; + + // Assert + Assert.Null(manager.AutosavesDirectoryPath); + Assert.Null(manager.DownloadedDirectoryPath); + } + + [Fact] + public void SessionsDirectoryPath_EqualsSession() + { + // Assert + Assert.Equal(expected: "Sessions", actual: FilePathManager.SessionsDirectoryPath); + } + + [Theory] + [InlineData("myfile.txt", "myfile.txt")] + /* Linux has no problems with many of these + [InlineData("my:file.txt", "my_file.txt")] + [InlineData("my*file.txt", "my_file.txt")] + [InlineData("my\"file.txt", "my_file.txt")] + [InlineData("my/file.txt", "my_file.txt")]*/ + public void ClearFileName_ReturnsUsableString(string fileName, string expectedResult) + { + // Act + var result = FilePathManager.ClearFileName(fileName); + + // Assert + Assert.Equal(expectedResult, actual: result); + } + + [Fact] + public void UpdateGameDirectory_Valid_ReturnsValidResultAndPathsAreSet() + { + // Arrange + var gameDirectoryPath = @$"C:{slash}Test{slash}GameDirectory"; + var nadeoIniFilePath = @$"C:{slash}Test{slash}GameDirectory{slash}Nadeo.ini"; + var tmForeverExeFilePath = @$"C:{slash}Test{slash}GameDirectory{slash}TmForever.exe"; + var tmUnlimiterExeFilePath = @$"C:{slash}Test{slash}GameDirectory{slash}TmInfinity.exe"; + + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(new Dictionary + { + { nadeoIniFilePath, new MockFileData("") }, + { tmForeverExeFilePath, new MockFileData("binary") }, + { tmUnlimiterExeFilePath, new MockFileData("binary") } + }); + var manager = new FilePathManager(config, fileSystem); + + var myDocuments = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + var expectedUserDataDirectoryPath = $"{myDocuments}{slash}TmForever"; + + // Act + var result = manager.UpdateGameDirectory(gameDirectoryPath); + + // Assert + Assert.Null(result.NadeoIniException); + Assert.Null(result.TmForeverException); + Assert.Null(result.TmUnlimiterException); + Assert.Equal(expectedUserDataDirectoryPath, manager.UserDataDirectoryPath); + Assert.Equal(tmForeverExeFilePath, manager.TmForeverExeFilePath); + Assert.Equal(tmUnlimiterExeFilePath, manager.TmUnlimiterExeFilePath); + } + + [Fact] + public void UpdateGameDirectory_NadeoIniNotFound_ReturnsFileNotFoundExceptionAndPathsAreSet() + { + // Arrange + var gameDirectoryPath = @$"C:{slash}Test{slash}GameDirectory"; + var tmForeverExeFilePath = @$"C:{slash}Test{slash}GameDirectory{slash}TmForever.exe"; + var tmUnlimiterExeFilePath = @$"C:{slash}Test{slash}GameDirectory{slash}TmInfinity.exe"; + + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(new Dictionary + { + { tmForeverExeFilePath, new MockFileData("binary") }, + { tmUnlimiterExeFilePath, new MockFileData("binary") } + }); + var manager = new FilePathManager(config, fileSystem); + + // Act + var result = manager.UpdateGameDirectory(gameDirectoryPath); + + // Assert + Assert.IsType(result.NadeoIniException); + Assert.Null(manager.UserDataDirectoryPath); + Assert.Null(result.TmForeverException); + Assert.Null(result.TmUnlimiterException); + Assert.Equal(tmForeverExeFilePath, manager.TmForeverExeFilePath); + Assert.Equal(tmUnlimiterExeFilePath, manager.TmUnlimiterExeFilePath); + } + + [Fact] + public void UpdateGameDirectory_TmForeverNotFound_ReturnsFileNotFoundExceptionAndPathsAreSet() + { + // Arrange + var gameDirectoryPath = @$"C:{slash}Test{slash}GameDirectory"; + var nadeoIniFilePath = @$"C:{slash}Test{slash}GameDirectory{slash}Nadeo.ini"; + var tmUnlimiterExeFilePath = @$"C:{slash}Test{slash}GameDirectory{slash}TmInfinity.exe"; + + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(new Dictionary + { + { nadeoIniFilePath, new MockFileData("") }, + { tmUnlimiterExeFilePath, new MockFileData("binary") } + }); + var manager = new FilePathManager(config, fileSystem); + + var myDocuments = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + var expectedUserDataDirectoryPath = $"{myDocuments}{slash}TmForever"; + + // Act + var result = manager.UpdateGameDirectory(gameDirectoryPath); + + // Assert + Assert.IsType(result.TmForeverException); + Assert.Null(manager.TmForeverExeFilePath); + Assert.Null(result.NadeoIniException); + Assert.Null(result.TmUnlimiterException); + Assert.Equal(expectedUserDataDirectoryPath, manager.UserDataDirectoryPath); + Assert.Equal(tmUnlimiterExeFilePath, manager.TmUnlimiterExeFilePath); + } + + [Fact] + public void UpdateGameDirectory_TmUnlimiterNotFound_ReturnsFileNotFoundExceptionAndPathsAreSet() + { + // Arrange + var gameDirectoryPath = @$"C:{slash}Test{slash}GameDirectory"; + var nadeoIniFilePath = @$"C:{slash}Test{slash}GameDirectory{slash}Nadeo.ini"; + var tmForeverExeFilePath = @$"C:{slash}Test{slash}GameDirectory{slash}TmForever.exe"; + + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(new Dictionary + { + { nadeoIniFilePath, new MockFileData("") }, + { tmForeverExeFilePath, new MockFileData("binary") } + }); + var manager = new FilePathManager(config, fileSystem); + + var myDocuments = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + var expectedUserDataDirectoryPath = $"{myDocuments}{slash}TmForever"; + + // Act + var result = manager.UpdateGameDirectory(gameDirectoryPath); + + // Assert + Assert.IsType(result.TmUnlimiterException); + Assert.Null(manager.TmUnlimiterExeFilePath); + Assert.Null(result.NadeoIniException); + Assert.Null(result.TmForeverException); + Assert.Equal(expectedUserDataDirectoryPath, manager.UserDataDirectoryPath); + Assert.Equal(tmForeverExeFilePath, manager.TmForeverExeFilePath); + } +} diff --git a/Tests/RandomizerTMF.Logic.Tests/Unit/Services/GbxServiceTests.cs b/Tests/RandomizerTMF.Logic.Tests/Unit/Services/GbxServiceTests.cs new file mode 100644 index 0000000..12e1001 --- /dev/null +++ b/Tests/RandomizerTMF.Logic.Tests/Unit/Services/GbxServiceTests.cs @@ -0,0 +1,63 @@ +using GBX.NET.Engines.Game; +using RandomizerTMF.Logic.Services; + +namespace RandomizerTMF.Logic.Tests.Unit.Services; + +public class GbxServiceTests +{ + [Fact] + public async Task Parse_ValidMapGbx_ReturnsFullNode() + { + var mapFile = "Randomizer TMF test track.Challenge.Gbx"; + var gbx = new GbxService(); + using var ms = new MemoryStream(await File.ReadAllBytesAsync(Path.Combine("Files", mapFile))); + + var node = gbx.Parse(ms); + + Assert.NotNull(node); + Assert.IsType(node); + Assert.NotNull(((CGameCtnChallenge)node).Blocks); // Possible indicator of full map parse + } + + [Fact] + public async Task Parse_ValidReplayGbx_ReturnsFullNode() + { + var mapFile = "petrp_Randomizer TMF test track.Replay.gbx"; + var gbx = new GbxService(); + using var ms = new MemoryStream(await File.ReadAllBytesAsync(Path.Combine("Files", mapFile))); + + var node = gbx.Parse(ms); + + Assert.NotNull(node); + Assert.IsType(node); + Assert.NotNull(((CGameCtnReplayRecord)node).Ghosts); // Possible indicator of full replay parse + } + + [Fact] + public async Task ParseHeader_ValidMapGbx_ReturnsFullNode() + { + var mapFile = "Randomizer TMF test track.Challenge.Gbx"; + var gbx = new GbxService(); + using var ms = new MemoryStream(await File.ReadAllBytesAsync(Path.Combine("Files", mapFile))); + + var node = gbx.ParseHeader(ms); + + Assert.NotNull(node); + Assert.IsType(node); + Assert.Null(((CGameCtnChallenge)node).Blocks); // Possible indicator of map header parse + } + + [Fact] + public async Task ParseHeader_ValidReplayGbx_ReturnsFullNode() + { + var mapFile = "petrp_Randomizer TMF test track.Replay.gbx"; + var gbx = new GbxService(); + using var ms = new MemoryStream(await File.ReadAllBytesAsync(Path.Combine("Files", mapFile))); + + var node = gbx.ParseHeader(ms); + + Assert.NotNull(node); + Assert.IsType(node); + Assert.Null(((CGameCtnReplayRecord)node).Ghosts); // Possible indicator of replay header parse + } +} diff --git a/Tests/RandomizerTMF.Logic.Tests/Unit/Services/MapDownloaderTests.cs b/Tests/RandomizerTMF.Logic.Tests/Unit/Services/MapDownloaderTests.cs new file mode 100644 index 0000000..378d99e --- /dev/null +++ b/Tests/RandomizerTMF.Logic.Tests/Unit/Services/MapDownloaderTests.cs @@ -0,0 +1,766 @@ +using Moq; +using RandomizerTMF.Logic.Services; +using System.IO.Abstractions.TestingHelpers; +using Microsoft.Extensions.Logging; +using RichardSzalay.MockHttp; +using GBX.NET.Engines.Game; +using RandomizerTMF.Logic.Exceptions; +using System.Net; +using System.Diagnostics; + +namespace RandomizerTMF.Logic.Tests.Unit.Services; + +public class MapDownloaderTests +{ + private readonly char slash = Path.DirectorySeparatorChar; + + [Fact] + public async Task ValidateMapAsync_InvalidMapUnderMaxAttempts_DoesNotThrow() + { + // Arrange + var events = Mock.Of(); + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(); + var filePathManager = new FilePathManager(config, fileSystem); + var discord = Mock.Of(); + var mockValidator = new Mock(); + var expectedInvalidBlock = default(string); + mockValidator.Setup(x => x.ValidateMap(It.IsAny(), out expectedInvalidBlock)) + .Returns(true); + var random = Mock.Of(); + var delay = Mock.Of(); + var logger = Mock.Of(); + var http = Mock.Of(); + var gbxService = Mock.Of(); + + var map = NodeInstance.Create(); + var cts = new CancellationTokenSource(); + + var mapDownloader = new MapDownloader(events, config, filePathManager, discord, mockValidator.Object, http, random, delay, fileSystem, gbxService, logger); + + // Act + var exception = await Record.ExceptionAsync(async () => await mapDownloader.ValidateMapAsync(map, cts.Token)); + + // Assert + Assert.Null(exception); + } + + [Theory] + [InlineData(null)] + [InlineData("LolBlock")] + public async Task ValidateMapAsync_InvalidMapUnderMaxAttempts_ThrowsMapValidationException(string? expectedInvalidBlock) + { + // Arrange + var events = Mock.Of(); + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(); + var filePathManager = new FilePathManager(config, fileSystem); + var discord = Mock.Of(); + var mockValidator = new Mock(); + mockValidator.Setup(x => x.ValidateMap(It.IsAny(), out expectedInvalidBlock)) + .Returns(false); + var random = Mock.Of(); + var delay = Mock.Of(); + var logger = Mock.Of(); + var http = Mock.Of(); + var gbxService = Mock.Of(); + + var map = NodeInstance.Create(); + var cts = new CancellationTokenSource(); + + var mapDownloader = new MapDownloader(events, config, filePathManager, discord, mockValidator.Object, http, random, delay, fileSystem, gbxService, logger); + + // Act & Assert + await Assert.ThrowsAsync(async () => await mapDownloader.ValidateMapAsync(map, cts.Token)); + } + + [Theory] + [InlineData(null, 8)] + [InlineData("LolBlock", 8)] + [InlineData(null, 9)] + [InlineData("LolBlock", 9)] + public async Task ValidateMapAsync_InvalidMapJustBeforeMaxAttempts_ThrowsMapValidationException(string? expectedInvalidBlock, int attempts) + { + // Arrange + var events = Mock.Of(); + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(); + var filePathManager = new FilePathManager(config, fileSystem); + var discord = Mock.Of(); + var mockValidator = new Mock(); + mockValidator.Setup(x => x.ValidateMap(It.IsAny(), out expectedInvalidBlock)) + .Returns(false); + var random = Mock.Of(); + var delay = Mock.Of(); + var logger = Mock.Of(); + var http = Mock.Of(); + var gbxService = Mock.Of(); + + var map = NodeInstance.Create(); + var cts = new CancellationTokenSource(); + + var mapDownloader = new MapDownloader(events, config, filePathManager, discord, mockValidator.Object, http, random, delay, fileSystem, gbxService, logger); + + // Act & Assert + for (var i = 0; i < attempts; i++) + { + await Assert.ThrowsAsync(async () => await mapDownloader.ValidateMapAsync(map, cts.Token)); + } + } + + [Theory] + [InlineData(null)] + [InlineData("LolBlock")] + public async Task ValidateMapAsync_InvalidMapAtMaxAttempts_ThrowsInvalidSessionException(string? expectedInvalidBlock) + { + // Arrange + var events = Mock.Of(); + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(); + var filePathManager = new FilePathManager(config, fileSystem); + var discord = Mock.Of(); + var mockValidator = new Mock(); + mockValidator.Setup(x => x.ValidateMap(It.IsAny(), out expectedInvalidBlock)) + .Returns(false); + var random = Mock.Of(); + var delay = Mock.Of(); + var logger = Mock.Of(); + var http = Mock.Of(); + var gbxService = Mock.Of(); + + var map = NodeInstance.Create(); + var cts = new CancellationTokenSource(); + + var mapDownloader = new MapDownloader(events, config, filePathManager, discord, mockValidator.Object, http, random, delay, fileSystem, gbxService, logger); + + // Act & Assert + for (var i = 0; i < 9; i++) + { + try + { + await mapDownloader.ValidateMapAsync(map, cts.Token); + } + catch + { + + } + } + + await Assert.ThrowsAsync(async () => await mapDownloader.ValidateMapAsync(map, cts.Token)); + } + + [Fact] + public async Task DownloadMapByTrackIdAsync_Success_UrlIsValid() + { + // Arrange + var events = Mock.Of(); + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(); + var filePathManager = new FilePathManager(config, fileSystem); + var discord = Mock.Of(); + var validator = Mock.Of(); + var random = Mock.Of(); + var delay = Mock.Of(); + var logger = Mock.Of(); + var gbxService = Mock.Of(); + + var expectedUrl = "https://tmuf.exchange/trackgbx/69"; + + var mockHandler = new MockHttpMessageHandler(); + mockHandler.When(expectedUrl).Respond(HttpStatusCode.OK); + var http = new HttpClient(mockHandler); + + var mapDownloader = new MapDownloader(events, config, filePathManager, discord, validator, http, random, delay, fileSystem, gbxService, logger); + + var cts = new CancellationTokenSource(); + + // Act + var response = await mapDownloader.DownloadMapByTrackIdAsync("tmuf.exchange", "69", cts.Token); + + // Assert + Assert.Equal(expectedUrl, actual: response.RequestMessage?.RequestUri?.ToString()); + } + + [Fact] + public async Task DownloadMapByTrackIdAsync_NotSuccess_Throws() + { + // Arrange + var events = Mock.Of(); + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(); + var filePathManager = new FilePathManager(config, fileSystem); + var discord = Mock.Of(); + var validator = Mock.Of(); + var random = Mock.Of(); + var delay = Mock.Of(); + var logger = Mock.Of(); + var gbxService = Mock.Of(); + + var mockHandler = new MockHttpMessageHandler(); + mockHandler.When("https://tmuf.exchange/trackgbx/69").Respond(HttpStatusCode.NotFound); + var http = new HttpClient(mockHandler); + + var mapDownloader = new MapDownloader(events, config, filePathManager, discord, validator, http, random, delay, fileSystem, gbxService, logger); + + var cts = new CancellationTokenSource(); + + // Act & Assert + await Assert.ThrowsAsync(async () => await mapDownloader.DownloadMapByTrackIdAsync("tmuf.exchange", "69", cts.Token)); + } + + [Fact] + public void GetTrackIdFromUri_ReturnsCorrectSegment() + { + // Arrange + var events = Mock.Of(); + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(); + var filePathManager = new FilePathManager(config, fileSystem); + var discord = Mock.Of(); + var validator = Mock.Of(); + var random = Mock.Of(); + var delay = Mock.Of(); + var logger = Mock.Of(); + var http = Mock.Of(); + var gbxService = Mock.Of(); + + var mapDownloader = new MapDownloader(events, config, filePathManager, discord, validator, http, random, delay, fileSystem, gbxService, logger); + + var uri = new Uri("https://tmuf.exchange/trackshow/69"); + + // Act + var result = mapDownloader.GetTrackIdFromUri(uri); + + // Assert + Assert.Equal(expected: "69", actual: result); + } + + [Fact] + public void GetRequestUriFromResponse_AllCorrect_ReturnsUri() + { + // Arrange + var events = Mock.Of(); + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(); + var filePathManager = new FilePathManager(config, fileSystem); + var discord = Mock.Of(); + var validator = Mock.Of(); + var random = Mock.Of(); + var delay = Mock.Of(); + var logger = Mock.Of(); + var http = Mock.Of(); + var gbxService = Mock.Of(); + + var mapDownloader = new MapDownloader(events, config, filePathManager, discord, validator, http, random, delay, fileSystem, gbxService, logger); + + var uri = new Uri("https://tmuf.exchange/trackshow/69"); + + var response = new HttpResponseMessage + { + RequestMessage = new HttpRequestMessage + { + RequestUri = uri + } + }; + + // Act + var result = mapDownloader.GetRequestUriFromResponse(response); + + // Assert + Assert.Equal(expected: uri, actual: result); + } + + [Fact] + public void GetRequestUriFromResponse_NullRequestUri_ReturnsNull() + { + // Arrange + var events = Mock.Of(); + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(); + var filePathManager = new FilePathManager(config, fileSystem); + var discord = Mock.Of(); + var validator = Mock.Of(); + var random = Mock.Of(); + var delay = Mock.Of(); + var logger = Mock.Of(); + var http = Mock.Of(); + var gbxService = Mock.Of(); + + var mapDownloader = new MapDownloader(events, config, filePathManager, discord, validator, http, random, delay, fileSystem, gbxService, logger); + + var response = new HttpResponseMessage + { + RequestMessage = new HttpRequestMessage() + }; + + // Act + var result = mapDownloader.GetRequestUriFromResponse(response); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetRequestUriFromResponse_NullRequestMessage_ReturnsNull() + { + // Arrange + var events = Mock.Of(); + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(); + var filePathManager = new FilePathManager(config, fileSystem); + var discord = Mock.Of(); + var validator = Mock.Of(); + var random = Mock.Of(); + var delay = Mock.Of(); + var logger = Mock.Of(); + var http = Mock.Of(); + var gbxService = Mock.Of(); + + var mapDownloader = new MapDownloader(events, config, filePathManager, discord, validator, http, random, delay, fileSystem, gbxService, logger); + + var response = new HttpResponseMessage(); + + // Act + var result = mapDownloader.GetRequestUriFromResponse(response); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task FetchRandomTrackAsync_MapNotFound_Throws() + { + // Arrange + var events = Mock.Of(); + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(); + var filePathManager = new FilePathManager(config, fileSystem); + var discord = Mock.Of(); + var validator = Mock.Of(); + var random = Mock.Of(); + var delay = Mock.Of(); + var logger = Mock.Of(); + var gbxService = Mock.Of(); + + var mockHandler = new MockHttpMessageHandler(); + mockHandler.When("https://tmnf.exchange/trackrandom?primarytype=0&authortimemax=180000").Respond(HttpStatusCode.NotFound); + var http = new HttpClient(mockHandler); + + var mapDownloader = new MapDownloader(events, config, filePathManager, discord, validator, http, random, delay, fileSystem, gbxService, logger); + + var cts = new CancellationTokenSource(); + + // Act & Assert + await Assert.ThrowsAsync(async () => await mapDownloader.FetchRandomTrackAsync(cts.Token)); + } + + [Fact] + public async Task FetchRandomTrackAsync_Success_ReturnsResponse() + { + // Arrange + var events = Mock.Of(); + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(); + var filePathManager = new FilePathManager(config, fileSystem); + var discord = Mock.Of(); + var validator = Mock.Of(); + var random = Mock.Of(); + var delay = Mock.Of(); + var logger = Mock.Of(); + var gbxService = Mock.Of(); + + var mockHandler = new MockHttpMessageHandler(); + mockHandler.When("https://tmnf.exchange/trackrandom?primarytype=0&authortimemax=180000").Respond(HttpStatusCode.OK); + var http = new HttpClient(mockHandler); + + var mapDownloader = new MapDownloader(events, config, filePathManager, discord, validator, http, random, delay, fileSystem, gbxService, logger); + + var cts = new CancellationTokenSource(); + + // Act + var response = await mapDownloader.FetchRandomTrackAsync(cts.Token); + + // Assert + Assert.Equal(expected: HttpStatusCode.OK, actual: response.StatusCode); + } + + [Fact] + public async Task SaveMapAsync_DownloadedDirectoryPathIsNull_Unreachable() + { + // Arrange + var events = Mock.Of(); + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(); + var filePathManager = new FilePathManager(config, fileSystem); + var discord = Mock.Of(); + var validator = Mock.Of(); + var random = Mock.Of(); + var delay = Mock.Of(); + var logger = Mock.Of(); + var gbxService = Mock.Of(); + + var mockHandler = new MockHttpMessageHandler(); + var http = new HttpClient(mockHandler); + + var mapDownloader = new MapDownloader(events, config, filePathManager, discord, validator, http, random, delay, fileSystem, gbxService, logger); + + var cts = new CancellationTokenSource(); + + var response = new HttpResponseMessage + { + RequestMessage = new HttpRequestMessage + { + RequestUri = new Uri("https://tmuf.exchange/trackshow/69") + } + }; + + // Act & Assert + await Assert.ThrowsAsync(async () => await mapDownloader.SaveMapAsync(response, "xdd", cts.Token)); + } + + [Fact] + public async Task SaveMapAsync_FileNameAvailable_SavesMapAndReturnsPath() + { + // Arrange + var events = Mock.Of(); + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(); + var filePathManager = new FilePathManager(config, fileSystem) + { + UserDataDirectoryPath = $"C:{slash}UserData" + }; + var discord = Mock.Of(); + var validator = Mock.Of(); + var random = Mock.Of(); + var delay = Mock.Of(); + var logger = Mock.Of(); + var gbxService = Mock.Of(); + + var mockHandler = new MockHttpMessageHandler(); + var http = new HttpClient(mockHandler); + + var mapDownloader = new MapDownloader(events, config, filePathManager, discord, validator, http, random, delay, fileSystem, gbxService, logger); + + var cts = new CancellationTokenSource(); + + var expectedData = " "u8.ToArray(); + + var content = new ByteArrayContent(expectedData); + content.Headers.ContentDisposition = new System.Net.Http.Headers.ContentDispositionHeaderValue("attachment") + { + FileName = "SomeMap.Challenge.Gbx" + }; + + var response = new HttpResponseMessage + { + RequestMessage = new HttpRequestMessage + { + RequestUri = new Uri("https://tmuf.exchange/trackshow/69") + }, + Content = content + }; + + var expectedSaveFilePath = Path.Combine("C:", "UserData", "Tracks", "Challenges", "Downloaded", "_RandomizerTMF", "SomeMap.Challenge.Gbx"); + + // Act + var path = await mapDownloader.SaveMapAsync(response, "xdd", cts.Token); + + // Assert + Assert.True(fileSystem.File.Exists(expectedSaveFilePath)); + Assert.Equal(expectedData, actual: fileSystem.File.ReadAllBytes(expectedSaveFilePath)); + Assert.Equal(expectedSaveFilePath, actual: path); + } + + [Fact] + public async Task SaveMapAsync_FileNameNull_SavesMapAndReturnsPath() + { + // Arrange + var events = Mock.Of(); + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(); + var filePathManager = new FilePathManager(config, fileSystem) + { + UserDataDirectoryPath = $"C:{slash}UserData" + }; + var discord = Mock.Of(); + var validator = Mock.Of(); + var random = Mock.Of(); + var delay = Mock.Of(); + var logger = Mock.Of(); + var gbxService = Mock.Of(); + + var mockHandler = new MockHttpMessageHandler(); + var http = new HttpClient(mockHandler); + + var mapDownloader = new MapDownloader(events, config, filePathManager, discord, validator, http, random, delay, fileSystem, gbxService, logger); + + var cts = new CancellationTokenSource(); + + var expectedData = " "u8.ToArray(); + + var content = new ByteArrayContent(expectedData); + content.Headers.ContentDisposition = new System.Net.Http.Headers.ContentDispositionHeaderValue("attachment") + { + FileName = null + }; + + var response = new HttpResponseMessage + { + RequestMessage = new HttpRequestMessage + { + RequestUri = new Uri("https://tmuf.exchange/trackshow/69") + }, + Content = content + }; + + var expectedSaveFilePath = Path.Combine("C:", "UserData", "Tracks", "Challenges", "Downloaded", "_RandomizerTMF", "xdd.Challenge.Gbx"); + + // Act + var path = await mapDownloader.SaveMapAsync(response, "xdd", cts.Token); + + // Assert + Assert.True(fileSystem.File.Exists(expectedSaveFilePath)); + Assert.Equal(expectedData, actual: fileSystem.File.ReadAllBytes(expectedSaveFilePath)); + Assert.Equal(expectedSaveFilePath, actual: path); + } + + [Fact] + public async Task SaveMapAsync_ContentDispositionNull_SavesMapAndReturnsPath() + { + // Arrange + var events = Mock.Of(); + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(); + var filePathManager = new FilePathManager(config, fileSystem) + { + UserDataDirectoryPath = $"C:{slash}UserData" + }; + var discord = Mock.Of(); + var validator = Mock.Of(); + var random = Mock.Of(); + var delay = Mock.Of(); + var logger = Mock.Of(); + var gbxService = Mock.Of(); + + var mockHandler = new MockHttpMessageHandler(); + var http = new HttpClient(mockHandler); + + var mapDownloader = new MapDownloader(events, config, filePathManager, discord, validator, http, random, delay, fileSystem, gbxService, logger); + + var cts = new CancellationTokenSource(); + + var expectedData = " "u8.ToArray(); + + var response = new HttpResponseMessage + { + RequestMessage = new HttpRequestMessage + { + RequestUri = new Uri("https://tmuf.exchange/trackshow/69") + }, + Content = new ByteArrayContent(expectedData) + }; + + var expectedSaveFilePath = Path.Combine("C:", "UserData", "Tracks", "Challenges", "Downloaded", "_RandomizerTMF", "xdd.Challenge.Gbx"); + + // Act + var path = await mapDownloader.SaveMapAsync(response, "xdd", cts.Token); + + // Assert + Assert.True(fileSystem.File.Exists(expectedSaveFilePath)); + Assert.Equal(expectedData, actual: fileSystem.File.ReadAllBytes(expectedSaveFilePath)); + Assert.Equal(expectedSaveFilePath, actual: path); + } + + [Fact] + public async Task PrepareNewMapAsync_RequestUriIsNull_ReturnsFalse() + { + // Arrange + var events = Mock.Of(); + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(); + var filePathManager = new FilePathManager(config, fileSystem) + { + UserDataDirectoryPath = $"C:{slash}UserData" + }; + var discord = Mock.Of(); + var validator = Mock.Of(); + var random = Mock.Of(); + var delay = Mock.Of(); + var logger = Mock.Of(); + var game = Mock.Of(); + var gbxService = Mock.Of(); + + var response = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK + }; + + var mockHandler = new MockHttpMessageHandler(); + mockHandler.When("https://tmnf.exchange/trackrandom?primarytype=0&authortimemax=180000").Respond(req => + { + req.RequestUri = null; + return response; + }); + var http = new HttpClient(mockHandler); + + var mapDownloader = new MapDownloader(events, config, filePathManager, discord, validator, http, random, delay, fileSystem, gbxService, logger); + + var cts = new CancellationTokenSource(); + + var session = new Session(events, mapDownloader, validator, config, game, logger, fileSystem); + + // Act + var result = await mapDownloader.PrepareNewMapAsync(session, cts.Token); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task PrepareNewMapAsync_MapIsInvalid_Throws() + { + // Arrange + var events = Mock.Of(); + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(); + var filePathManager = new FilePathManager(config, fileSystem) + { + UserDataDirectoryPath = $"C:{slash}UserData" + }; + var discord = Mock.Of(); + var validator = Mock.Of(); + var random = Mock.Of(); + var delay = Mock.Of(); + var logger = Mock.Of(); + var game = Mock.Of(); + var gbxService = Mock.Of(); + + var response = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK + }; + + var mockHandler = new MockHttpMessageHandler(); + mockHandler.When("https://tmnf.exchange/trackrandom?primarytype=0&authortimemax=180000").Respond(req => + { + req.RequestUri = new("https://tmnf.exchange/trackshow/69"); + return response; + }); + mockHandler.When("https://tmnf.exchange/trackgbx/69").Respond(new ByteArrayContent("E*"u8.ToArray())); + var http = new HttpClient(mockHandler); + + var mapDownloader = new MapDownloader(events, config, filePathManager, discord, validator, http, random, delay, fileSystem, gbxService, logger); + + var cts = new CancellationTokenSource(); + + var session = new Session(events, mapDownloader, validator, config, game, logger, fileSystem); + + // Act + var result = await mapDownloader.PrepareNewMapAsync(session, cts.Token); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task PrepareNewMapAsync_MapIsNull_ReturnsFalse() + { + // Arrange + var events = Mock.Of(); + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(); + var filePathManager = new FilePathManager(config, fileSystem) + { + UserDataDirectoryPath = $"C:{slash}UserData" + }; + var discord = Mock.Of(); + var validator = Mock.Of(); + var random = Mock.Of(); + var delay = Mock.Of(); + var logger = Mock.Of(); + var game = Mock.Of(); + + var map = NodeInstance.Create(); + var mockGbxService = new Mock(); + mockGbxService.Setup(x => x.Parse(It.IsAny())).Returns(map); + + var response = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK + }; + + var mockHandler = new MockHttpMessageHandler(); + mockHandler.When("https://tmnf.exchange/trackrandom?primarytype=0&authortimemax=180000").Respond(req => + { + req.RequestUri = new("https://tmnf.exchange/trackshow/69"); + return response; + }); + mockHandler.When("https://tmnf.exchange/trackgbx/69").Respond(new ByteArrayContent("E*"u8.ToArray())); + var http = new HttpClient(mockHandler); + + var mapDownloader = new MapDownloader(events, config, filePathManager, discord, validator, http, random, delay, fileSystem, mockGbxService.Object, logger); + + var cts = new CancellationTokenSource(); + + var session = new Session(events, mapDownloader, validator, config, game, logger, fileSystem); + + // Act & Assert + await Assert.ThrowsAsync(async () => await mapDownloader.PrepareNewMapAsync(session, cts.Token)); + } + + [Fact] + public async Task PrepareNewMapAsync_Valid_SavesMapAndReturnsTrue() + { + // Arrange + var events = Mock.Of(); + var config = new RandomizerConfig(); + var fileSystem = new MockFileSystem(); + var filePathManager = new FilePathManager(config, fileSystem) + { + UserDataDirectoryPath = $"C:{slash}UserData" + }; + var discord = Mock.Of(); + + var mockValidator = new Mock(); + var invalidBlock = default(string); + mockValidator.Setup(x => x.ValidateMap(It.IsAny(), out invalidBlock)).Returns(true); + var validator = mockValidator.Object; + + var random = Mock.Of(); + var delay = Mock.Of(); + var logger = Mock.Of(); + var game = Mock.Of(); + + var map = NodeInstance.Create(); + var mockGbxService = new Mock(); + mockGbxService.Setup(x => x.Parse(It.IsAny())).Returns(map); + + var response = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK + }; + + var mockHandler = new MockHttpMessageHandler(); + mockHandler.When("https://tmnf.exchange/trackrandom?primarytype=0&authortimemax=180000").Respond(req => + { + req.RequestUri = new("https://tmnf.exchange/trackshow/69"); + return response; + }); + mockHandler.When("https://tmnf.exchange/trackgbx/69").Respond(new ByteArrayContent("E*"u8.ToArray())); + var http = new HttpClient(mockHandler); + + var mapDownloader = new MapDownloader(events, config, filePathManager, discord, validator, http, random, delay, fileSystem, mockGbxService.Object, logger); + + var cts = new CancellationTokenSource(); + + var session = new Session(events, mapDownloader, validator, config, game, logger, fileSystem); + + // Act + var result = await mapDownloader.PrepareNewMapAsync(session, cts.Token); + + // Assert + Assert.True(result); + + // TODO: Check for saved stuff + } +} diff --git a/Tests/RandomizerTMF.Logic.Tests/Unit/Services/NadeoIniTests.cs b/Tests/RandomizerTMF.Logic.Tests/Unit/Services/NadeoIniTests.cs new file mode 100644 index 0000000..6ec4f68 --- /dev/null +++ b/Tests/RandomizerTMF.Logic.Tests/Unit/Services/NadeoIniTests.cs @@ -0,0 +1,54 @@ +using System.IO.Abstractions.TestingHelpers; + +namespace RandomizerTMF.Logic.Tests.Unit.Services; + +public class NadeoIniTests +{ + [Fact] + public void Parse_UserSubDirIsSet() + { + // Arrange + string nadeoIniFilePath = "Nadeo.ini"; + string iniContent = @" +# Comment +; Comment +[Section] +Key=Value +UserSubDir=CustomUserDataDirectory +"; + var fileSystem = new MockFileSystem(new Dictionary + { + { nadeoIniFilePath, new MockFileData(iniContent) } + }); + + + // Act + var result = NadeoIni.Parse(nadeoIniFilePath, fileSystem); + + // Assert + Assert.Equal(expected: "CustomUserDataDirectory", actual: result.UserSubDir); + } + + [Fact] + public void TestParse_DefaultUserSubDir() + { + // Arrange + string nadeoIniFilePath = "Nadeo.ini"; + string iniContent = @" +# Comment +; Comment +[Section] +Key=Value +"; + var fileSystem = new MockFileSystem(new Dictionary + { + { nadeoIniFilePath, new MockFileData(iniContent) } + }); + + // Act + var result = NadeoIni.Parse(nadeoIniFilePath, fileSystem); + + // Assert + Assert.Equal(expected: "TmForever", actual: result.UserSubDir); + } +} diff --git a/Tests/RandomizerTMF.Logic.Tests/Unit/Services/RandomizerConfigTests.cs b/Tests/RandomizerTMF.Logic.Tests/Unit/Services/RandomizerConfigTests.cs new file mode 100644 index 0000000..4dc9b94 --- /dev/null +++ b/Tests/RandomizerTMF.Logic.Tests/Unit/Services/RandomizerConfigTests.cs @@ -0,0 +1,74 @@ +using Microsoft.Extensions.Logging; +using Moq; +using RandomizerTMF.Logic.Services; +using System.IO.Abstractions.TestingHelpers; + +namespace RandomizerTMF.Logic.Tests.Unit.Services; + +public class RandomizerConfigTests +{ + [Fact] + public void GetOrCreate_NoConfig_Create() + { + // Arrange + var mockLogger = new Mock(); + var fileSystem = new MockFileSystem(); + var defaultConfig = new RandomizerConfig(); + + // Act + var config = RandomizerConfig.GetOrCreate(mockLogger.Object, fileSystem); + + // Assert + Assert.True(fileSystem.FileExists("Config.yml")); + Assert.Equal(expected: defaultConfig.GameDirectory, actual: config.GameDirectory); + Assert.Equal(expected: defaultConfig.DownloadedMapsDirectory, actual: config.DownloadedMapsDirectory); + Assert.Equal(expected: defaultConfig.ReplayParseFailRetries, actual: config.ReplayParseFailRetries); + Assert.Equal(expected: defaultConfig.ReplayParseFailDelayMs, actual: config.ReplayParseFailDelayMs); + Assert.Equal(expected: defaultConfig.ReplayFileFormat, actual: config.ReplayFileFormat); + } + + [Fact] + public void GetOrCreate_ExistingConfig_Read() + { + // Arrange + var mockLogger = new Mock(); + var fileSystem = new MockFileSystem(new Dictionary + { + { "Config.yml", new MockFileData(@"GameDirectory: C:\GameDirectory") } + }); + + // Act + var config = RandomizerConfig.GetOrCreate(mockLogger.Object, fileSystem); + + // Assert + Assert.True(fileSystem.FileExists("Config.yml")); + Assert.Equal(expected: @"C:\GameDirectory", actual: config.GameDirectory); + } + + [Theory] + [InlineData(@"GameDirectory: [C:\GameDirectory")] + [InlineData("ReplayParseFailRetries: number")] + public void GetOrCreate_CorruptedConfig_Overwrite(string configContent) + { + // Arrange + var mockLogger = new Mock(); + var fileSystem = new MockFileSystem(new Dictionary + { + { "Config.yml", new MockFileData(configContent) } + }); + var lastWriteTime = fileSystem.File.GetLastWriteTime("Config.yml"); + var defaultConfig = new RandomizerConfig(); + + // Act + var config = RandomizerConfig.GetOrCreate(mockLogger.Object, fileSystem); + + // Assert + Assert.True(fileSystem.FileExists("Config.yml")); + Assert.NotEqual(expected: lastWriteTime, fileSystem.File.GetLastWriteTime("Config.yml")); + Assert.Equal(expected: defaultConfig.GameDirectory, actual: config.GameDirectory); + Assert.Equal(expected: defaultConfig.DownloadedMapsDirectory, actual: config.DownloadedMapsDirectory); + Assert.Equal(expected: defaultConfig.ReplayParseFailRetries, actual: config.ReplayParseFailRetries); + Assert.Equal(expected: defaultConfig.ReplayParseFailDelayMs, actual: config.ReplayParseFailDelayMs); + Assert.Equal(expected: defaultConfig.ReplayFileFormat, actual: config.ReplayFileFormat); + } +} diff --git a/Tests/RandomizerTMF.Logic.Tests/Unit/Services/RandomizerEventsTests.cs b/Tests/RandomizerTMF.Logic.Tests/Unit/Services/RandomizerEventsTests.cs new file mode 100644 index 0000000..dac4207 --- /dev/null +++ b/Tests/RandomizerTMF.Logic.Tests/Unit/Services/RandomizerEventsTests.cs @@ -0,0 +1,151 @@ +using GBX.NET.Engines.Game; +using Microsoft.Extensions.Logging; +using Moq; +using RandomizerTMF.Logic.Services; + +namespace RandomizerTMF.Logic.Tests.Unit.Services; + +public class RandomizerEventsTests +{ + [Fact] + public void OnStatus_InvokesEvent() + { + // Arrange + var config = new RandomizerConfig(); + var mockLogger = new Mock(); + var mockDiscord = new Mock(); + var events = new RandomizerEvents(config, mockLogger.Object, mockDiscord.Object); + + var eventRaisedWithStatus = default(string); + events.Status += (status) => eventRaisedWithStatus = status; + + // Act + events.OnStatus("Some status"); + + // Assert + Assert.Equal(expected: "Some status", actual: eventRaisedWithStatus); + } + + [Fact] + public void OnFirstMapStarted_InvokesEvent() + { + // Arrange + var config = new RandomizerConfig(); + var mockLogger = new Mock(); + var mockDiscord = new Mock(); + var events = new RandomizerEvents(config, mockLogger.Object, mockDiscord.Object); + + var eventRaised = false; + events.FirstMapStarted += () => eventRaised = true; + + // Act + events.OnFirstMapStarted(); + + // Assert + Assert.True(eventRaised); + } + + [Fact] + public void OnMapStarted_InvokesEvent() + { + // Arrange + var config = new RandomizerConfig(); + var mockLogger = new Mock(); + var mockDiscord = new Mock(); + var events = new RandomizerEvents(config, mockLogger.Object, mockDiscord.Object); + var sessionMap = new SessionMap(NodeInstance.Create(), DateTimeOffset.Now, "https://tmuf.exchange/trackshow/69"); + + var eventRaised = false; + events.MapStarted += (map) => eventRaised = true; + + // Act + events.OnMapStarted(sessionMap); + + // Assert + Assert.True(eventRaised); + } + + [Fact] + public void OnMapEnded_InvokesEvent() + { + // Arrange + var config = new RandomizerConfig(); + var mockLogger = new Mock(); + var mockDiscord = new Mock(); + var events = new RandomizerEvents(config, mockLogger.Object, mockDiscord.Object); + + var eventRaised = false; + events.MapEnded += () => eventRaised = true; + + // Act + events.OnMapEnded(); + + // Assert + Assert.True(eventRaised); + } + + [Fact] + public void OnMapSkip_InvokesEvent() + { + // Arrange + var config = new RandomizerConfig(); + var mockLogger = new Mock(); + var mockDiscord = new Mock(); + var events = new RandomizerEvents(config, mockLogger.Object, mockDiscord.Object); + + var eventRaised = false; + events.MapSkip += () => eventRaised = true; + + // Act + events.OnMapSkip(); + + // Assert + Assert.True(eventRaised); + } + + [Fact] + public void OnMedalUpdate_InvokesEvent() + { + // Arrange + var config = new RandomizerConfig(); + var mockLogger = new Mock(); + var mockDiscord = new Mock(); + var events = new RandomizerEvents(config, mockLogger.Object, mockDiscord.Object); + + var eventRaised = false; + events.MedalUpdate += () => eventRaised = true; + + // Act + events.OnMedalUpdate(); + + // Assert + Assert.True(eventRaised); + } + + [Fact] + public void OnAutosaveCreatedOrChanged_InvokesEvent() + { + // Arrange + var config = new RandomizerConfig(); + var mockLogger = new Mock(); + var mockDiscord = new Mock(); + var events = new RandomizerEvents(config, mockLogger.Object, mockDiscord.Object); + var replay = NodeInstance.Create(); + + var eventFileName = default(string); + var eventReplay = default(CGameCtnReplayRecord); + + events.AutosaveCreatedOrChanged += (fileName, replay) => + { + eventFileName = fileName; + eventReplay = replay; + }; + + // Act + events.OnAutosaveCreatedOrChanged("File", replay); + + // Assert + Assert.Equal(expected: "File", actual: eventFileName); + Assert.Same(expected: replay, actual: eventReplay); + } +} diff --git a/Tests/RandomizerTMF.Logic.Tests/Unit/Services/ValidatorTests.cs b/Tests/RandomizerTMF.Logic.Tests/Unit/Services/ValidatorTests.cs new file mode 100644 index 0000000..f43a80a --- /dev/null +++ b/Tests/RandomizerTMF.Logic.Tests/Unit/Services/ValidatorTests.cs @@ -0,0 +1,631 @@ +using GBX.NET; +using GBX.NET.Engines.Game; +using Moq; +using RandomizerTMF.Logic.Exceptions; +using RandomizerTMF.Logic.Services; +using System.Collections.Concurrent; + +namespace RandomizerTMF.Logic.Tests.Unit.Services; + +public class ValidatorTests +{ + [Fact] + public void ValidateRules_TimeLimitEqualsZero_Throws() + { + var autosaveScanner = new Mock().Object; + var additionalData = new Mock().Object; + + var config = new RandomizerConfig + { + Rules = new() { TimeLimit = TimeSpan.Zero } + }; + + var validator = new Validator(autosaveScanner, additionalData, config); + + Assert.Throws(validator.ValidateRules); + } + + [Theory] + [InlineData(10)] + [InlineData(20)] + public void ValidateRules_TimeLimit10HoursOrMore_Throws(int hours) + { + var autosaveScanner = new Mock().Object; + var additionalData = new Mock().Object; + + var config = new RandomizerConfig + { + Rules = new() { TimeLimit = TimeSpan.FromHours(hours) } + }; + + var validator = new Validator(autosaveScanner, additionalData, config); + + Assert.Throws(validator.ValidateRules); + } + + [Fact] + public void ValidateRules_AllRulesValid_DoesNotThrow() + { + var autosaveScanner = new Mock().Object; + var additionalData = new Mock().Object; + + var config = new RandomizerConfig(); + + var validator = new Validator(autosaveScanner, additionalData, config); + + var exception = Record.Exception(validator.ValidateRules); + + Assert.Null(exception); + } + + [Fact] + public void ValidateRequestRules_AllRulesValid_DoesNotThrow() + { + var autosaveScanner = new Mock().Object; + var additionalData = new Mock().Object; + + var config = new RandomizerConfig(); + + var validator = new Validator(autosaveScanner, additionalData, config); + + var exception = Record.Exception(validator.ValidateRequestRules); + + Assert.Null(exception); + } + + [Theory] + [InlineData(ESite.Nations, EPrimaryType.Platform)] + [InlineData(ESite.Nations, EPrimaryType.Stunts)] + [InlineData(ESite.Nations, EPrimaryType.Puzzle)] + [InlineData(ESite.TMNF, EPrimaryType.Platform)] + [InlineData(ESite.TMNF, EPrimaryType.Stunts)] + [InlineData(ESite.TMNF, EPrimaryType.Puzzle)] + public void ValidateRequestRules_UnitedGamemodeWithTMNFOrNations_Throws(ESite site, EPrimaryType type) + { + var autosaveScanner = new Mock().Object; + var additionalData = new Mock().Object; + + var config = new RandomizerConfig + { + Rules = new() + { + RequestRules = new() + { + Site = site, + PrimaryType = type + } + } + }; + + var validator = new Validator(autosaveScanner, additionalData, config); + + Assert.Throws(validator.ValidateRequestRules); + } + + [Theory] + [InlineData(ESite.Original, EEnvironment.Island)] + [InlineData(ESite.Original, EEnvironment.Bay)] + [InlineData(ESite.Original, EEnvironment.Coast)] + [InlineData(ESite.Sunrise, EEnvironment.Snow)] + [InlineData(ESite.Sunrise, EEnvironment.Desert)] + [InlineData(ESite.Sunrise, EEnvironment.Rally)] + [InlineData(ESite.Nations, EEnvironment.Island)] + [InlineData(ESite.Nations, EEnvironment.Bay)] + [InlineData(ESite.Nations, EEnvironment.Coast)] + [InlineData(ESite.Nations, EEnvironment.Snow)] + [InlineData(ESite.Nations, EEnvironment.Desert)] + [InlineData(ESite.Nations, EEnvironment.Rally)] + [InlineData(ESite.TMNF, EEnvironment.Island)] + [InlineData(ESite.TMNF, EEnvironment.Bay)] + [InlineData(ESite.TMNF, EEnvironment.Coast)] + [InlineData(ESite.TMNF, EEnvironment.Snow)] + [InlineData(ESite.TMNF, EEnvironment.Desert)] + [InlineData(ESite.TMNF, EEnvironment.Rally)] + public void ValidateRequestRules_SpecificEnvsWithOtherExchange_Throws(ESite site, EEnvironment env) + { + var autosaveScanner = new Mock().Object; + var additionalData = new Mock().Object; + + var config = new RandomizerConfig + { + Rules = new() + { + RequestRules = new() + { + Site = site, + Environment = new() { env } + } + } + }; + + var validator = new Validator(autosaveScanner, additionalData, config); + + Assert.Throws(validator.ValidateRequestRules); + } + + [Theory] + [InlineData(ESite.Sunrise, EEnvironment.Island, EEnvironment.Bay)] + [InlineData(ESite.Sunrise, EEnvironment.Island, EEnvironment.Coast)] + [InlineData(ESite.Sunrise, EEnvironment.Bay, EEnvironment.Island)] + [InlineData(ESite.Sunrise, EEnvironment.Bay, EEnvironment.Coast)] + [InlineData(ESite.Sunrise, EEnvironment.Coast, EEnvironment.Bay)] + [InlineData(ESite.Sunrise, EEnvironment.Coast, EEnvironment.Island)] + [InlineData(ESite.Original, EEnvironment.Desert, EEnvironment.Snow)] + [InlineData(ESite.Original, EEnvironment.Desert, EEnvironment.Rally)] + [InlineData(ESite.Original, EEnvironment.Snow, EEnvironment.Desert)] + [InlineData(ESite.Original, EEnvironment.Snow, EEnvironment.Rally)] + [InlineData(ESite.Original, EEnvironment.Rally, EEnvironment.Snow)] + [InlineData(ESite.Original, EEnvironment.Rally, EEnvironment.Desert)] + public void ValidateRequestRules_EnvimixOnOldExchange_Throws(ESite site, EEnvironment env, EEnvironment vehicle) + { + var autosaveScanner = new Mock().Object; + var additionalData = new Mock().Object; + + var config = new RandomizerConfig + { + Rules = new() + { + RequestRules = new() + { + Site = site, + Environment = new() { env }, + Vehicle = new() { vehicle } + } + } + }; + + var validator = new Validator(autosaveScanner, additionalData, config); + + Assert.Throws(validator.ValidateRequestRules); + } + + [Theory] + [InlineData(ESite.Original, EEnvironment.Island)] + [InlineData(ESite.Original, EEnvironment.Bay)] + [InlineData(ESite.Original, EEnvironment.Coast)] + [InlineData(ESite.Sunrise, EEnvironment.Snow)] + [InlineData(ESite.Sunrise, EEnvironment.Desert)] + [InlineData(ESite.Sunrise, EEnvironment.Rally)] + [InlineData(ESite.Nations, EEnvironment.Island)] + [InlineData(ESite.Nations, EEnvironment.Bay)] + [InlineData(ESite.Nations, EEnvironment.Coast)] + [InlineData(ESite.Nations, EEnvironment.Snow)] + [InlineData(ESite.Nations, EEnvironment.Desert)] + [InlineData(ESite.Nations, EEnvironment.Rally)] + [InlineData(ESite.TMNF, EEnvironment.Island)] + [InlineData(ESite.TMNF, EEnvironment.Bay)] + [InlineData(ESite.TMNF, EEnvironment.Coast)] + [InlineData(ESite.TMNF, EEnvironment.Snow)] + [InlineData(ESite.TMNF, EEnvironment.Desert)] + [InlineData(ESite.TMNF, EEnvironment.Rally)] + public void ValidateRequestRules_SpecificVehiclesWithOtherExchange_Throws(ESite site, EEnvironment env) + { + var autosaveScanner = new Mock().Object; + var additionalData = new Mock().Object; + + var config = new RandomizerConfig + { + Rules = new() + { + RequestRules = new() + { + Site = site, + Vehicle = new() { env } + } + } + }; + + var validator = new Validator(autosaveScanner, additionalData, config); + + Assert.Throws(validator.ValidateRequestRules); + } + + [Theory] + [InlineData(ESite.Any)] + [InlineData(ESite.Original)] + [InlineData(ESite.Nations)] + [InlineData(ESite.TMNF)] + public void ValidateRequestRules_EqualEnvAndVehicleDistributionWithNonTMUFExchange_Throws(ESite site) + { + var autosaveScanner = new Mock().Object; + var additionalData = new Mock().Object; + + var config = new RandomizerConfig + { + Rules = new() + { + RequestRules = new() + { + Site = site, + EqualEnvironmentDistribution = true, + EqualVehicleDistribution = true + } + } + }; + + var validator = new Validator(autosaveScanner, additionalData, config); + + Assert.Throws(validator.ValidateRequestRules); + } + + [Theory] + [InlineData(ESite.Nations)] + [InlineData(ESite.TMNF)] + public void ValidateRequestRules_EqualEnvDistributionWithStadiumExchange_Throws(ESite site) + { + var autosaveScanner = new Mock().Object; + var additionalData = new Mock().Object; + + var config = new RandomizerConfig + { + Rules = new() + { + RequestRules = new() + { + Site = site, + EqualEnvironmentDistribution = true + } + } + }; + + var validator = new Validator(autosaveScanner, additionalData, config); + + Assert.Throws(validator.ValidateRequestRules); + } + + [Theory] + [InlineData(ESite.Nations)] + [InlineData(ESite.TMNF)] + public void ValidateRequestRules_EqualVehicleDistributionWithStadiumExchange_Throws(ESite site) + { + var autosaveScanner = new Mock().Object; + var additionalData = new Mock().Object; + + var config = new RandomizerConfig + { + Rules = new() + { + RequestRules = new() + { + Site = site, + EqualVehicleDistribution = true + } + } + }; + + var validator = new Validator(autosaveScanner, additionalData, config); + + Assert.Throws(validator.ValidateRequestRules); + } + + [Theory] + [InlineData(ESite.Original, EEnvironment.Snow)] + [InlineData(ESite.Original, EEnvironment.Desert)] + [InlineData(ESite.Original, EEnvironment.Rally)] + [InlineData(ESite.Sunrise, EEnvironment.Island)] + [InlineData(ESite.Sunrise, EEnvironment.Bay)] + [InlineData(ESite.Sunrise, EEnvironment.Coast)] + [InlineData(ESite.Nations, EEnvironment.Stadium)] + [InlineData(ESite.TMNF, EEnvironment.Stadium)] + [InlineData(ESite.TMUF, EEnvironment.Snow)] + [InlineData(ESite.TMUF, EEnvironment.Desert)] + [InlineData(ESite.TMUF, EEnvironment.Rally)] + [InlineData(ESite.TMUF, EEnvironment.Island)] + [InlineData(ESite.TMUF, EEnvironment.Bay)] + [InlineData(ESite.TMUF, EEnvironment.Coast)] + [InlineData(ESite.TMUF, EEnvironment.Stadium)] + public void ValidateRequestRules_ValidEnvironmentWithExchange_DoesNotThrow(ESite site, EEnvironment env) + { + var autosaveScanner = new Mock().Object; + var additionalData = new Mock().Object; + + var config = new RandomizerConfig + { + Rules = new() + { + RequestRules = new() + { + Site = site, + Environment = new() { env } + } + } + }; + + var validator = new Validator(autosaveScanner, additionalData, config); + + var exception = Record.Exception(validator.ValidateRequestRules); + + Assert.Null(exception); + } + + [Theory] + [InlineData(ESite.Original, EEnvironment.Snow)] + [InlineData(ESite.Original, EEnvironment.Desert)] + [InlineData(ESite.Original, EEnvironment.Rally)] + [InlineData(ESite.Sunrise, EEnvironment.Island)] + [InlineData(ESite.Sunrise, EEnvironment.Bay)] + [InlineData(ESite.Sunrise, EEnvironment.Coast)] + [InlineData(ESite.Nations, EEnvironment.Stadium)] + [InlineData(ESite.TMNF, EEnvironment.Stadium)] + [InlineData(ESite.TMUF, EEnvironment.Snow)] + [InlineData(ESite.TMUF, EEnvironment.Desert)] + [InlineData(ESite.TMUF, EEnvironment.Rally)] + [InlineData(ESite.TMUF, EEnvironment.Island)] + [InlineData(ESite.TMUF, EEnvironment.Bay)] + [InlineData(ESite.TMUF, EEnvironment.Coast)] + [InlineData(ESite.TMUF, EEnvironment.Stadium)] + public void ValidateRequestRules_ValidVehicleWithExchange_DoesNotThrow(ESite site, EEnvironment env) + { + var autosaveScanner = new Mock().Object; + var additionalData = new Mock().Object; + + var config = new RandomizerConfig + { + Rules = new() + { + RequestRules = new() + { + Site = site, + Vehicle = new() { env } + } + } + }; + + var validator = new Validator(autosaveScanner, additionalData, config); + + var exception = Record.Exception(validator.ValidateRequestRules); + + Assert.Null(exception); + } + + [Fact] + public void ValidateMap_AutosavesContainUid_ReturnsFalse() + { + var autosaveHeaders = new ConcurrentDictionary(); + autosaveHeaders["mapuid"] = new AutosaveHeader("path/to/file.Replay.Gbx", NodeInstance.Create()); + + var mockAutosaveScanner = new Mock(); + mockAutosaveScanner.SetupGet(x => x.AutosaveHeaders).Returns(autosaveHeaders); + var autosaveScanner = mockAutosaveScanner.Object; + + var additionalData = new Mock().Object; + var config = new RandomizerConfig(); + + var validator = new Validator(autosaveScanner, additionalData, config); + + var map = NodeInstance.Create(); + map.MapUid = "mapuid"; + + var result = validator.ValidateMap(map, out string? invalidBlock); + + Assert.Null(invalidBlock); + Assert.False(result); + } + + [Fact] + public void ValidateMap_MapNotPlayedAndNoUnlimiter_ReturnsTrue() + { + var mockAutosaveScanner = new Mock(); + mockAutosaveScanner.SetupGet(x => x.AutosaveHeaders).Returns(new ConcurrentDictionary()); + var autosaveScanner = mockAutosaveScanner.Object; + + var additionalData = new Mock().Object; + var config = new RandomizerConfig + { + Rules = new() + { + NoUnlimiter = false + } + }; + + var validator = new Validator(autosaveScanner, additionalData, config); + + var map = NodeInstance.Create(); + map.MapUid = "mapuid"; + map.ChallengeParameters = NodeInstance.Create(); + + var result = validator.ValidateMap(map, out string? invalidBlock); + + Assert.Null(invalidBlock); + Assert.True(result); + } + + [Fact] + public void ValidateMap_NoUnlimiterHasUnlimiterChunk_ReturnsFalse() + { + var mockAutosaveScanner = new Mock(); + mockAutosaveScanner.SetupGet(x => x.AutosaveHeaders).Returns(new ConcurrentDictionary()); + var autosaveScanner = mockAutosaveScanner.Object; + + var additionalData = new Mock().Object; + var config = new RandomizerConfig(); + + var validator = new Validator(autosaveScanner, additionalData, config); + + var map = NodeInstance.Create(); + map.MapUid = "mapuid"; + map.Chunks.Add(new SkippableChunk(map, Array.Empty(), 0x3F001000)); + + var result = validator.ValidateMap(map, out string? invalidBlock); + + Assert.Null(invalidBlock); + Assert.False(result); + } + + [Fact] + public void ValidateMap_NoUnlimiterNullMapSize_ReturnsFalse() + { + var mockAutosaveScanner = new Mock(); + mockAutosaveScanner.SetupGet(x => x.AutosaveHeaders).Returns(new ConcurrentDictionary()); + var autosaveScanner = mockAutosaveScanner.Object; + + var additionalData = new Mock().Object; + var config = new RandomizerConfig(); + + var validator = new Validator(autosaveScanner, additionalData, config); + + var map = NodeInstance.Create(); + map.MapUid = "mapuid"; + map.Size = null; + + var result = validator.ValidateMap(map, out string? invalidBlock); + + Assert.Null(invalidBlock); + Assert.False(result); + } + + [Fact] + public void ValidateMap_NoUnlimiterMapSizesDoesNotContainCollection_ReturnsFalse() + { + var mockAutosaveScanner = new Mock(); + mockAutosaveScanner.SetupGet(x => x.AutosaveHeaders).Returns(new ConcurrentDictionary()); + var autosaveScanner = mockAutosaveScanner.Object; + + var mockAdditionalData = new Mock(); + mockAdditionalData.SetupGet(x => x.MapSizes).Returns(new Dictionary>()); + var additionalData = mockAdditionalData.Object; + + var config = new RandomizerConfig(); + + var validator = new Validator(autosaveScanner, additionalData, config); + + var map = NodeInstance.Create(); + map.MapUid = "mapuid"; + map.Collection = "Rally"; + map.Size = (45, 32, 45); + + var result = validator.ValidateMap(map, out string? invalidBlock); + + Assert.Null(invalidBlock); + Assert.False(result); + } + + [Fact] + public void ValidateMap_NoUnlimiterInvalidMapSize_ReturnsFalse() + { + var mockAutosaveScanner = new Mock(); + mockAutosaveScanner.SetupGet(x => x.AutosaveHeaders).Returns(new ConcurrentDictionary()); + var autosaveScanner = mockAutosaveScanner.Object; + + var mockAdditionalData = new Mock(); + mockAdditionalData.SetupGet(x => x.MapSizes).Returns(new Dictionary> + { + { "Rally", new HashSet { (45, 32, 45) } } + }); + var additionalData = mockAdditionalData.Object; + + var config = new RandomizerConfig(); + + var validator = new Validator(autosaveScanner, additionalData, config); + + var map = NodeInstance.Create(); + map.MapUid = "mapuid"; + map.Collection = "Rally"; + map.Size = (255, 32, 255); + + var result = validator.ValidateMap(map, out string? invalidBlock); + + Assert.Null(invalidBlock); + Assert.False(result); + } + + [Fact] + public void ValidateMap_NoUnlimiterOfficialBlocksDoesNotContainCollection_ReturnsFalse() + { + var mockAutosaveScanner = new Mock(); + mockAutosaveScanner.SetupGet(x => x.AutosaveHeaders).Returns(new ConcurrentDictionary()); + var autosaveScanner = mockAutosaveScanner.Object; + + var mockAdditionalData = new Mock(); + mockAdditionalData.SetupGet(x => x.MapSizes).Returns(new Dictionary> + { + { "Rally", new HashSet { (45, 32, 45) } } + }); + mockAdditionalData.SetupGet(x => x.OfficialBlocks).Returns(new Dictionary>()); + var additionalData = mockAdditionalData.Object; + + var config = new RandomizerConfig(); + + var validator = new Validator(autosaveScanner, additionalData, config); + + var map = NodeInstance.Create(); + map.MapUid = "mapuid"; + map.Collection = "Rally"; + map.Size = (45, 32, 45); + + var result = validator.ValidateMap(map, out string? invalidBlock); + + Assert.Null(invalidBlock); + Assert.False(result); + } + + [Fact] + public void ValidateMap_NoUnlimiterHasUnofficialBlock_ReturnsFalse() + { + var mockAutosaveScanner = new Mock(); + mockAutosaveScanner.SetupGet(x => x.AutosaveHeaders).Returns(new ConcurrentDictionary()); + var autosaveScanner = mockAutosaveScanner.Object; + + var mockAdditionalData = new Mock(); + mockAdditionalData.SetupGet(x => x.MapSizes).Returns(new Dictionary> + { + { "Rally", new HashSet { (45, 32, 45) } } + }); + mockAdditionalData.SetupGet(x => x.OfficialBlocks).Returns(new Dictionary> + { + { "Rally", new HashSet { "OfficialBlock1" } } + }); + var additionalData = mockAdditionalData.Object; + + var config = new RandomizerConfig(); + + var validator = new Validator(autosaveScanner, additionalData, config); + + var map = NodeInstance.Create(); + map.ChallengeParameters = NodeInstance.Create(); + map.MapUid = "mapuid"; + map.Collection = "Rally"; + map.Size = (45, 32, 45); + map.Blocks = new List { CGameCtnBlock.Unassigned1 }; + + var result = validator.ValidateMap(map, out string? invalidBlock); + + Assert.Equal(expected: "Unassigned1", actual: invalidBlock); + Assert.False(result); + } + + [Fact] + public void ValidateMap_NoUnlimiterHasValidMapSizeAndBlocks_ReturnsTrue() + { + var mockAutosaveScanner = new Mock(); + mockAutosaveScanner.SetupGet(x => x.AutosaveHeaders).Returns(new ConcurrentDictionary()); + var autosaveScanner = mockAutosaveScanner.Object; + + var mockAdditionalData = new Mock(); + mockAdditionalData.SetupGet(x => x.MapSizes).Returns(new Dictionary> + { + { "Rally", new HashSet { (45, 32, 45) } } + }); + mockAdditionalData.SetupGet(x => x.OfficialBlocks).Returns(new Dictionary> + { + { "Rally", new HashSet { "Unassigned1" } } + }); + var additionalData = mockAdditionalData.Object; + + var config = new RandomizerConfig(); + + var validator = new Validator(autosaveScanner, additionalData, config); + + var map = NodeInstance.Create(); + map.ChallengeParameters = NodeInstance.Create(); + map.MapUid = "mapuid"; + map.Collection = "Rally"; + map.Size = (45, 32, 45); + map.Blocks = new List { CGameCtnBlock.Unassigned1 }; + + var result = validator.ValidateMap(map, out string? invalidBlock); + + Assert.Null(invalidBlock); + Assert.True(result); + } +} diff --git a/Tests/RandomizerTMF.Logic.Tests/Unit/SessionDataTests.cs b/Tests/RandomizerTMF.Logic.Tests/Unit/SessionDataTests.cs new file mode 100644 index 0000000..2e647f2 --- /dev/null +++ b/Tests/RandomizerTMF.Logic.Tests/Unit/SessionDataTests.cs @@ -0,0 +1,186 @@ +using Microsoft.Extensions.Logging; +using GBX.NET.Engines.Game; +using Moq; +using RandomizerTMF.Logic.Services; +using System.IO.Abstractions.TestingHelpers; +using TmEssentials; +using System.Reflection; +using GBX.NET; + +namespace RandomizerTMF.Logic.Tests.Unit; + +public class SessionDataTests +{ + [Fact] + public void Initialize() + { + var startedAt = DateTimeOffset.Now; + var config = new RandomizerConfig(); + var logger = new Mock(); + var fileSystem = new MockFileSystem(); + var data = SessionData.Initialize(startedAt, config, logger.Object, fileSystem); + + Assert.Equal(RandomizerEngine.Version, data.Version); + Assert.Equal(startedAt, data.StartedAt); + Assert.Equal(config.Rules, data.Rules); + Assert.Equal(Path.Combine(FilePathManager.SessionsDirectoryPath, data.StartedAtText), data.DirectoryPath); + Assert.NotNull(data.Maps); + Assert.True(fileSystem.Directory.Exists(data.DirectoryPath)); + } + + [Theory] + [InlineData(CGameCtnChallenge.PlayMode.Race, null, "map_name__2'03''45_playerLogin.Replay.Gbx")] + [InlineData(CGameCtnChallenge.PlayMode.Stunts, null, "map_name__67_2'03''45_playerLogin.Replay.Gbx")] + [InlineData(CGameCtnChallenge.PlayMode.Platform, null, "map_name__3_2'03''45_playerLogin.Replay.Gbx")] + [InlineData(CGameCtnChallenge.PlayMode.Race, "", "map_name__2'03''45_playerLogin.Replay.Gbx")] + [InlineData(CGameCtnChallenge.PlayMode.Stunts, "", "map_name__67_2'03''45_playerLogin.Replay.Gbx")] + [InlineData(CGameCtnChallenge.PlayMode.Platform, "", "map_name__3_2'03''45_playerLogin.Replay.Gbx")] + [InlineData(CGameCtnChallenge.PlayMode.Race, " {1}-{0}-{2}", " 2'03''45-map_name_-playerLogin")] + [InlineData(CGameCtnChallenge.PlayMode.Stunts, "{1}-{2}-{0}.Replay.Gbx", "67_2'03''45-playerLogin-map_name_.Replay.Gbx")] + [InlineData(CGameCtnChallenge.PlayMode.Platform, "{0}-{2}-{1} ", "map_name_-playerLogin-3_2'03''45 ")] + public void UpdateFromAutosave_SavesReplayToCurrentMap(CGameCtnChallenge.PlayMode mode, string replayFileFormat, string expectedReplayFileName) + { + // Arrange + var fullPath = @"C:\Test\Autosave.Replay.Gbx"; + + var config = new RandomizerConfig + { + ReplayFileFormat = replayFileFormat + }; + var mockLogger = new Mock(); + var fileSystem = new MockFileSystem(new Dictionary + { + { fullPath, new MockFileData("some replay content") } + }); + var sessionData = SessionData.Initialize(config, mockLogger.Object, fileSystem); + + var replaysDir = Path.Combine(sessionData.DirectoryPath, "Replays"); + var expectedReplayFilePath = Path.Combine(replaysDir, expectedReplayFileName); + + var map = NodeInstance.Create(); + map.MapName = "map name*"; + map.MapUid = "uid"; + map.Mode = mode; + + var sessionMap = new SessionMap(map, DateTimeOffset.UtcNow, "https://tmuf.exchange/trackshow/69"); + + sessionData.Maps.Add(new SessionDataMap + { + Name = sessionMap.Map.MapName, + Uid = sessionMap.Map.MapUid, + TmxLink = sessionMap.TmxLink, + }); + + var ghost = NodeInstance.Create(); + ghost.StuntScore = 67; + ghost.Respawns = 3; + + var replay = NodeInstance.Create(); + typeof(CGameCtnReplayRecord).GetField("time", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(replay, new TimeInt32(123450)); + typeof(CGameCtnReplayRecord).GetField("ghosts", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(replay, new CGameCtnGhost[] { ghost }); + typeof(CGameCtnReplayRecord).GetField("playerLogin", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(replay, "playerLogin"); + + var expectedElapsed = TimeSpan.FromMinutes(1); + + // Act + sessionData.UpdateFromAutosave(fullPath, sessionMap, replay, expectedElapsed); + + // Assert + Assert.NotEmpty(sessionData.Maps); + Assert.NotEmpty(sessionData.Maps[0].Replays); + + var sessionReplay = sessionData.Maps[0].Replays[0]; + + Assert.Equal(expectedReplayFileName, actual: sessionReplay.FileName); + Assert.Equal(expected: expectedElapsed, sessionReplay.Timestamp); + + Assert.True(fileSystem.FileExists(expectedReplayFilePath)); + Assert.True(fileSystem.FileExists(Path.Combine(sessionData.DirectoryPath, "Session.yml"))); + Assert.Equal(fileSystem.File.ReadAllBytes(expectedReplayFilePath), actual: fileSystem.File.ReadAllBytes(fullPath)); + } + + [Fact] + public void Save_CreatesSessionYml() + { + // Arrange + var config = new RandomizerConfig(); + var mockLogger = new Mock(); + var fileSystem = new MockFileSystem(); + var sessionData = SessionData.Initialize(config, mockLogger.Object, fileSystem); + + // Act + sessionData.Save(); + + // Assert + Assert.True(fileSystem.FileExists(Path.Combine(sessionData.DirectoryPath, "Session.yml"))); + } + + [Fact] + public void InternalSetReadOnlySessionYml_SetsReadOnlyAttribute() + { + // Arrange + var config = new RandomizerConfig(); + var mockLogger = new Mock(); + var fileSystem = new MockFileSystem(); + var sessionData = SessionData.Initialize(config, mockLogger.Object, fileSystem); + + // Act + sessionData.InternalSetReadOnlySessionYml(); + + // Assert + Assert.True(fileSystem.File.GetAttributes(Path.Combine(sessionData.DirectoryPath, "Session.yml")).HasFlag(FileAttributes.ReadOnly)); + } + + [Fact] + public void InternalSetReadOnlySessionYml_SetsReadOnlyAttribute_FileNotFound() + { + // Arrange + var config = new RandomizerConfig(); + var mockLogger = new Mock(); + var fileSystem = new MockFileSystem(); + var sessionData = SessionData.Initialize(config, mockLogger.Object, fileSystem); + + // To make things easier + fileSystem.File.Delete(Path.Combine(sessionData.DirectoryPath, "Session.yml")); + + // Act & Assert + Assert.Throws(sessionData.InternalSetReadOnlySessionYml); + } + + [Fact] + public void SetMapResult_UpdatesMapResultAndLastTimestamp() + { + // Arrange + var config = new RandomizerConfig(); + var mockLogger = new Mock(); + var fileSystem = new MockFileSystem(); + var sessionData = SessionData.Initialize(config, mockLogger.Object, fileSystem); + var fileTimestamp = fileSystem.GetFile(Path.Combine(sessionData.DirectoryPath, "Session.yml")).LastWriteTime; + + var map = NodeInstance.Create(); + map.MapName = "map name*"; + map.MapUid = "uid"; + + var lastTimestamp = TimeSpan.FromMinutes(1); + + var sessionMap = new SessionMap(map, DateTimeOffset.UtcNow, "https://tmuf.exchange/trackshow/69") + { + LastTimestamp = lastTimestamp + }; + + sessionData.Maps.Add(new SessionDataMap + { + Name = sessionMap.Map.MapName, + Uid = sessionMap.Map.MapUid, + TmxLink = sessionMap.TmxLink, + }); + + // Act + sessionData.SetMapResult(sessionMap, "SomeResult"); + + // Assert + Assert.Equal(expected: "SomeResult", actual: sessionData.Maps[0].Result); + Assert.Equal(expected: lastTimestamp, actual: sessionData.Maps[0].LastTimestamp); + Assert.NotEqual(expected: fileTimestamp, actual: fileSystem.GetFile(Path.Combine(sessionData.DirectoryPath, "Session.yml")).LastWriteTime); + } +} diff --git a/Tests/RandomizerTMF.Logic.Tests/Unit/SessionMapTests.cs b/Tests/RandomizerTMF.Logic.Tests/Unit/SessionMapTests.cs new file mode 100644 index 0000000..be4c489 --- /dev/null +++ b/Tests/RandomizerTMF.Logic.Tests/Unit/SessionMapTests.cs @@ -0,0 +1,458 @@ +using GBX.NET.Engines.Game; +using TmEssentials; + +namespace RandomizerTMF.Logic.Tests.Unit +{ + public class SessionMapTests + { + [Theory] + [InlineData(CGameCtnChallenge.PlayMode.Race)] + [InlineData(CGameCtnChallenge.PlayMode.Puzzle)] + public void IsAuthorMedal_RaceOrPuzzle_ReplayTimeLessThanAuthorTime_ReturnsTrue(CGameCtnChallenge.PlayMode mode) + { + var map = NodeInstance.Create(); + map.Mode = mode; + map.ChallengeParameters = NodeInstance.Create(); + map.ChallengeParameters.AuthorTime = new TimeInt32(100); + + var sessionMap = new SessionMap(map, DateTimeOffset.UtcNow, ""); + + var ghost = NodeInstance.Create(); + ghost.RaceTime = new TimeInt32(80); + + var result = sessionMap.IsAuthorMedal(ghost); + + Assert.True(result); + } + + [Theory] + [InlineData(CGameCtnChallenge.PlayMode.Race)] + [InlineData(CGameCtnChallenge.PlayMode.Puzzle)] + public void IsAuthorMedal_RaceOrPuzzle_ReplayTimeEqualToAuthorTime_ReturnsTrue(CGameCtnChallenge.PlayMode mode) + { + var map = NodeInstance.Create(); + map.Mode = mode; + map.ChallengeParameters = NodeInstance.Create(); + map.ChallengeParameters.AuthorTime = new TimeInt32(100); + + var sessionMap = new SessionMap(map, DateTimeOffset.UtcNow, ""); + + var ghost = NodeInstance.Create(); + ghost.RaceTime = new TimeInt32(100); + + var result = sessionMap.IsAuthorMedal(ghost); + + Assert.True(result); + } + + [Theory] + [InlineData(CGameCtnChallenge.PlayMode.Race)] + [InlineData(CGameCtnChallenge.PlayMode.Puzzle)] + public void IsAuthorMedal_RaceOrPuzzle_ReplayTimeGreaterThanAuthorTime_ReturnsFalse(CGameCtnChallenge.PlayMode mode) + { + var map = NodeInstance.Create(); + map.Mode = mode; + map.ChallengeParameters = NodeInstance.Create(); + map.ChallengeParameters.AuthorTime = new TimeInt32(100); + + var sessionMap = new SessionMap(map, DateTimeOffset.UtcNow, ""); + + var ghost = NodeInstance.Create(); + ghost.RaceTime = new(120); + + var result = sessionMap.IsAuthorMedal(ghost); + + Assert.False(result); + } + + [Fact] + public void IsAuthorMedal_Platform_RespawnsLessThanAuthorScoreAndReplayTimeLessThanAuthorTime_ReturnsTrue() + { + var map = NodeInstance.Create(); + map.Mode = CGameCtnChallenge.PlayMode.Platform; + map.ChallengeParameters = NodeInstance.Create(); + map.ChallengeParameters.AuthorScore = 3; + map.ChallengeParameters.AuthorTime = new TimeInt32(100); + + var sessionMap = new SessionMap(map, DateTimeOffset.UtcNow, ""); + + var ghost = NodeInstance.Create(); + ghost.RaceTime = new(80); + ghost.Respawns = 2; + + var result = sessionMap.IsAuthorMedal(ghost); + + Assert.True(result); + } + + [Fact] + public void IsAuthorMedal_Platform_RespawnsEqualToAuthorScoreAndReplayTimeEqualToAuthorTime_ReturnsTrue() + { + var map = NodeInstance.Create(); + map.Mode = CGameCtnChallenge.PlayMode.Platform; + map.ChallengeParameters = NodeInstance.Create(); + map.ChallengeParameters.AuthorScore = 3; + map.ChallengeParameters.AuthorTime = new TimeInt32(100); + + var sessionMap = new SessionMap(map, DateTimeOffset.UtcNow, ""); + + var ghost = NodeInstance.Create(); + ghost.RaceTime = new(100); + ghost.Respawns = 3; + + var result = sessionMap.IsAuthorMedal(ghost); + + Assert.True(result); + } + + [Fact] + public void IsAuthorMedal_Platform_RespawnsGreaterThanAuthorScoreAndReplayTimeLessThanAuthorTime_ReturnsFalse() + { + var map = NodeInstance.Create(); + map.Mode = CGameCtnChallenge.PlayMode.Platform; + map.ChallengeParameters = NodeInstance.Create(); + map.ChallengeParameters.AuthorScore = 3; + map.ChallengeParameters.AuthorTime = new TimeInt32(100); + + var sessionMap = new SessionMap(map, DateTimeOffset.UtcNow, ""); + + var ghost = NodeInstance.Create(); + ghost.Respawns = 5; + ghost.RaceTime = new(80); + + var result = sessionMap.IsAuthorMedal(ghost); + + Assert.False(result); + } + + [Fact] + public void IsAuthorMedal_Platform_RespawnsGreaterThanAuthorScoreAndReplayTimeEqualToAuthorTime_ReturnsFalse() + { + var map = NodeInstance.Create(); + map.Mode = CGameCtnChallenge.PlayMode.Platform; + map.ChallengeParameters = NodeInstance.Create(); + map.ChallengeParameters.AuthorScore = 3; + map.ChallengeParameters.AuthorTime = new TimeInt32(100); + + var sessionMap = new SessionMap(map, DateTimeOffset.UtcNow, ""); + + var ghost = NodeInstance.Create(); + ghost.Respawns = 5; + ghost.RaceTime = new(100); + + var result = sessionMap.IsAuthorMedal(ghost); + + Assert.False(result); + } + + [Fact] + public void IsAuthorMedal_Platform_RespawnsIsZeroAndReplayTimeLessThanAuthorTime_ReturnsTrue() + { + var map = NodeInstance.Create(); + map.Mode = CGameCtnChallenge.PlayMode.Platform; + map.ChallengeParameters = NodeInstance.Create(); + map.ChallengeParameters.AuthorScore = 3; + map.ChallengeParameters.AuthorTime = new TimeInt32(100); + + var sessionMap = new SessionMap(map, DateTimeOffset.UtcNow, ""); + + var ghost = NodeInstance.Create(); + ghost.Respawns = 0; + ghost.RaceTime = new(80); + + var result = sessionMap.IsAuthorMedal(ghost); + + Assert.True(result); + } + + [Fact] + public void IsAuthorMedal_Platform_RespawnsIsZeroAndReplayTimeEqualToAuthorTime_ReturnsTrue() + { + var map = NodeInstance.Create(); + map.Mode = CGameCtnChallenge.PlayMode.Platform; + map.ChallengeParameters = NodeInstance.Create(); + map.ChallengeParameters.AuthorScore = 0; + map.ChallengeParameters.AuthorTime = new TimeInt32(100); + + var sessionMap = new SessionMap(map, DateTimeOffset.UtcNow, ""); + + var ghost = NodeInstance.Create(); + ghost.Respawns = 0; + ghost.RaceTime = new(100); + + var result = sessionMap.IsAuthorMedal(ghost); + + Assert.True(result); + } + + [Fact] + public void IsAuthorMedal_PlatformMode_RespawnsIsZeroAndReplayTimeGreaterThanAuthorTime_ReturnsFalse() + { + var map = NodeInstance.Create(); + map.Mode = CGameCtnChallenge.PlayMode.Platform; + map.ChallengeParameters = NodeInstance.Create(); + map.ChallengeParameters.AuthorScore = 0; + map.ChallengeParameters.AuthorTime = new TimeInt32(100); + + var sessionMap = new SessionMap(map, DateTimeOffset.UtcNow, ""); + + var ghost = NodeInstance.Create(); + ghost.Respawns = 0; + ghost.RaceTime = new(120); + + var result = sessionMap.IsAuthorMedal(ghost); + + Assert.False(result); + } + + [Fact] + public void IsAuthorMedal_Stunts_ScoreGreaterThanAuthorScore_ReturnsTrue() + { + var map = NodeInstance.Create(); + map.Mode = CGameCtnChallenge.PlayMode.Stunts; + map.ChallengeParameters = NodeInstance.Create(); + map.ChallengeParameters.AuthorScore = 200; + + var sessionMap = new SessionMap(map, DateTimeOffset.UtcNow, ""); + + var ghost = NodeInstance.Create(); + ghost.StuntScore = 250; + + var result = sessionMap.IsAuthorMedal(ghost); + + Assert.True(result); + } + + [Fact] + public void IsAuthorMedal_Stunts_ScoreEqualToAuthorScore_ReturnsTrue() + { + var map = NodeInstance.Create(); + map.Mode = CGameCtnChallenge.PlayMode.Stunts; + map.ChallengeParameters = NodeInstance.Create(); + map.ChallengeParameters.AuthorScore = 200; + + var sessionMap = new SessionMap(map, DateTimeOffset.UtcNow, ""); + + var ghost = NodeInstance.Create(); + ghost.StuntScore = 200; + + var result = sessionMap.IsAuthorMedal(ghost); + + Assert.True(result); + } + + [Fact] + public void IsAuthorMedal_Stunts_ScoreIsLessThanAuthorScore_ReturnsFalse() + { + var map = NodeInstance.Create(); + map.Mode = CGameCtnChallenge.PlayMode.Stunts; + map.ChallengeParameters = NodeInstance.Create(); + map.ChallengeParameters.AuthorScore = 200; + + var sessionMap = new SessionMap(map, DateTimeOffset.UtcNow, ""); + + var ghost = NodeInstance.Create(); + ghost.StuntScore = 150; + + var result = sessionMap.IsAuthorMedal(ghost); + + Assert.False(result); + } + + [Fact] + public void IsAuthorMedal_UnsupportedGamemode_ShouldThrow() + { + var map = NodeInstance.Create(); + map.Mode = (CGameCtnChallenge.PlayMode)999; + map.ChallengeParameters = NodeInstance.Create(); + + var sessionMap = new SessionMap(map, DateTimeOffset.UtcNow, ""); + + var ghost = NodeInstance.Create(); + + Assert.Throws(() => sessionMap.IsAuthorMedal(ghost)); + } + + [Theory] + [InlineData(CGameCtnChallenge.PlayMode.Race)] + [InlineData(CGameCtnChallenge.PlayMode.Puzzle)] + public void IsGoldMedal_RaceOrPuzzle_RaceTimeLessThanGoldTime_ReturnsTrue(CGameCtnChallenge.PlayMode mode) + { + var map = NodeInstance.Create(); + map.Mode = mode; + map.ChallengeParameters = NodeInstance.Create(); + map.ChallengeParameters.GoldTime = new(60); + + var sessionMap = new SessionMap(map, DateTimeOffset.UtcNow, ""); + + var ghost = NodeInstance.Create(); + ghost.RaceTime = new(50); + + var result = sessionMap.IsGoldMedal(ghost); + + Assert.True(result); + } + + [Theory] + [InlineData(CGameCtnChallenge.PlayMode.Race)] + [InlineData(CGameCtnChallenge.PlayMode.Puzzle)] + public void IsGoldMedal_RaceOrPuzzle_RaceTimeEqualToGoldTime_ReturnsTrue(CGameCtnChallenge.PlayMode mode) + { + var map = NodeInstance.Create(); + map.Mode = mode; + map.ChallengeParameters = NodeInstance.Create(); + map.ChallengeParameters.GoldTime = new(60); + + var sessionMap = new SessionMap(map, DateTimeOffset.UtcNow, ""); + + var ghost = NodeInstance.Create(); + ghost.RaceTime = new(60); + + var result = sessionMap.IsGoldMedal(ghost); + + Assert.True(result); + } + + [Theory] + [InlineData(CGameCtnChallenge.PlayMode.Race)] + [InlineData(CGameCtnChallenge.PlayMode.Puzzle)] + public void IsGoldMedal_RaceOrPuzzle_RaceTimeGreaterThanGoldTime_ReturnsFalse(CGameCtnChallenge.PlayMode mode) + { + var map = NodeInstance.Create(); + map.Mode = mode; + map.ChallengeParameters = NodeInstance.Create(); + map.ChallengeParameters.GoldTime = new(60); + + var sessionMap = new SessionMap(map, DateTimeOffset.UtcNow, ""); + + var ghost = NodeInstance.Create(); + ghost.RaceTime = new(70); + + var result = sessionMap.IsGoldMedal(ghost); + + Assert.False(result); + } + + [Fact] + public void IsGoldMedal_Platform_RespawnsLessThanGold_ReturnsTrue() + { + var map = NodeInstance.Create(); + map.Mode = CGameCtnChallenge.PlayMode.Platform; + map.ChallengeParameters = NodeInstance.Create(); + map.ChallengeParameters.GoldTime = new(120); + + var sessionMap = new SessionMap(map, DateTimeOffset.UtcNow, ""); + + var ghost = NodeInstance.Create(); + ghost.Respawns = 100; + + var result = sessionMap.IsGoldMedal(ghost); + + Assert.True(result); + } + + [Fact] + public void IsGoldMedal_Platform_RespawnsEqualToGold_ReturnsTrue() + { + var map = NodeInstance.Create(); + map.Mode = CGameCtnChallenge.PlayMode.Platform; + map.ChallengeParameters = NodeInstance.Create(); + map.ChallengeParameters.GoldTime = new(100); + + var sessionMap = new SessionMap(map, DateTimeOffset.UtcNow, ""); + + var ghost = NodeInstance.Create(); + ghost.Respawns = 100; + + var result = sessionMap.IsGoldMedal(ghost); + + Assert.True(result); + } + + [Fact] + public void IsGoldMedal_Platform_RespawnsIsGreaterThanGoldTime_ReturnsFalse() + { + var map = NodeInstance.Create(); + map.Mode = CGameCtnChallenge.PlayMode.Platform; + map.ChallengeParameters = NodeInstance.Create(); + map.ChallengeParameters.GoldTime = new(120); + + var sessionMap = new SessionMap(map, DateTimeOffset.UtcNow, ""); + + var ghost = NodeInstance.Create(); + ghost.Respawns = 150; + + var result = sessionMap.IsGoldMedal(ghost); + + Assert.False(result); + } + + [Fact] + public void IsGoldMedal_Stunts_ScoreGreaterThanGoldTime_ReturnsTrue() + { + var map = NodeInstance.Create(); + map.Mode = CGameCtnChallenge.PlayMode.Stunts; + map.ChallengeParameters = NodeInstance.Create(); + map.ChallengeParameters.GoldTime = new(200); + + var sessionMap = new SessionMap(map, DateTimeOffset.UtcNow, ""); + + var ghost = NodeInstance.Create(); + ghost.StuntScore = 250; + + var result = sessionMap.IsGoldMedal(ghost); + + Assert.True(result); + } + + [Fact] + public void IsGoldMedal_Stunts_ScoreGreaterEqualToGoldTime_ReturnsTrue() + { + var map = NodeInstance.Create(); + map.Mode = CGameCtnChallenge.PlayMode.Stunts; + map.ChallengeParameters = NodeInstance.Create(); + map.ChallengeParameters.GoldTime = new(250); + + var sessionMap = new SessionMap(map, DateTimeOffset.UtcNow, ""); + + var ghost = NodeInstance.Create(); + ghost.StuntScore = 250; + + var result = sessionMap.IsGoldMedal(ghost); + + Assert.True(result); + } + + [Fact] + public void IsGoldMedal_Stunts_ScoreLessThanGoldTime_ReturnsFalse() + { + var map = NodeInstance.Create(); + map.Mode = CGameCtnChallenge.PlayMode.Stunts; + map.ChallengeParameters = NodeInstance.Create(); + map.ChallengeParameters.GoldTime = new(200); + + var sessionMap = new SessionMap(map, DateTimeOffset.UtcNow, ""); + + var ghost = NodeInstance.Create(); + ghost.StuntScore = 150; + + var result = sessionMap.IsGoldMedal(ghost); + + Assert.False(result); + } + + [Fact] + public void IsGoldMedal_UnsupportedGamemode_ShouldThrow() + { + var map = NodeInstance.Create(); + map.Mode = (CGameCtnChallenge.PlayMode)999; + map.ChallengeParameters = NodeInstance.Create(); + + var sessionMap = new SessionMap(map, DateTimeOffset.UtcNow, ""); + + var ghost = NodeInstance.Create(); + + Assert.Throws(() => sessionMap.IsGoldMedal(ghost)); + } + } +} diff --git a/Tests/RandomizerTMF.Logic.Tests/Unit/SessionTests.cs b/Tests/RandomizerTMF.Logic.Tests/Unit/SessionTests.cs new file mode 100644 index 0000000..9ee3f87 --- /dev/null +++ b/Tests/RandomizerTMF.Logic.Tests/Unit/SessionTests.cs @@ -0,0 +1,499 @@ +using GBX.NET; +using GBX.NET.Engines.Game; +using Microsoft.Extensions.Logging; +using Moq; +using RandomizerTMF.Logic.Services; +using System.Diagnostics; +using System.IO.Abstractions.TestingHelpers; +using System.Reflection; +using TmEssentials; +using static GBX.NET.Engines.Hms.CHmsLightMapCache; +using static GBX.NET.Engines.Plug.CPlugMaterialUserInst; + +namespace RandomizerTMF.Logic.Tests.Unit; + +public class SessionTests +{ + [Fact] + public void ReloadMap_MapIsNull_ReturnsFalse() + { + // Arrange + var events = Mock.Of(); + var mapDownloader = Mock.Of(); + var validator = Mock.Of(); + var config = new RandomizerConfig(); + var game = Mock.Of(); + var logger = Mock.Of(); + var fileSystem = new MockFileSystem(); + + var session = new Session(events, mapDownloader, validator, config, game, logger, fileSystem); + + // Act + var actual = session.ReloadMap(); + + // Assert + Assert.False(actual); + } + + [Fact] + public void ReloadMap_MapFilePathIsNull_ReturnsFalse() + { + // Arrange + var events = Mock.Of(); + var mapDownloader = Mock.Of(); + var validator = Mock.Of(); + var config = new RandomizerConfig(); + var game = Mock.Of(); + var logger = Mock.Of(); + var fileSystem = new MockFileSystem(); + var map = NodeInstance.Create(); + + var session = new Session(events, mapDownloader, validator, config, game, logger, fileSystem) + { + Map = new SessionMap(map, DateTimeOffset.UtcNow, "https://tmuf.exchange/trackgbx/69") + }; + + // Act + var actual = session.ReloadMap(); + + // Assert + Assert.False(actual); + } + + [Fact] + public void ReloadMap_MapFilePathIsNotNull_ReturnsTrue() + { + // Arrange + var events = Mock.Of(); + var mapDownloader = Mock.Of(); + var validator = Mock.Of(); + var config = new RandomizerConfig(); + var game = Mock.Of(); + var logger = Mock.Of(); + var fileSystem = new MockFileSystem(); + var map = NodeInstance.Create(); + + var session = new Session(events, mapDownloader, validator, config, game, logger, fileSystem) + { + Map = new SessionMap(map, DateTimeOffset.UtcNow, "https://tmuf.exchange/trackgbx/69") { FilePath = "SomePath" } + }; + + // Act + var actual = session.ReloadMap(); + + // Assert + Assert.True(actual); + } + + [Fact] + public async Task SkipMapAsync_CancelsSkipTokenSource() + { + // Arrange + var events = Mock.Of(); + var mapDownloader = Mock.Of(); + var validator = Mock.Of(); + var config = new RandomizerConfig(); + var game = Mock.Of(); + var logger = Mock.Of(); + var fileSystem = new MockFileSystem(); + var map = NodeInstance.Create(); + + var cts = new CancellationTokenSource(); + + var session = new Session(events, mapDownloader, validator, config, game, logger, fileSystem) + { + Map = new SessionMap(map, DateTimeOffset.UtcNow, "https://tmuf.exchange/trackgbx/69") { FilePath = "SomePath" } + }; + + var task = session.PlayMapAsync(cts.Token); + + // Act + await session.SkipMapAsync(); + + // Assert + Assert.True(session.SkipTokenSource!.IsCancellationRequested); + } + + [Fact] + public void StopTrackingMap_SetsMapPropetiesToNull() + { + // Arrange + var events = Mock.Of(); + var mapDownloader = Mock.Of(); + var validator = Mock.Of(); + var config = new RandomizerConfig(); + var game = Mock.Of(); + var logger = Mock.Of(); + var fileSystem = new MockFileSystem(); + var map = NodeInstance.Create(); + + var cts = new CancellationTokenSource(); + + var session = new Session(events, mapDownloader, validator, config, game, logger, fileSystem) + { + Map = new SessionMap(map, DateTimeOffset.UtcNow, "https://tmuf.exchange/trackgbx/69") { FilePath = "SomePath" } + }; + + // This weirdness is being done because SkipTokenSource has private setter + + var task = session.PlayMapAsync(cts.Token); + + // Act + session.StopTrackingMap(); + + // Assert + Assert.Null(session.Map); + Assert.Null(session.SkipTokenSource); + } + + [Fact] + public void SkipManually_HasGold_NotClassifiedAsSkipMap() + { + // Arrange + var events = Mock.Of(); + var mapDownloader = Mock.Of(); + var validator = Mock.Of(); + var config = new RandomizerConfig(); + var game = Mock.Of(); + var logger = Mock.Of(); + var fileSystem = new MockFileSystem(); + + var map = NodeInstance.Create(); + map.MapUid = "420uid"; + + var session = new Session(events, mapDownloader, validator, config, game, logger, fileSystem) + { + Map = new SessionMap(map, DateTimeOffset.UtcNow, "https://tmuf.exchange/trackgbx/69") { FilePath = "SomePath" } + }; + + session.GoldMaps.Add(map.MapUid, session.Map); + + // Act + session.SkipManually(session.Map); + + // Assert + Assert.DoesNotContain(map.MapUid, (IDictionary)session.SkippedMaps); + } + + [Fact] + public void SkipManually_DoesNotHaveGold_ClassifiedAsSkipMap() + { + // Arrange + var events = Mock.Of(); + var mapDownloader = Mock.Of(); + var validator = Mock.Of(); + var config = new RandomizerConfig(); + var game = Mock.Of(); + var logger = Mock.Of(); + var fileSystem = new MockFileSystem(); + + var map = NodeInstance.Create(); + map.MapUid = "420uid"; + + var session = new Session(events, mapDownloader, validator, config, game, logger, fileSystem) + { + Map = new SessionMap(map, DateTimeOffset.UtcNow, "https://tmuf.exchange/trackgbx/69") { FilePath = "SomePath" } + }; + + // Act + session.SkipManually(session.Map); + + // Assert + Assert.Contains(map.MapUid, (IDictionary)session.SkippedMaps); + } + + [Fact] + public void EvaluateAutosave_NullAuthorTime_AutoSkipsMap() + { + // Arrange + var events = Mock.Of(); + var mapDownloader = Mock.Of(); + var validator = Mock.Of(); + var config = new RandomizerConfig(); + var game = Mock.Of(); + var logger = Mock.Of(); + var fileSystem = new MockFileSystem(); + + var map = NodeInstance.Create(); + map.MapUid = "420uid"; + map.ChallengeParameters = NodeInstance.Create(); + map.ChallengeParameters.AuthorTime = null; + + var cts = new CancellationTokenSource(); + + var replay = NodeInstance.Create(); + typeof(CGameCtnReplayRecord).GetField("mapInfo", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(replay, new Ident("mapuid", "Stadium", "bigbang1112")); + + var session = new Session(events, mapDownloader, validator, config, game, logger, fileSystem) + { + Map = new SessionMap(map, DateTimeOffset.UtcNow, "https://tmuf.exchange/trackgbx/69") { FilePath = "SomePath" } + }; + + var task = session.PlayMapAsync(cts.Token); + + // Act + session.EvaluateAutosave("SomeOtherPath", replay); + + // Assert + Assert.True(session.SkipTokenSource!.IsCancellationRequested); + } + + [Fact] + public void EvaluateAutosave_NoGhostInReplay_Throws() + { + // Arrange + var events = Mock.Of(); + var mapDownloader = Mock.Of(); + var validator = Mock.Of(); + var config = new RandomizerConfig(); + var game = Mock.Of(); + var logger = Mock.Of(); + var fileSystem = new MockFileSystem(); + + var map = NodeInstance.Create(); + map.Mode = CGameCtnChallenge.PlayMode.Race; + map.MapUid = "420uid"; + map.ChallengeParameters = NodeInstance.Create(); + map.ChallengeParameters.AuthorTime = new TimeInt32(69); + map.ChallengeParameters.GoldTime = new TimeInt32(420); + + var cts = new CancellationTokenSource(); + + var replay = NodeInstance.Create(); + typeof(CGameCtnReplayRecord).GetField("mapInfo", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(replay, new Ident("mapuid", "Stadium", "bigbang1112")); + + var session = new Session(events, mapDownloader, validator, config, game, logger, fileSystem) + { + Map = new SessionMap(map, DateTimeOffset.UtcNow, "https://tmuf.exchange/trackgbx/69") { FilePath = "SomePath" } + }; + + // This weirdness is being done because SkipTokenSource has private setter + + var task = session.PlayMapAsync(cts.Token); + + // Act & Assert + Assert.Throws(() => session.EvaluateAutosave("SomeOtherPath", replay)); + } + + [Fact] + public void EvaluateAutosave_IsAuthorMedal_UpdatesAndAutoSkipsMap() + { + // Arrange + var events = Mock.Of(); + var mapDownloader = Mock.Of(); + var validator = Mock.Of(); + var config = new RandomizerConfig(); + var game = Mock.Of(); + var logger = Mock.Of(); + var fileSystem = new MockFileSystem(); + + var map = NodeInstance.Create(); + map.Mode = CGameCtnChallenge.PlayMode.Race; + map.MapUid = "420uid"; + map.ChallengeParameters = NodeInstance.Create(); + map.ChallengeParameters.AuthorTime = new TimeInt32(69); + + var cts = new CancellationTokenSource(); + + var ghost = NodeInstance.Create(); + ghost.RaceTime = new(42); + + var replay = NodeInstance.Create(); + typeof(CGameCtnReplayRecord).GetField("mapInfo", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(replay, new Ident("mapuid", "Stadium", "bigbang1112")); + + var ghosts = new[] { ghost }; + typeof(CGameCtnReplayRecord).GetField("ghosts", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(replay, ghosts); + + var session = new Session(events, mapDownloader, validator, config, game, logger, fileSystem) + { + Map = new SessionMap(map, DateTimeOffset.UtcNow, "https://tmuf.exchange/trackgbx/69") { FilePath = "SomePath" } + }; + + // This weirdness is being done because SkipTokenSource has private setter + + var task = session.PlayMapAsync(cts.Token); + + // Act + session.EvaluateAutosave("SomeOtherPath", replay); + + // Assert + Assert.True(session.SkipTokenSource!.IsCancellationRequested); + Assert.DoesNotContain(map.MapUid, (IDictionary)session.GoldMaps); + Assert.Contains(map.MapUid, (IDictionary)session.AuthorMaps); + } + + [Fact] + public void EvaluateAutosave_IsGoldMedal_UpdatesMap() + { + // Arrange + var events = Mock.Of(); + var mapDownloader = Mock.Of(); + var validator = Mock.Of(); + var config = new RandomizerConfig(); + var game = Mock.Of(); + var logger = Mock.Of(); + var fileSystem = new MockFileSystem(); + + var map = NodeInstance.Create(); + map.Mode = CGameCtnChallenge.PlayMode.Race; + map.MapUid = "420uid"; + map.ChallengeParameters = NodeInstance.Create(); + map.ChallengeParameters.AuthorTime = new TimeInt32(69); + map.ChallengeParameters.GoldTime = new TimeInt32(420); + + var cts = new CancellationTokenSource(); + + var ghost = NodeInstance.Create(); + ghost.RaceTime = new(128); + + var replay = NodeInstance.Create(); + typeof(CGameCtnReplayRecord).GetField("mapInfo", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(replay, new Ident("mapuid", "Stadium", "bigbang1112")); + + var ghosts = new[] { ghost }; + typeof(CGameCtnReplayRecord).GetField("ghosts", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(replay, ghosts); + + var session = new Session(events, mapDownloader, validator, config, game, logger, fileSystem) + { + Map = new SessionMap(map, DateTimeOffset.UtcNow, "https://tmuf.exchange/trackgbx/69") { FilePath = "SomePath" } + }; + + // This weirdness is being done because SkipTokenSource has private setter + + var task = session.PlayMapAsync(cts.Token); + + // Act + session.EvaluateAutosave("SomeOtherPath", replay); + + // Assert + Assert.False(session.SkipTokenSource!.IsCancellationRequested); + Assert.Contains(map.MapUid, (IDictionary)session.GoldMaps); + Assert.DoesNotContain(map.MapUid, (IDictionary)session.AuthorMaps); + } + + [Fact] + public void AutosaveCreatedOrChanged_ReplayMapInfoIsNull_ReturnsFalse() + { + // Arrange + var events = Mock.Of(); + var mapDownloader = Mock.Of(); + var validator = Mock.Of(); + var config = new RandomizerConfig(); + var game = Mock.Of(); + var logger = Mock.Of(); + var fileSystem = new MockFileSystem(); + + var replay = NodeInstance.Create(); + + var session = new Session(events, mapDownloader, validator, config, game, logger, fileSystem); + + // Act + var result = session.AutosaveCreatedOrChanged("SomePath", replay); + + // Assert + Assert.False(result); + } + + [Fact] + public void AutosaveCreatedOrChanged_SessionMapIsNull_ReturnsFalse() + { + // Arrange + var events = Mock.Of(); + var mapDownloader = Mock.Of(); + var validator = Mock.Of(); + var config = new RandomizerConfig(); + var game = Mock.Of(); + var logger = Mock.Of(); + var fileSystem = new MockFileSystem(); + + var ghost = NodeInstance.Create(); + ghost.RaceTime = new(128); + + var replay = NodeInstance.Create(); + typeof(CGameCtnReplayRecord).GetField("mapInfo", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(replay, new Ident("mapuid", "Stadium", "bigbang1112")); + + var session = new Session(events, mapDownloader, validator, config, game, logger, fileSystem); + + // Act + var result = session.AutosaveCreatedOrChanged("SomePath", replay); + + // Assert + Assert.False(result); + } + + [Fact] + public void AutosaveCreatedOrChanged_SessionMapInfoNotMatchingReplayMapInfo_ReturnsFalse() + { + // Arrange + var events = Mock.Of(); + var mapDownloader = Mock.Of(); + var validator = Mock.Of(); + var config = new RandomizerConfig(); + var game = Mock.Of(); + var logger = Mock.Of(); + var fileSystem = new MockFileSystem(); + + var map = NodeInstance.Create(); + map.Mode = CGameCtnChallenge.PlayMode.Race; + map.MapInfo = ("420uid", "Stadium", "bigbang1112"); + map.ChallengeParameters = NodeInstance.Create(); + map.ChallengeParameters.AuthorTime = new TimeInt32(69); + map.ChallengeParameters.GoldTime = new TimeInt32(420); + + var ghost = NodeInstance.Create(); + ghost.RaceTime = new(128); + + var replay = NodeInstance.Create(); + typeof(CGameCtnReplayRecord).GetField("mapInfo", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(replay, new Ident("mapuid", "Stadium", "bigbang1112")); + + var session = new Session(events, mapDownloader, validator, config, game, logger, fileSystem) + { + Map = new SessionMap(map, DateTimeOffset.UtcNow, "https://tmuf.exchange/trackgbx/69") { FilePath = "SomePath" } + }; + + // Act + var result = session.AutosaveCreatedOrChanged("SomePath", replay); + + // Assert + Assert.False(result); + } + + [Fact] + public void AutosaveCreatedOrChanged_SessionMapInfoMatchesReplayMapInfo_ReturnsTrue() + { + // Arrange + var events = Mock.Of(); + var mapDownloader = Mock.Of(); + var validator = Mock.Of(); + var config = new RandomizerConfig(); + var game = Mock.Of(); + var logger = Mock.Of(); + var fileSystem = new MockFileSystem(); + + var map = NodeInstance.Create(); + map.Mode = CGameCtnChallenge.PlayMode.Race; + map.MapInfo = ("mapuid", "Stadium", "bigbang1112"); + map.ChallengeParameters = NodeInstance.Create(); + map.ChallengeParameters.AuthorTime = new TimeInt32(69); + map.ChallengeParameters.GoldTime = new TimeInt32(420); + + var ghost = NodeInstance.Create(); + ghost.RaceTime = new(128); + + var replay = NodeInstance.Create(); + typeof(CGameCtnReplayRecord).GetField("mapInfo", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(replay, new Ident("mapuid", "Stadium", "bigbang1112")); + + var ghosts = new[] { ghost }; + typeof(CGameCtnReplayRecord).GetField("ghosts", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(replay, ghosts); + + var session = new Session(events, mapDownloader, validator, config, game, logger, fileSystem) + { + Map = new SessionMap(map, DateTimeOffset.UtcNow, "https://tmuf.exchange/trackgbx/69") { FilePath = "SomePath" } + }; + + // Act + var result = session.AutosaveCreatedOrChanged("SomePath", replay); + + // Assert + Assert.True(result); + } +} diff --git a/Tests/RandomizerTMF.Logic.Tests/Unit/YamlTests.cs b/Tests/RandomizerTMF.Logic.Tests/Unit/YamlTests.cs new file mode 100644 index 0000000..79cc08a --- /dev/null +++ b/Tests/RandomizerTMF.Logic.Tests/Unit/YamlTests.cs @@ -0,0 +1,20 @@ +namespace RandomizerTMF.Logic.Tests.Unit; + +public class YamlTests +{ + [Fact] + public void Serializer_DoesNotThrow() + { + var exception = Record.Exception(() => Yaml.Serializer); + + Assert.Null(exception); + } + + [Fact] + public void Deserializer_DoesNotThrow() + { + var exception = Record.Exception(() => Yaml.Deserializer); + + Assert.Null(exception); + } +} diff --git a/Tests/RandomizerTMF.Logic.Tests/Usings.cs b/Tests/RandomizerTMF.Logic.Tests/Usings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/Tests/RandomizerTMF.Logic.Tests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file