diff --git a/README.md b/README.md index 0b1d03e..2da7fe0 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ The project combines features of [TMX](https://tm-exchange.com/), autosave Gbx f [![GitHub all releases](https://img.shields.io/github/downloads/BigBang1112/randomizer-tmf/total?style=for-the-badge)](https://github.com/BigBang1112/randomizer-tmf/releases) - ✔️ **50 downloads within 1 week** - Guaranteed support throughout 2023 -- **100 downloads** - Discord Rich Presence integration +- ✔️ **100 downloads** - Discord Rich Presence integration **(coming right after refactoring)** - **300 downloads** - TMUF theme - **500 downloads** - Profile management (fresh account randomization) - **2000 downloads** - Automated RMC leaderboards diff --git a/Src/RandomizerTMF.Logic/RandomizerConfig.cs b/Src/RandomizerTMF.Logic/RandomizerConfig.cs index ad67b40..e409643 100644 --- a/Src/RandomizerTMF.Logic/RandomizerConfig.cs +++ b/Src/RandomizerTMF.Logic/RandomizerConfig.cs @@ -17,4 +17,7 @@ public class RandomizerConfig /// {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 index 4cf2548..2ba8a98 100644 --- a/Src/RandomizerTMF.Logic/RandomizerEngine.cs +++ b/Src/RandomizerTMF.Logic/RandomizerEngine.cs @@ -111,7 +111,9 @@ 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(); @@ -148,7 +150,7 @@ static RandomizerEngine() }; Http = new HttpClient(socketHandler); - Http.DefaultRequestHeaders.UserAgent.TryParseAdd($"Randomizer TMF {typeof(RandomizerEngine).Assembly.GetName().Version}"); + Http.DefaultRequestHeaders.UserAgent.TryParseAdd($"Randomizer TMF {Version}"); Logger.LogInformation("Preparing general events..."); @@ -187,34 +189,52 @@ private static void AutosaveCreatedOrChanged(object sender, FileSystemEventArgs lastAutosaveUpdate = lastWriteTime; // + var retryCounter = 0; + CGameCtnReplayRecord replay; - try + while (true) { - // 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) + try { - Logger.LogWarning("Found file {file} that is not a replay.", e.FullPath); - return; - } + // Any kind of autosave update section - if (r.MapInfo is null) - { - Logger.LogWarning("Found replay {file} that has no map info.", e.FullPath); - return; + 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); - AutosaveHeaders.TryAdd(r.MapInfo.Id, new AutosaveHeader(Path.GetFileName(e.FullPath), r)); + if (retryCounter >= Config.ReplayParseFailRetries) + { + return; + } - replay = r; - } - catch (Exception ex) - { - Logger.LogError(ex, "Error while analyzing a new file {autosavePath} in autosaves folder.", e.FullPath); - return; + Thread.Sleep(Config.ReplayParseFailDelayMs); + + continue; + } + + break; } try @@ -749,7 +769,7 @@ private static void InitializeSessionData() { var startedAt = DateTimeOffset.Now; - CurrentSessionData = new SessionData(startedAt, Config.Rules); + CurrentSessionData = new SessionData(Version, startedAt, Config.Rules); if (CurrentSessionDataDirectoryPath is null) { @@ -776,12 +796,12 @@ public static void ValidateRules() { if (Config.Rules.TimeLimit == TimeSpan.Zero) { - throw new RuleValidationException("Time limit cannot be 0:00:00"); + 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"); + throw new RuleValidationException("Time limit cannot be above 9:59:59."); } foreach (var primaryType in Enum.GetValues()) @@ -790,51 +810,95 @@ public static void ValidateRules() { continue; } - + if (Config.Rules.RequestRules.PrimaryType == primaryType - && (Config.Rules.RequestRules.Site == ESite.Any + && (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} is not valid with TMNF or Nations Exchange"); + throw new RuleValidationException($"{primaryType} cannot be specifically selected with TMNF or Nations Exchange."); } } - if (Config.Rules.RequestRules.Environment is not null || Config.Rules.RequestRules.Vehicle is not null) + if (Config.Rules.RequestRules.Environment?.Count > 0) { - foreach (var env in Enum.GetValues()) + 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)) { - if (env is EEnvironment.Stadium) - { - continue; - } + 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 != ESite.Any && !Config.Rules.RequestRules.Site.HasFlag(ESite.TMNF) && !Config.Rules.RequestRules.Site.HasFlag(ESite.Nations)) - { - continue; - } - - if (Config.Rules.RequestRules.Environment?.Contains(env) == true) - { - throw new RuleValidationException($"{env} is not valid with TMNF or Nations Exchange"); - } + 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.Vehicle?.Contains(env) == true) + 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) { - throw new RuleValidationException($"{env}Car is not valid with TMNF or Nations Exchange"); + 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.HasFlag(ESite.TMNF) || Config.Rules.RequestRules.Site.HasFlag(ESite.Nations)) + && (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"); + throw new RuleValidationException("Equal environment distribution is not valid with TMNF or Nations Exchange."); } if (Config.Rules.RequestRules.EqualVehicleDistribution - && Config.Rules.RequestRules.Site.HasFlag(ESite.TMNF) || Config.Rules.RequestRules.Site.HasFlag(ESite.Nations)) + && (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"); + throw new RuleValidationException("Equal vehicle distribution is not valid with TMNF or Nations Exchange."); } } diff --git a/Src/RandomizerTMF.Logic/RandomizerTMF.Logic.csproj b/Src/RandomizerTMF.Logic/RandomizerTMF.Logic.csproj index 2979829..0d5611b 100644 --- a/Src/RandomizerTMF.Logic/RandomizerTMF.Logic.csproj +++ b/Src/RandomizerTMF.Logic/RandomizerTMF.Logic.csproj @@ -1,7 +1,7 @@ - 1.0.2 + 1.0.3 net7.0 enable enable diff --git a/Src/RandomizerTMF.Logic/RequestRules.cs b/Src/RandomizerTMF.Logic/RequestRules.cs index 926c820..38816f6 100644 --- a/Src/RandomizerTMF.Logic/RequestRules.cs +++ b/Src/RandomizerTMF.Logic/RequestRules.cs @@ -1,4 +1,5 @@ -using System.Collections; +using RandomizerTMF.Logic.Exceptions; +using System.Collections; using System.Diagnostics; using System.Reflection; using System.Text; @@ -52,17 +53,23 @@ public string ToUrl() // Not very efficient but does the job done fast enough .Where(x => x != ESite.Any && (Site & x) == x) .ToArray(); - var siteUrl = GetSiteUrl(matchingSites.Length == 0 - ? siteValues.Where(x => x is not ESite.Any).ToArray() + // If Site is Any, then it picks from sites that are valid within environments and cars + var site = GetRandomSite(matchingSites.Length == 0 + ? siteValues.Where(x => x is not ESite.Any + && IsSiteValidWithEnvironments(x) + && IsSiteValidWithVehicles(x) + && IsSiteValidWithEnvimix(x)).ToArray() : matchingSites); + var siteUrl = GetSiteUrl(site); + b.Append(siteUrl); b.Append("/trackrandom"); var first = true; - foreach (var prop in GetType().GetProperties().Where(DoesNotSkip)) + foreach (var prop in GetType().GetProperties().Where(IsQueryProperty)) { var val = prop.GetValue(this); @@ -81,6 +88,12 @@ public string ToUrl() // Not very efficient but does the job done fast enough continue; } + // Adjust url on weird combinations + if (site is ESite.TMNF or ESite.Nations && !IsValidInNations(prop, val)) + { + continue; + } + if (first) { b.Append('?'); @@ -109,13 +122,72 @@ public string ToUrl() // Not very efficient but does the job done fast enough return b.ToString(); } - private bool DoesNotSkip(PropertyInfo prop) + private bool IsQueryProperty(PropertyInfo prop) { return prop.Name is not nameof(Site) and not nameof(EqualEnvironmentDistribution) and not nameof(EqualVehicleDistribution); } + private static bool IsSiteValidWithEnvironments(ESite site, HashSet? envs) + { + if (envs is null) + { + return true; + } + + return site switch + { + ESite.Sunrise => envs.Contains(EEnvironment.Island) || envs.Contains(EEnvironment.Coast) || envs.Contains(EEnvironment.Bay), + ESite.Original => envs.Contains(EEnvironment.Snow) || envs.Contains(EEnvironment.Desert) || envs.Contains(EEnvironment.Rally), + ESite.TMNF or ESite.Nations => envs.Contains(EEnvironment.Stadium), + _ => true, + }; + } + + private bool IsSiteValidWithEnvironments(ESite site) + { + return IsSiteValidWithEnvironments(site, Environment); + } + + private bool IsSiteValidWithVehicles(ESite site) + { + return IsSiteValidWithEnvironments(site, Vehicle); + } + + private bool IsSiteValidWithEnvimix(ESite site) + { + if (site is not ESite.Sunrise and not ESite.Original || Environment is null || Environment.Count == 0) + { + return true; + } + + foreach (var env in Environment) + { + if (Vehicle?.Contains(env) == false) + { + return false; + } + } + + return true; + } + + private bool IsValidInNations(PropertyInfo prop, object val) + { + if (prop.Name is nameof(Environment) or nameof(Vehicle) && !val.Equals(EEnvironment.Stadium)) + { + return false; + } + + if (prop.Name is nameof(PrimaryType) && !val.Equals(EPrimaryType.Race)) + { + return false; + } + + return true; + } + private static EEnvironment GetRandomEnvironment(HashSet? container) { if (container is null || container.Count == 0) @@ -131,19 +203,19 @@ private static HashSet GetRandomEnvironmentThroughSet(HashSet() { GetRandomEnvironment(container) }; } - private static string GetSiteUrl(ESite[] matchingSites) + private static ESite GetRandomSite(ESite[] matchingSites) { - var randomSite = matchingSites[Random.Shared.Next(matchingSites.Length)]; - - return randomSite switch - { - ESite.Any => throw new UnreachableException("Any is not a valid site"), - ESite.TMNF => "tmnf.exchange", - ESite.TMUF => "tmuf.exchange", - _ => $"{randomSite.ToString().ToLower()}.tm-exchange.com", - }; + return matchingSites[Random.Shared.Next(matchingSites.Length)]; } + private static string GetSiteUrl(ESite site) => site switch + { + ESite.Any => throw new UnreachableException("Any is not a valid site"), + ESite.TMNF => "tmnf.exchange", + ESite.TMUF => "tmuf.exchange", + _ => $"{site.ToString().ToLower()}.tm-exchange.com", + }; + private static void AppendValue(StringBuilder b, Type type, object val, Type? genericType = null) { if (val is TimeInt32 timeInt32) diff --git a/Src/RandomizerTMF.Logic/SessionData.cs b/Src/RandomizerTMF.Logic/SessionData.cs index aa7efd3..67f0768 100644 --- a/Src/RandomizerTMF.Logic/SessionData.cs +++ b/Src/RandomizerTMF.Logic/SessionData.cs @@ -4,6 +4,7 @@ namespace RandomizerTMF.Logic; public class SessionData { + public string? Version { get; set; } public DateTimeOffset StartedAt { get; set; } public RandomizerRules Rules { get; set; } @@ -12,13 +13,14 @@ public class SessionData public List Maps { get; set; } = new(); - public SessionData() : this(DateTimeOffset.Now, new()) + public SessionData() : this(null, DateTimeOffset.Now, new()) { } - public SessionData(DateTimeOffset startedAt, RandomizerRules rules) + public SessionData(string? version, DateTimeOffset startedAt, RandomizerRules rules) { + Version = version; StartedAt = startedAt; Rules = rules; } diff --git a/Src/RandomizerTMF/RandomizerTMF.csproj b/Src/RandomizerTMF/RandomizerTMF.csproj index d4e6849..76a03dc 100644 --- a/Src/RandomizerTMF/RandomizerTMF.csproj +++ b/Src/RandomizerTMF/RandomizerTMF.csproj @@ -20,7 +20,7 @@ - 1.0.2 + 1.0.3 true true diff --git a/Src/RandomizerTMF/ViewModels/SessionDataViewModel.cs b/Src/RandomizerTMF/ViewModels/SessionDataViewModel.cs index 81e7690..2423f9d 100644 --- a/Src/RandomizerTMF/ViewModels/SessionDataViewModel.cs +++ b/Src/RandomizerTMF/ViewModels/SessionDataViewModel.cs @@ -4,8 +4,7 @@ using ReactiveUI; using System.Collections; using System.Collections.ObjectModel; -using System.Diagnostics; -using System.Linq; +using System.Reflection; using System.Text.RegularExpressions; using TmEssentials; @@ -107,7 +106,10 @@ public SessionDataViewModel(SessionDataModel model) private ObservableCollection ConstructRules() { - var rules = new ObservableCollection(); + var rules = new ObservableCollection + { + "Version: " + (Model.Data.Version is null ? "< 1.0.3" : Model.Data.Version) + }; if (Model.Data.Rules is null) // To handle sessions made in early Randomizer TMF version { @@ -121,39 +123,54 @@ private ObservableCollection ConstructRules() continue; } - rules.Add($"{ToSentenceCase(prop.Name)}: {prop.GetValue(Model.Data.Rules)}"); + AddRuleString(rules, prop, Model.Data.Rules); } foreach (var prop in Model.Data.Rules.RequestRules.GetType().GetProperties()) { - var val = prop.GetValue(Model.Data.Rules.RequestRules); + AddRuleString(rules, prop, Model.Data.Rules.RequestRules); + } + + return rules; + } + + private static void AddRuleString(IList rules, PropertyInfo prop, object owner) + { + var val = prop.GetValue(owner); + + if (val is null) + { + return; + } - if (val is null) + if (val is bool valBool) + { + if (valBool) { - continue; + rules.Add(ToSentenceCase(prop.Name)); } - if (val is not string and IEnumerable enumerable) + return; + } + + if (val is not string and IEnumerable enumerable) + { + if (enumerable.Cast().Any()) { - if (enumerable.Cast().Any()) - { - val = string.Join(", ", enumerable.Cast().Select(x => x.ToString())); - } - else - { - val = null; - } + val = string.Join(", ", enumerable.Cast().Select(x => x.ToString())); } - - if (val is TimeInt32 timeInt32) + else { - val = timeInt32.ToString(useHundredths: true); + val = null; } + } - rules.Add($"{ToSentenceCase(prop.Name)}: {val}"); + if (val is TimeInt32 timeInt32) + { + val = timeInt32.ToString(useHundredths: true); } - return rules; + rules.Add($"{ToSentenceCase(prop.Name)}: {val}"); } public void OpenSessionFolderClick() diff --git a/Src/RandomizerTMF/Views/RequestRulesControl.axaml b/Src/RandomizerTMF/Views/RequestRulesControl.axaml index 587f088..7ae4253 100644 --- a/Src/RandomizerTMF/Views/RequestRulesControl.axaml +++ b/Src/RandomizerTMF/Views/RequestRulesControl.axaml @@ -145,7 +145,7 @@ If any horizontal layer above has no buttons toggled, it simply means "any of those". + Opacity="0.5">If any horizontal layer above has no buttons toggled, it simply means "all selected".