From 09c8e9f90b2260af2b33f72138124ffbf91ecdc2 Mon Sep 17 00:00:00 2001 From: maxisoft Date: Wed, 16 Aug 2023 15:37:34 +0200 Subject: [PATCH 01/10] Randomize the collect interval This commit adds a new feature that resolves #18 by randomizing the collect interval using a normal distribution with a mean of 30 minutes and a standard deviation of 7 minutes. The random number is clamped between 11 minutes and 1 hour. The commit also refactors the ResetTimer method to take a Func parameter instead of a Timer parameter, and adds a summary and remarks for the GetRandomizedTimerDelay method. The commit uses the NextGaussian method from the newly created RandomUtils class to generate normally distributed random numbers. --- ASFFreeGames.Tests/RandomUtilsTests.cs | 77 ++++++++++++++++++++++ ASFFreeGames/ASFFreeGamesPlugin.cs | 43 +++++++++++- ASFFreeGames/RandomUtils.cs | 91 ++++++++++++++++++++++++++ 3 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 ASFFreeGames.Tests/RandomUtilsTests.cs create mode 100644 ASFFreeGames/RandomUtils.cs diff --git a/ASFFreeGames.Tests/RandomUtilsTests.cs b/ASFFreeGames.Tests/RandomUtilsTests.cs new file mode 100644 index 0000000..753d9b8 --- /dev/null +++ b/ASFFreeGames.Tests/RandomUtilsTests.cs @@ -0,0 +1,77 @@ +#pragma warning disable CA1707 // Identifiers should not contain underscores +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Xunit; + +namespace Maxisoft.ASF.Tests; + +public class RandomUtilsTests { + // A static method to provide test data for the theory + public static TheoryData GetTestData() => + new TheoryData { + // mean, std, sample size, margin of error + { 0, 1, 1000, 0.05 }, // original test case + { 10, 2, 1000, 0.1 }, // original test case + { -5, 3, 5000, 0.15 }, // additional test case + { 20, 5, 10000, 0.2 } // additional test case + }; + + // A test method to check if the mean and standard deviation of the normal distribution are close to the expected values + [Theory] + [MemberData(nameof(GetTestData))] + [SuppressMessage("ReSharper", "InconsistentNaming")] + public void NextGaussian_Should_Have_Expected_Mean_And_Std(double mean, double standardDeviation, int sampleSize, double marginOfError) { + // Arrange + using RandomUtils.GaussianRandom rng = new(); + + // Act + // Generate a large number of samples from the normal distribution + double[] samples = Enumerable.Range(0, sampleSize).Select(_ => rng.NextGaussian(mean, standardDeviation)).ToArray(); + + // Calculate the sample mean and sample standard deviation using local functions + double sampleMean = Mean(samples); + double sampleStd = StandardDeviation(samples); + + // Assert + // Check if the sample mean and sample standard deviation are close to the expected values within the margin of error + Assert.InRange(sampleMean, mean - marginOfError, mean + marginOfError); + Assert.InRange(sampleStd, standardDeviation - marginOfError, standardDeviation + marginOfError); + } + + // Local function to calculate the mean of a span of doubles + private static double Mean(ReadOnlySpan values) { + // Check if the span is empty + if (values.IsEmpty) { + // Throw an exception + throw new InvalidOperationException("The span is empty."); + } + + // Sum up all the values + double sum = 0; + + foreach (double value in values) { + sum += value; + } + + // Divide by the number of values + return sum / values.Length; + } + + // Local function to calculate the standard deviation of a span of doubles + private static double StandardDeviation(ReadOnlySpan values) { + // Calculate the mean using the local function + double mean = Mean(values); + + // Sum up the squares of the differences from the mean + double sumOfSquares = 0; + + foreach (double value in values) { + sumOfSquares += (value - mean) * (value - mean); + } + + // Divide by the number of values and take the square root + return Math.Sqrt(sumOfSquares / values.Length); + } +} +#pragma warning restore CA1707 // Identifiers should not contain underscores diff --git a/ASFFreeGames/ASFFreeGamesPlugin.cs b/ASFFreeGames/ASFFreeGamesPlugin.cs index 1e7dab3..b02a2ad 100644 --- a/ASFFreeGames/ASFFreeGamesPlugin.cs +++ b/ASFFreeGames/ASFFreeGamesPlugin.cs @@ -82,6 +82,12 @@ public Task OnLoaded() { public Task OnUpdateProceeding(Version currentVersion, Version newVersion) => Task.CompletedTask; private async void CollectGamesOnClock(object? source) { + // Calculate a random delay using GetRandomizedTimerDelay method + TimeSpan delay = GetRandomizedTimerDelay(); + + // Reset the timer with the new delay + ResetTimer(() => new Timer(CollectGamesOnClock, source, delay, delay)); + if ((Bots.Count > 0) && (Context.Bots.Count != Bots.Count)) { Context = new PluginContext(Bots, BotContextRegistry, Options, LoggerFilter, new Lazy(() => CancellationTokenSourceLazy.Value.Token)); } @@ -109,6 +115,33 @@ private async void CollectGamesOnClock(object? source) { } } + private static readonly RandomUtils.GaussianRandom Random = new(); + + /// + /// Calculates a random delay using a normal distribution with a mean of 30 minutes and a standard deviation of 7 minutes. + /// + /// The randomized delay. + /// + /// The random number is clamped between 11 minutes and 1 hour. + /// This method uses the NextGaussian method from the RandomUtils class to generate normally distributed random numbers. + /// + private static TimeSpan GetRandomizedTimerDelay() { + double randomNumber = Random.NextGaussian(30 * 60, 7 * 60); + TimeSpan delay = TimeSpan.FromSeconds(randomNumber); + + // Convert delay to seconds + double delaySeconds = delay.TotalSeconds; + + // Clamp the delay between 11 minutes and 1 hour in seconds + delaySeconds = Math.Max(delaySeconds, 11 * 60); + delaySeconds = Math.Min(delaySeconds, 60 * 60); + + // Convert delay back to TimeSpan + delay = TimeSpan.FromSeconds(delaySeconds); + + return delay; + } + private async Task RegisterBot(Bot bot) { Bots.Add(bot); @@ -144,9 +177,13 @@ private async Task RemoveBot(Bot bot) { Context.LoggerFilter.RemoveFilters(bot); } - private void ResetTimer(Timer? newTimer = null) { + private void ResetTimer(Func? newTimerFactory = null) { Timer?.Dispose(); - Timer = newTimer; + Timer = null; + + if (newTimerFactory is not null) { + Timer = newTimerFactory(); + } } private async Task SaveOptions(CancellationToken cancellationToken) { @@ -159,7 +196,7 @@ private async Task SaveOptions(CancellationToken cancellationToken) { private void StartTimerIfNeeded() { if (Timer is null) { TimeSpan delay = Options.RecheckInterval; - ResetTimer(new Timer(CollectGamesOnClock)); + ResetTimer(() => new Timer(CollectGamesOnClock)); Timer?.Change(TimeSpan.FromSeconds(30), delay); } } diff --git a/ASFFreeGames/RandomUtils.cs b/ASFFreeGames/RandomUtils.cs new file mode 100644 index 0000000..e9ef9ae --- /dev/null +++ b/ASFFreeGames/RandomUtils.cs @@ -0,0 +1,91 @@ +using System; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography; + +namespace Maxisoft.ASF; + +#nullable enable + +public static class RandomUtils { + /// + /// Generates a random number from a normal distribution with the specified mean and standard deviation. + /// + /// The random number generator to use. + /// The mean of the normal distribution. + /// The standard deviation of the normal distribution. + /// A random number from the normal distribution. + /// + /// This method uses the Box-Muller transform to convert two uniformly distributed random numbers into two normally distributed random numbers. + /// + public static double NextGaussian([NotNull] this RandomNumberGenerator random, double mean, double standardDeviation) { + Debug.Assert(random != null, nameof(random) + " != null"); + + // Generate two uniform random numbers + Span bytes = stackalloc byte[8]; + random.GetBytes(bytes); + double u1 = BitConverter.ToUInt32(bytes) / (double) uint.MaxValue; + random.GetBytes(bytes); + double u2 = BitConverter.ToUInt32(bytes) / (double) uint.MaxValue; + + // Apply the Box-Muller formula + double randStdNormal = Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Sin(2.0 * Math.PI * u2); + + // Scale and shift to get a random number with the desired mean and standard deviation + double randNormal = mean + (standardDeviation * randStdNormal); + + return randNormal; + } + + internal sealed class GaussianRandom : RandomNumberGenerator { + // A flag to indicate if there is a stored value for the next Gaussian number + private bool HasNextGaussian; + + // The stored value for the next Gaussian number + private double NextGaussianValue; + + public override void GetBytes(byte[] data) => Fill(data); + + public override void GetNonZeroBytes(byte[] data) => Fill(data); + + private double NextDouble() { + if (HasNextGaussian) { + HasNextGaussian = false; + + return NextGaussianValue; + } + + // Generate two uniform random numbers + Span bytes = stackalloc byte[8]; + GetBytes(bytes); + float u1 = BitConverter.ToUInt32(bytes) / (float) uint.MaxValue; + GetBytes(bytes); + float u2 = BitConverter.ToUInt32(bytes) / (float) uint.MaxValue; + + // Apply the Box-Muller formula + float r = MathF.Sqrt(-2.0f * MathF.Log(u1)); + float theta = 2.0f * MathF.PI * u2; + + // Store one of the values for next time + NextGaussianValue = r * MathF.Sin(theta); + HasNextGaussian = true; + + // Return the other value + return r * MathF.Cos(theta); + } + + /// + /// Generates a random number from a normal distribution with the specified mean and standard deviation. + /// + /// The mean of the normal distribution. + /// The standard deviation of the normal distribution. + /// A random number from the normal distribution. + /// + /// This method uses the overridden NextDouble method to get a normally distributed random number. + /// + public double NextGaussian(double mean, double standardDeviation) => + + // Use the overridden NextDouble method to get a normally distributed random number + mean + (standardDeviation * NextDouble()); + } +} From cc04eaf2381a839fbc8fa13a295a0a0e31fa240c Mon Sep 17 00:00:00 2001 From: maxisoft Date: Tue, 22 Aug 2023 11:05:30 +0200 Subject: [PATCH 02/10] Improve flexibility and randomness of collect interval This commit improves the flexibility and randomness of the collect interval by refactoring the GetRandomizedTimerDelay method and adding four parameters to it: meanSeconds, stdSeconds, minSeconds, and maxSeconds. These parameters allow the caller to specify the mean, standard deviation, minimum, and maximum values of the normal distribution used to generate the random delay. The commit also updates the documentation of the method to reflect the new parameters and their units. It adds a seealso section to link to an external source that explains how to implement the NextGaussian method in C#. The commit also modifies the StartTimerIfNeeded method to use the GetRandomizedTimerDelay method with different parameters for the initial and regular delays. This adds more randomness to the first collect operation, which will happen at a random time within 1 second and 5 minutes after starting the plugin. --- ASFFreeGames/ASFFreeGamesPlugin.cs | 35 +++++++++++++++++++----------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/ASFFreeGames/ASFFreeGamesPlugin.cs b/ASFFreeGames/ASFFreeGamesPlugin.cs index b02a2ad..0da59b1 100644 --- a/ASFFreeGames/ASFFreeGamesPlugin.cs +++ b/ASFFreeGames/ASFFreeGamesPlugin.cs @@ -118,23 +118,35 @@ private async void CollectGamesOnClock(object? source) { private static readonly RandomUtils.GaussianRandom Random = new(); /// - /// Calculates a random delay using a normal distribution with a mean of 30 minutes and a standard deviation of 7 minutes. + /// Calculates a random delay using a normal distribution with a mean of Options.RecheckInterval.TotalSeconds and a standard deviation of 7 minutes. /// /// The randomized delay. + /// + private TimeSpan GetRandomizedTimerDelay() => GetRandomizedTimerDelay(Options.RecheckInterval.TotalSeconds, 7 * 60); + + /// + /// Calculates a random delay using a normal distribution with a given mean and standard deviation. + /// + /// The mean of the normal distribution in seconds. + /// The standard deviation of the normal distribution in seconds. + /// The minimum value of the random delay in seconds. The default value is 11 minutes. + /// The maximum value of the random delay in seconds. The default value is 1 hour. + /// The randomized delay. /// - /// The random number is clamped between 11 minutes and 1 hour. + /// The random number is clamped between the minSeconds and maxSeconds parameters. /// This method uses the NextGaussian method from the RandomUtils class to generate normally distributed random numbers. + /// See [Random nextGaussian() method in Java with Examples] for more details on how to implement NextGaussian in C#. /// - private static TimeSpan GetRandomizedTimerDelay() { - double randomNumber = Random.NextGaussian(30 * 60, 7 * 60); + private static TimeSpan GetRandomizedTimerDelay(double meanSeconds, double stdSeconds, double minSeconds = 11 * 60, double maxSeconds = 60 * 60) { + double randomNumber = Random.NextGaussian(meanSeconds, stdSeconds); TimeSpan delay = TimeSpan.FromSeconds(randomNumber); // Convert delay to seconds double delaySeconds = delay.TotalSeconds; - // Clamp the delay between 11 minutes and 1 hour in seconds - delaySeconds = Math.Max(delaySeconds, 11 * 60); - delaySeconds = Math.Min(delaySeconds, 60 * 60); + // Clamp the delay between minSeconds and maxSeconds in seconds + delaySeconds = Math.Max(delaySeconds, minSeconds); + delaySeconds = Math.Min(delaySeconds, maxSeconds); // Convert delay back to TimeSpan delay = TimeSpan.FromSeconds(delaySeconds); @@ -195,16 +207,13 @@ private async Task SaveOptions(CancellationToken cancellationToken) { private void StartTimerIfNeeded() { if (Timer is null) { - TimeSpan delay = Options.RecheckInterval; + TimeSpan delay = GetRandomizedTimerDelay(); ResetTimer(() => new Timer(CollectGamesOnClock)); - Timer?.Change(TimeSpan.FromSeconds(30), delay); + Timer?.Change(GetRandomizedTimerDelay(30, 6, 1, 5 * 60), delay); } } - ~ASFFreeGamesPlugin() { - Timer?.Dispose(); - Timer = null; - } + ~ASFFreeGamesPlugin() => ResetTimer(); } #pragma warning restore CA1812 // ASF uses this class during runtime From ac2ab4672a25d00b013a0583214de6c9e67288de Mon Sep 17 00:00:00 2001 From: maxisoft Date: Tue, 22 Aug 2023 11:17:16 +0200 Subject: [PATCH 03/10] This commit simplifies the code by using the CancellationToken property of the ASFFreeGamesPlugin class instead of CancellationTokenSourceLazy --- ASFFreeGames/ASFFreeGamesPlugin.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ASFFreeGames/ASFFreeGamesPlugin.cs b/ASFFreeGames/ASFFreeGamesPlugin.cs index 0da59b1..94e9a3e 100644 --- a/ASFFreeGames/ASFFreeGamesPlugin.cs +++ b/ASFFreeGames/ASFFreeGamesPlugin.cs @@ -159,11 +159,11 @@ private async Task RegisterBot(Bot bot) { StartTimerIfNeeded(); - await BotContextRegistry.SaveBotContext(bot, new BotContext(bot), CancellationTokenSourceLazy.Value.Token).ConfigureAwait(false); + await BotContextRegistry.SaveBotContext(bot, new BotContext(bot), CancellationToken).ConfigureAwait(false); BotContext? ctx = BotContextRegistry.GetBotContext(bot); if (ctx is not null) { - await ctx.LoadFromFileSystem(CancellationTokenSourceLazy.Value.Token).ConfigureAwait(false); + await ctx.LoadFromFileSystem(CancellationToken).ConfigureAwait(false); } } From 2a3460e0c09968f631b9ae45171733433943e8f4 Mon Sep 17 00:00:00 2001 From: maxisoft Date: Tue, 22 Aug 2023 11:34:31 +0200 Subject: [PATCH 04/10] Add option to randomize collect interval and optimize performance This commit adds a new option to the ASFFreeGamesOptions class called RandomizeRecheckInterval, which is a nullable bool that indicates whether to randomize the collect interval or not. The default value is true, which means the collect interval will be randomized by default. The commit also modifies the ASFFreeGamesPlugin class to use the RandomizeRecheckInterval option to determine the value of the RandomizeIntervalSwitch property, which is used to multiply the standard deviation of the normal distribution used to generate the random delay in the GetRandomizedTimerDelay method. If the option is false or null, then the random delay will be equal to the mean value. The commit also optimizes the performance of the GetRandomizedTimerDelay method by checking if the standard deviation parameter is zero before calling the Random.NextGaussian method. If it is zero, then the random number will be equal to the mean parameter, and there is no need to generate a random number from a normal distribution. This can save some computation time and resources. --- ASFFreeGames/ASFFreeGamesPlugin.cs | 20 ++++++++++++++++--- .../Configurations/ASFFreeGamesOptions.cs | 2 +- .../ASFFreeGamesOptionsLoader.cs | 3 +-- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/ASFFreeGames/ASFFreeGamesPlugin.cs b/ASFFreeGames/ASFFreeGamesPlugin.cs index 94e9a3e..33f0e28 100644 --- a/ASFFreeGames/ASFFreeGamesPlugin.cs +++ b/ASFFreeGames/ASFFreeGamesPlugin.cs @@ -122,7 +122,18 @@ private async void CollectGamesOnClock(object? source) { /// /// The randomized delay. /// - private TimeSpan GetRandomizedTimerDelay() => GetRandomizedTimerDelay(Options.RecheckInterval.TotalSeconds, 7 * 60); + private TimeSpan GetRandomizedTimerDelay() => GetRandomizedTimerDelay(Options.RecheckInterval.TotalSeconds, 7 * 60 * RandomizeIntervalSwitch); + + /// + /// Gets a value that indicates whether to randomize the collect interval or not. + /// + /// + /// A value of 1 if Options.RandomizeRecheckInterval is true or null, or a value of 0 otherwise. + /// + /// + /// This property is used to multiply the standard deviation of the normal distribution used to generate the random delay in the GetRandomizedTimerDelay method. If this property returns 0, then the random delay will be equal to the mean value. + /// + private int RandomizeIntervalSwitch => (Options.RandomizeRecheckInterval ?? true ? 1 : 0); /// /// Calculates a random delay using a normal distribution with a given mean and standard deviation. @@ -138,7 +149,10 @@ private async void CollectGamesOnClock(object? source) { /// See [Random nextGaussian() method in Java with Examples] for more details on how to implement NextGaussian in C#. /// private static TimeSpan GetRandomizedTimerDelay(double meanSeconds, double stdSeconds, double minSeconds = 11 * 60, double maxSeconds = 60 * 60) { - double randomNumber = Random.NextGaussian(meanSeconds, stdSeconds); + double randomNumber; + + randomNumber = stdSeconds != 0 ? Random.NextGaussian(meanSeconds, stdSeconds) : meanSeconds; + TimeSpan delay = TimeSpan.FromSeconds(randomNumber); // Convert delay to seconds @@ -209,7 +223,7 @@ private void StartTimerIfNeeded() { if (Timer is null) { TimeSpan delay = GetRandomizedTimerDelay(); ResetTimer(() => new Timer(CollectGamesOnClock)); - Timer?.Change(GetRandomizedTimerDelay(30, 6, 1, 5 * 60), delay); + Timer?.Change(GetRandomizedTimerDelay(30, 6 * RandomizeIntervalSwitch, 1, 5 * 60), delay); } } diff --git a/ASFFreeGames/Configurations/ASFFreeGamesOptions.cs b/ASFFreeGames/Configurations/ASFFreeGamesOptions.cs index 94be7e4..bebd9b7 100644 --- a/ASFFreeGames/Configurations/ASFFreeGamesOptions.cs +++ b/ASFFreeGames/Configurations/ASFFreeGamesOptions.cs @@ -14,7 +14,7 @@ public class ASFFreeGamesOptions { // Use Nullable instead of bool? for nullable value types [JsonProperty("randomizeRecheckInterval")] - public Nullable RandomizeRecheckInterval { get; set; } + public Nullable RandomizeRecheckInterval { get; set; } [JsonProperty("skipFreeToPlay")] public Nullable SkipFreeToPlay { get; set; } diff --git a/ASFFreeGames/Configurations/ASFFreeGamesOptionsLoader.cs b/ASFFreeGames/Configurations/ASFFreeGamesOptionsLoader.cs index ec18c8e..947c4ae 100644 --- a/ASFFreeGames/Configurations/ASFFreeGamesOptionsLoader.cs +++ b/ASFFreeGames/Configurations/ASFFreeGamesOptionsLoader.cs @@ -25,8 +25,7 @@ public static void Bind(ref ASFFreeGamesOptions options) { options.RecheckInterval = TimeSpan.FromMilliseconds(configurationRoot.GetValue("RecheckIntervalMs", options.RecheckInterval.TotalMilliseconds)); options.SkipFreeToPlay = configurationRoot.GetValue("SkipFreeToPlay", options.SkipFreeToPlay); options.SkipDLC = configurationRoot.GetValue("SkipDLC", options.SkipDLC); - double? randomizeRecheckInterval = configurationRoot.GetValue("RandomizeRecheckIntervalMs", options.RandomizeRecheckInterval?.TotalMilliseconds); - options.RandomizeRecheckInterval = randomizeRecheckInterval is not null ? TimeSpan.FromMilliseconds(randomizeRecheckInterval.Value) : null; + options.RandomizeRecheckInterval = configurationRoot.GetValue("RandomizeRecheckInterval", options.RandomizeRecheckInterval); } finally { Semaphore.Release(); From d9254b5fe38500407e721606b722edb32d86a889 Mon Sep 17 00:00:00 2001 From: maxisoft Date: Tue, 22 Aug 2023 12:13:57 +0200 Subject: [PATCH 05/10] Extract interface from ASFFreeGamesPlugin and move timer logic to CollectIntervalManager This commit extracts an interface called IASFFreeGamesPlugin from the ASFFreeGamesPlugin class, which contains the members that are used by the CollectIntervalManager class. This interface is implemented by the ASFFreeGamesPlugin class and passed as a parameter to the constructor of the CollectIntervalManager class. This way, the CollectIntervalManager class can access the plugin's options and methods without depending on its concrete implementation. This commit also moves the timer and random delay logic from the ASFFreeGamesPlugin class to the CollectIntervalManager class, which encapsulates the functionality of managing the collect interval. The CollectIntervalManager class has methods to start, stop, and reset the timer with a random initial and regular delay. The timer's callback is the CollectGamesOnClock method of the plugin. This commit refactors and simplifies the code by separating concerns. --- ASFFreeGames/ASFFreeGamesPlugin.cs | 99 +++++------------------ ASFFreeGames/CollectIntervalManager.cs | 104 +++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 81 deletions(-) create mode 100644 ASFFreeGames/CollectIntervalManager.cs diff --git a/ASFFreeGames/ASFFreeGamesPlugin.cs b/ASFFreeGames/ASFFreeGamesPlugin.cs index 33f0e28..0816093 100644 --- a/ASFFreeGames/ASFFreeGamesPlugin.cs +++ b/ASFFreeGames/ASFFreeGamesPlugin.cs @@ -16,10 +16,17 @@ namespace Maxisoft.ASF; +internal interface IASFFreeGamesPlugin { + internal Version Version { get; } + internal ASFFreeGamesOptions Options { get; } + + internal void CollectGamesOnClock(object? source); +} + #pragma warning disable CA1812 // ASF uses this class during runtime [UsedImplicitly] [SuppressMessage("Design", "CA1001:Disposable fields")] -internal sealed class ASFFreeGamesPlugin : IASF, IBot, IBotConnection, IBotCommand2, IUpdateAware { +internal sealed class ASFFreeGamesPlugin : IASF, IBot, IBotConnection, IBotCommand2, IUpdateAware, IASFFreeGamesPlugin { internal const string StaticName = nameof(ASFFreeGamesPlugin); private const int CollectGamesTimeout = 3 * 60 * 1000; @@ -44,17 +51,19 @@ internal static PluginContext Context { private bool VerboseLog => Options.VerboseLog ?? true; private readonly ContextRegistry BotContextRegistry = new(); - private ASFFreeGamesOptions Options = new(); + public ASFFreeGamesOptions Options => OptionsField; + private ASFFreeGamesOptions OptionsField = new(); - private Timer? Timer; + private readonly CollectIntervalManager CollectIntervalManager; public ASFFreeGamesPlugin() { CommandDispatcher = new CommandDispatcher(Options); + CollectIntervalManager = new CollectIntervalManager(this); _context.Value = new PluginContext(Bots, BotContextRegistry, Options, LoggerFilter, new Lazy(() => CancellationTokenSourceLazy.Value.Token)); } public async Task OnASFInit(IReadOnlyDictionary? additionalConfigProperties = null) { - ASFFreeGamesOptionsLoader.Bind(ref Options); + ASFFreeGamesOptionsLoader.Bind(ref OptionsField); Options.VerboseLog ??= GlobalDatabase?.LoadFromJsonStorage($"{Name}.Verbose")?.ToObject() ?? Options.VerboseLog; await SaveOptions(CancellationToken).ConfigureAwait(false); } @@ -81,12 +90,8 @@ public Task OnLoaded() { public Task OnUpdateProceeding(Version currentVersion, Version newVersion) => Task.CompletedTask; - private async void CollectGamesOnClock(object? source) { - // Calculate a random delay using GetRandomizedTimerDelay method - TimeSpan delay = GetRandomizedTimerDelay(); - - // Reset the timer with the new delay - ResetTimer(() => new Timer(CollectGamesOnClock, source, delay, delay)); + public async void CollectGamesOnClock(object? source) { + CollectIntervalManager.RandomlyChangeCollectInterval(source); if ((Bots.Count > 0) && (Context.Bots.Count != Bots.Count)) { Context = new PluginContext(Bots, BotContextRegistry, Options, LoggerFilter, new Lazy(() => CancellationTokenSourceLazy.Value.Token)); @@ -115,59 +120,6 @@ private async void CollectGamesOnClock(object? source) { } } - private static readonly RandomUtils.GaussianRandom Random = new(); - - /// - /// Calculates a random delay using a normal distribution with a mean of Options.RecheckInterval.TotalSeconds and a standard deviation of 7 minutes. - /// - /// The randomized delay. - /// - private TimeSpan GetRandomizedTimerDelay() => GetRandomizedTimerDelay(Options.RecheckInterval.TotalSeconds, 7 * 60 * RandomizeIntervalSwitch); - - /// - /// Gets a value that indicates whether to randomize the collect interval or not. - /// - /// - /// A value of 1 if Options.RandomizeRecheckInterval is true or null, or a value of 0 otherwise. - /// - /// - /// This property is used to multiply the standard deviation of the normal distribution used to generate the random delay in the GetRandomizedTimerDelay method. If this property returns 0, then the random delay will be equal to the mean value. - /// - private int RandomizeIntervalSwitch => (Options.RandomizeRecheckInterval ?? true ? 1 : 0); - - /// - /// Calculates a random delay using a normal distribution with a given mean and standard deviation. - /// - /// The mean of the normal distribution in seconds. - /// The standard deviation of the normal distribution in seconds. - /// The minimum value of the random delay in seconds. The default value is 11 minutes. - /// The maximum value of the random delay in seconds. The default value is 1 hour. - /// The randomized delay. - /// - /// The random number is clamped between the minSeconds and maxSeconds parameters. - /// This method uses the NextGaussian method from the RandomUtils class to generate normally distributed random numbers. - /// See [Random nextGaussian() method in Java with Examples] for more details on how to implement NextGaussian in C#. - /// - private static TimeSpan GetRandomizedTimerDelay(double meanSeconds, double stdSeconds, double minSeconds = 11 * 60, double maxSeconds = 60 * 60) { - double randomNumber; - - randomNumber = stdSeconds != 0 ? Random.NextGaussian(meanSeconds, stdSeconds) : meanSeconds; - - TimeSpan delay = TimeSpan.FromSeconds(randomNumber); - - // Convert delay to seconds - double delaySeconds = delay.TotalSeconds; - - // Clamp the delay between minSeconds and maxSeconds in seconds - delaySeconds = Math.Max(delaySeconds, minSeconds); - delaySeconds = Math.Min(delaySeconds, maxSeconds); - - // Convert delay back to TimeSpan - delay = TimeSpan.FromSeconds(delaySeconds); - - return delay; - } - private async Task RegisterBot(Bot bot) { Bots.Add(bot); @@ -197,21 +149,12 @@ private async Task RemoveBot(Bot bot) { } if (Bots.Count == 0) { - ResetTimer(); + CollectIntervalManager.StopTimer(); } Context.LoggerFilter.RemoveFilters(bot); } - private void ResetTimer(Func? newTimerFactory = null) { - Timer?.Dispose(); - Timer = null; - - if (newTimerFactory is not null) { - Timer = newTimerFactory(); - } - } - private async Task SaveOptions(CancellationToken cancellationToken) { if (!cancellationToken.IsCancellationRequested) { const string cmd = $"FREEGAMES {FreeGamesCommand.SaveOptionsInternalCommandString}"; @@ -219,15 +162,9 @@ private async Task SaveOptions(CancellationToken cancellationToken) { } } - private void StartTimerIfNeeded() { - if (Timer is null) { - TimeSpan delay = GetRandomizedTimerDelay(); - ResetTimer(() => new Timer(CollectGamesOnClock)); - Timer?.Change(GetRandomizedTimerDelay(30, 6 * RandomizeIntervalSwitch, 1, 5 * 60), delay); - } - } + private void StartTimerIfNeeded() => CollectIntervalManager.StartTimerIfNeeded(); - ~ASFFreeGamesPlugin() => ResetTimer(); + ~ASFFreeGamesPlugin() => CollectIntervalManager.Dispose(); } #pragma warning restore CA1812 // ASF uses this class during runtime diff --git a/ASFFreeGames/CollectIntervalManager.cs b/ASFFreeGames/CollectIntervalManager.cs new file mode 100644 index 0000000..e9bac16 --- /dev/null +++ b/ASFFreeGames/CollectIntervalManager.cs @@ -0,0 +1,104 @@ +using System; +using System.Threading; + +namespace Maxisoft.ASF; + +internal sealed class CollectIntervalManager : IDisposable { + private static readonly RandomUtils.GaussianRandom Random = new(); + + /// + /// Gets a value that indicates whether to randomize the collect interval or not. + /// + /// + /// A value of 1 if Options.RandomizeRecheckInterval is true or null, or a value of 0 otherwise. + /// + /// + /// This property is used to multiply the standard deviation of the normal distribution used to generate the random delay in the GetRandomizedTimerDelay method. If this property returns 0, then the random delay will be equal to the mean value. + /// + private int RandomizeIntervalSwitch => Plugin.Options.RandomizeRecheckInterval ?? true ? 1 : 0; + + // The reference to the plugin instance + private readonly IASFFreeGamesPlugin Plugin; + + // The timer instance + private Timer? Timer; + + // The constructor that takes a plugin instance as a parameter + public CollectIntervalManager(IASFFreeGamesPlugin plugin) => Plugin = plugin; + + public void Dispose() => StopTimer(); + + // The public method that starts the timer if needed + public void StartTimerIfNeeded() { + if (Timer is null) { + // Get a random initial delay + TimeSpan initialDelay = GetRandomizedTimerDelay(30, 6 * RandomizeIntervalSwitch, 1, 5 * 60); + + // Get a random regular delay + TimeSpan regularDelay = GetRandomizedTimerDelay(Plugin.Options.RecheckInterval.TotalSeconds, 7 * 60 * RandomizeIntervalSwitch); + + // Create a new timer with the collect operation as the callback + Timer = new Timer(Plugin.CollectGamesOnClock); + + // Start the timer with the initial and regular delays + Timer.Change(initialDelay, regularDelay); + } + } + + /// + /// Calculates a random delay using a normal distribution with a mean of Options.RecheckInterval.TotalSeconds and a standard deviation of 7 minutes. + /// + /// The randomized delay. + /// + private TimeSpan GetRandomizedTimerDelay() => GetRandomizedTimerDelay(Plugin.Options.RecheckInterval.TotalSeconds, 7 * 60 * RandomizeIntervalSwitch); + + internal TimeSpan RandomlyChangeCollectInterval(object? source) { + // Calculate a random delay using GetRandomizedTimerDelay method + TimeSpan delay = GetRandomizedTimerDelay(); + ResetTimer(() => new Timer(state => Plugin.CollectGamesOnClock(state), source, delay, delay)); + + return delay; + } + + internal void StopTimer() => ResetTimer(null); + + /// + /// Calculates a random delay using a normal distribution with a given mean and standard deviation. + /// + /// The mean of the normal distribution in seconds. + /// The standard deviation of the normal distribution in seconds. + /// The minimum value of the random delay in seconds. The default value is 11 minutes. + /// The maximum value of the random delay in seconds. The default value is 1 hour. + /// The randomized delay. + /// + /// The random number is clamped between the minSeconds and maxSeconds parameters. + /// This method uses the NextGaussian method from the RandomUtils class to generate normally distributed random numbers. + /// See [Random nextGaussian() method in Java with Examples] for more details on how to implement NextGaussian in C#. + /// + private static TimeSpan GetRandomizedTimerDelay(double meanSeconds, double stdSeconds, double minSeconds = 11 * 60, double maxSeconds = 60 * 60) { + double randomNumber = stdSeconds != 0 ? Random.NextGaussian(meanSeconds, stdSeconds) : meanSeconds; + + TimeSpan delay = TimeSpan.FromSeconds(randomNumber); + + // Convert delay to seconds + double delaySeconds = delay.TotalSeconds; + + // Clamp the delay between minSeconds and maxSeconds in seconds + delaySeconds = Math.Max(delaySeconds, minSeconds); + delaySeconds = Math.Min(delaySeconds, maxSeconds); + + // Convert delay back to TimeSpan + delay = TimeSpan.FromSeconds(delaySeconds); + + return delay; + } + + private void ResetTimer(Func? newTimerFactory) { + Timer?.Dispose(); + Timer = null; + + if (newTimerFactory is not null) { + Timer = newTimerFactory(); + } + } +} From 000979a4fda74b65e8d3f05c6bacffbe850ce3d9 Mon Sep 17 00:00:00 2001 From: maxisoft Date: Tue, 22 Aug 2023 12:20:53 +0200 Subject: [PATCH 06/10] Extract interface from CollectIntervalManager and use it in ASFFreeGamesPlugin This commit extracts an interface called ICollectIntervalManager from the CollectIntervalManager class, which defines the contract for managing the collect interval for the ASFFreeGamesPlugin. The interface contains the public and internal methods and properties of the CollectIntervalManager class, and is documented using XML comments. The commit also modifies the ASFFreeGamesPlugin class to use the ICollectIntervalManager interface instead of the CollectIntervalManager class as a field. The plugin's constructor creates an instance of the CollectIntervalManager class and passes it as an argument to the ICollectIntervalManager field. This way, the plugin can access the collect interval manager's functionality without depending on its concrete implementation. This commit improves the code quality by following the dependency inversion principle, which states that high-level modules should not depend on low-level modules, but both should depend on abstractions. This makes the code more loosely coupled and easier to test and maintain. --- ASFFreeGames/ASFFreeGamesPlugin.cs | 2 +- ASFFreeGames/CollectIntervalManager.cs | 30 +++++++++++++++++++++++--- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/ASFFreeGames/ASFFreeGamesPlugin.cs b/ASFFreeGames/ASFFreeGamesPlugin.cs index 0816093..bcf2ce9 100644 --- a/ASFFreeGames/ASFFreeGamesPlugin.cs +++ b/ASFFreeGames/ASFFreeGamesPlugin.cs @@ -54,7 +54,7 @@ internal static PluginContext Context { public ASFFreeGamesOptions Options => OptionsField; private ASFFreeGamesOptions OptionsField = new(); - private readonly CollectIntervalManager CollectIntervalManager; + private readonly ICollectIntervalManager CollectIntervalManager; public ASFFreeGamesPlugin() { CommandDispatcher = new CommandDispatcher(Options); diff --git a/ASFFreeGames/CollectIntervalManager.cs b/ASFFreeGames/CollectIntervalManager.cs index e9bac16..f4fb486 100644 --- a/ASFFreeGames/CollectIntervalManager.cs +++ b/ASFFreeGames/CollectIntervalManager.cs @@ -3,7 +3,31 @@ namespace Maxisoft.ASF; -internal sealed class CollectIntervalManager : IDisposable { +// The interface that defines the contract for the CollectIntervalManager class +/// +/// +/// An interface that provides methods to manage the collect interval for the ASFFreeGamesPlugin. +/// +internal interface ICollectIntervalManager : IDisposable { + /// + /// Starts the timer with a random initial and regular delay if it is not already started. + /// + void StartTimerIfNeeded(); + + /// + /// Changes the collect interval to a new random value and resets the timer. + /// + /// The source object passed to the timer callback. + /// The new random collect interval. + TimeSpan RandomlyChangeCollectInterval(object? source); + + /// + /// Stops the timer and disposes it. + /// + void StopTimer(); +} + +internal sealed class CollectIntervalManager : ICollectIntervalManager { private static readonly RandomUtils.GaussianRandom Random = new(); /// @@ -52,7 +76,7 @@ public void StartTimerIfNeeded() { /// private TimeSpan GetRandomizedTimerDelay() => GetRandomizedTimerDelay(Plugin.Options.RecheckInterval.TotalSeconds, 7 * 60 * RandomizeIntervalSwitch); - internal TimeSpan RandomlyChangeCollectInterval(object? source) { + public TimeSpan RandomlyChangeCollectInterval(object? source) { // Calculate a random delay using GetRandomizedTimerDelay method TimeSpan delay = GetRandomizedTimerDelay(); ResetTimer(() => new Timer(state => Plugin.CollectGamesOnClock(state), source, delay, delay)); @@ -60,7 +84,7 @@ internal TimeSpan RandomlyChangeCollectInterval(object? source) { return delay; } - internal void StopTimer() => ResetTimer(null); + public void StopTimer() => ResetTimer(null); /// /// Calculates a random delay using a normal distribution with a given mean and standard deviation. From 4e9752464c41e50f1f888586d793da889783ac1f Mon Sep 17 00:00:00 2001 From: maxisoft Date: Sun, 29 Oct 2023 11:31:27 +0100 Subject: [PATCH 07/10] Increase sample size for normal distribution tests This commit increases the sample size for the normal distribution tests in RandomUtilsTests.cs. The increased sample size reduces the margin of error and makes the tests more reliable and consistent. --- ASFFreeGames.Tests/RandomUtilsTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ASFFreeGames.Tests/RandomUtilsTests.cs b/ASFFreeGames.Tests/RandomUtilsTests.cs index 753d9b8..7222f09 100644 --- a/ASFFreeGames.Tests/RandomUtilsTests.cs +++ b/ASFFreeGames.Tests/RandomUtilsTests.cs @@ -11,10 +11,10 @@ public class RandomUtilsTests { public static TheoryData GetTestData() => new TheoryData { // mean, std, sample size, margin of error - { 0, 1, 1000, 0.05 }, // original test case - { 10, 2, 1000, 0.1 }, // original test case - { -5, 3, 5000, 0.15 }, // additional test case - { 20, 5, 10000, 0.2 } // additional test case + { 0, 1, 10000, 0.05 }, + { 10, 2, 10000, 0.1 }, + { -5, 3, 50000, 0.15 }, + { 20, 5, 100000, 0.2 } }; // A test method to check if the mean and standard deviation of the normal distribution are close to the expected values From 3e9ee500a2695b4e79adde2078632659d8ccb94b Mon Sep 17 00:00:00 2001 From: maxisoft Date: Tue, 31 Oct 2023 13:51:11 +0100 Subject: [PATCH 08/10] Fix NullReferenceException in PluginContext.get_CancellationToken() This commit adds some checks and methods to ensure that the PluginContext is valid and initialized before using it. It also adds a cancellationToken parameter to some of the methods that use the PluginContext cancellation token. This should resolve the issue #42 (https://github.com/maxisoft/ASFFreeGames/issues/42) that was reported. --- ASFFreeGames/ASFFreeGamesPlugin.cs | 21 +++++++--- ASFFreeGames/Commands/FreeGamesCommand.cs | 48 +++++++++++++++-------- ASFFreeGames/PluginContext.cs | 2 +- 3 files changed, 48 insertions(+), 23 deletions(-) diff --git a/ASFFreeGames/ASFFreeGamesPlugin.cs b/ASFFreeGames/ASFFreeGamesPlugin.cs index bcf2ce9..24123ab 100644 --- a/ASFFreeGames/ASFFreeGamesPlugin.cs +++ b/ASFFreeGames/ASFFreeGamesPlugin.cs @@ -68,7 +68,13 @@ public async Task OnASFInit(IReadOnlyDictionary? additionalConfi await SaveOptions(CancellationToken).ConfigureAwait(false); } - public async Task OnBotCommand(Bot bot, EAccess access, string message, string[] args, ulong steamID = 0) => await CommandDispatcher.Execute(bot, message, args, steamID).ConfigureAwait(false); + public async Task OnBotCommand(Bot bot, EAccess access, string message, string[] args, ulong steamID = 0) { + if (!Context.Valid) { + CreateContext(); + } + + return await CommandDispatcher.Execute(bot, message, args, steamID).ConfigureAwait(false); + } public async Task OnBotDestroy(Bot bot) => await RemoveBot(bot).ConfigureAwait(false); @@ -93,14 +99,14 @@ public Task OnLoaded() { public async void CollectGamesOnClock(object? source) { CollectIntervalManager.RandomlyChangeCollectInterval(source); - if ((Bots.Count > 0) && (Context.Bots.Count != Bots.Count)) { - Context = new PluginContext(Bots, BotContextRegistry, Options, LoggerFilter, new Lazy(() => CancellationTokenSourceLazy.Value.Token)); + if (!Context.Valid || ((Bots.Count > 0) && (Context.Bots.Count != Bots.Count))) { + CreateContext(); } using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken); cts.CancelAfter(TimeSpan.FromMilliseconds(CollectGamesTimeout)); - if (cts.IsCancellationRequested) { + if (cts.IsCancellationRequested || !Context.Valid) { return; } @@ -120,6 +126,11 @@ public async void CollectGamesOnClock(object? source) { } } + /// + /// Creates a new PluginContext instance and assigns it to the Context property. + /// + private void CreateContext() => Context = new PluginContext(Bots, BotContextRegistry, Options, LoggerFilter, new Lazy(() => CancellationTokenSourceLazy.Value.Token), true); + private async Task RegisterBot(Bot bot) { Bots.Add(bot); @@ -152,7 +163,7 @@ private async Task RemoveBot(Bot bot) { CollectIntervalManager.StopTimer(); } - Context.LoggerFilter.RemoveFilters(bot); + LoggerFilter.RemoveFilters(bot); } private async Task SaveOptions(CancellationToken cancellationToken) { diff --git a/ASFFreeGames/Commands/FreeGamesCommand.cs b/ASFFreeGames/Commands/FreeGamesCommand.cs index 025e876..d3372c3 100644 --- a/ASFFreeGames/Commands/FreeGamesCommand.cs +++ b/ASFFreeGames/Commands/FreeGamesCommand.cs @@ -41,13 +41,13 @@ internal sealed class FreeGamesCommand : IBotCommand, IDisposable { if (args.Length >= 2) { switch (args[1].ToUpperInvariant()) { case "SET": - return await HandleSetCommand(bot, args).ConfigureAwait(false); + return await HandleSetCommand(bot, args, cancellationToken).ConfigureAwait(false); case "RELOAD": return await HandleReloadCommand(bot).ConfigureAwait(false); case SaveOptionsInternalCommandString: - return await HandleInternalSaveOptionsCommand(bot).ConfigureAwait(false); + return await HandleInternalSaveOptionsCommand(bot, cancellationToken).ConfigureAwait(false); case CollectInternalCommandString: - return await HandleInternalCollectCommand(bot, args).ConfigureAwait(false); + return await HandleInternalCollectCommand(bot, args, cancellationToken).ConfigureAwait(false); } } @@ -56,43 +56,46 @@ internal sealed class FreeGamesCommand : IBotCommand, IDisposable { private static string FormatBotResponse(Bot? bot, string resp) => IBotCommand.FormatBotResponse(bot, resp); - private async Task HandleSetCommand(Bot? bot, string[] args) { + private async Task HandleSetCommand(Bot? bot, string[] args, CancellationToken cancellationToken) { + using CancellationTokenSource cts = CreateLinkedTokenSource(cancellationToken); + cancellationToken = cts.Token; + if (args.Length >= 3) { switch (args[2].ToUpperInvariant()) { case "VERBOSE": Options.VerboseLog = true; - await SaveOptions().ConfigureAwait(false); + await SaveOptions(cancellationToken).ConfigureAwait(false); return FormatBotResponse(bot, "Verbosity on"); case "NOVERBOSE": Options.VerboseLog = false; - await SaveOptions().ConfigureAwait(false); + await SaveOptions(cancellationToken).ConfigureAwait(false); return FormatBotResponse(bot, "Verbosity off"); case "F2P": case "FREETOPLAY": case "NOSKIPFREETOPLAY": Options.SkipFreeToPlay = false; - await SaveOptions().ConfigureAwait(false); + await SaveOptions(cancellationToken).ConfigureAwait(false); return FormatBotResponse(bot, $"{ASFFreeGamesPlugin.StaticName} is going to collect f2p games"); case "NOF2P": case "NOFREETOPLAY": case "SKIPFREETOPLAY": Options.SkipFreeToPlay = true; - await SaveOptions().ConfigureAwait(false); + await SaveOptions(cancellationToken).ConfigureAwait(false); return FormatBotResponse(bot, $"{ASFFreeGamesPlugin.StaticName} is now skipping f2p games"); case "DLC": case "NOSKIPDLC": Options.SkipDLC = false; - await SaveOptions().ConfigureAwait(false); + await SaveOptions(cancellationToken).ConfigureAwait(false); return FormatBotResponse(bot, $"{ASFFreeGamesPlugin.StaticName} is going to collect dlc"); case "NODLC": case "SKIPDLC": Options.SkipDLC = true; - await SaveOptions().ConfigureAwait(false); + await SaveOptions(cancellationToken).ConfigureAwait(false); return FormatBotResponse(bot, $"{ASFFreeGamesPlugin.StaticName} is now skipping dlc"); @@ -104,6 +107,13 @@ internal sealed class FreeGamesCommand : IBotCommand, IDisposable { return null; } + /// + /// Creates a linked cancellation token source from the given cancellation token and the Context cancellation token. + /// + /// The cancellation token to link. + /// A CancellationTokenSource that is linked to both tokens. + private static CancellationTokenSource CreateLinkedTokenSource(CancellationToken cancellationToken) => Context.Valid ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, Context.CancellationToken) : CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + private Task HandleReloadCommand(Bot? bot) { ASFFreeGamesOptionsLoader.Bind(ref Options); @@ -116,23 +126,24 @@ internal sealed class FreeGamesCommand : IBotCommand, IDisposable { return FormatBotResponse(bot, $"Collected a total of {collected} free game(s)"); } - private async ValueTask HandleInternalSaveOptionsCommand(Bot? bot) { - await SaveOptions().ConfigureAwait(false); + private async ValueTask HandleInternalSaveOptionsCommand(Bot? bot, CancellationToken cancellationToken) { + await SaveOptions(cancellationToken).ConfigureAwait(false); return null; } - private async ValueTask HandleInternalCollectCommand(Bot? bot, string[] args) { + private async ValueTask HandleInternalCollectCommand(Bot? bot, string[] args, CancellationToken cancellationToken) { Dictionary botMap = Context.Bots.ToDictionary(static b => b.BotName, static b => b, StringComparer.InvariantCultureIgnoreCase); - int collected = await CollectGames(args.Skip(2).Select(botName => botMap[botName]), ECollectGameRequestSource.Scheduled, Context.CancellationToken).ConfigureAwait(false); + int collected = await CollectGames(args.Skip(2).Select(botName => botMap[botName]), ECollectGameRequestSource.Scheduled, cancellationToken).ConfigureAwait(false); return FormatBotResponse(bot, $"Collected a total of {collected} free game(s)"); } - private async Task SaveOptions() { - using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(Context.CancellationToken); + private async Task SaveOptions(CancellationToken cancellationToken) { + using CancellationTokenSource cts = CreateLinkedTokenSource(cancellationToken); + cancellationToken = cts.Token; cts.CancelAfter(10_000); - await ASFFreeGamesOptionsLoader.Save(Options, cts.Token).ConfigureAwait(false); + await ASFFreeGamesOptionsLoader.Save(Options, cancellationToken).ConfigureAwait(false); } private SemaphoreSlim? SemaphoreSlim; @@ -156,6 +167,9 @@ private async Task SaveOptions() { #pragma warning restore CA1805 private async Task CollectGames(IEnumerable bots, ECollectGameRequestSource requestSource, CancellationToken cancellationToken = default) { + using CancellationTokenSource cts = CreateLinkedTokenSource(cancellationToken); + cancellationToken = cts.Token; + if (cancellationToken.IsCancellationRequested) { return 0; } diff --git a/ASFFreeGames/PluginContext.cs b/ASFFreeGames/PluginContext.cs index d8818a3..314012e 100644 --- a/ASFFreeGames/PluginContext.cs +++ b/ASFFreeGames/PluginContext.cs @@ -5,6 +5,6 @@ namespace Maxisoft.ASF; -internal readonly record struct PluginContext(IReadOnlyCollection Bots, IContextRegistry BotContexts, ASFFreeGamesOptions Options, LoggerFilter LoggerFilter, Lazy CancellationTokenLazy) { +internal readonly record struct PluginContext(IReadOnlyCollection Bots, IContextRegistry BotContexts, ASFFreeGamesOptions Options, LoggerFilter LoggerFilter, Lazy CancellationTokenLazy, bool Valid = false) { public CancellationToken CancellationToken => CancellationTokenLazy.Value; } From 887f0959a311a1a251382c573b9f4adb35d986e8 Mon Sep 17 00:00:00 2001 From: maxisoft Date: Tue, 31 Oct 2023 14:34:30 +0100 Subject: [PATCH 09/10] Refactor PluginContext and SaveOptions methods This commit makes the following changes: - Change the PluginContext from a readonly record struct to a sealed record class - Add a CancellationTokenChanger struct that implements IDisposable and temporarily changes the cancellation token of the PluginContext instance - Add a TemporaryChangeCancellationToken method that creates an instance of the CancellationTokenChanger struct - Change the SaveOptions method to return a string and use the TemporaryChangeCancellationToken method - Change the CollectGamesOnClock method to use the TemporaryChangeCancellationToken method - Add XML documentation comments to the PluginContext and its members --- ASFFreeGames/ASFFreeGamesPlugin.cs | 48 ++++++++++++++++++++---------- ASFFreeGames/PluginContext.cs | 39 +++++++++++++++++++++++- 2 files changed, 71 insertions(+), 16 deletions(-) diff --git a/ASFFreeGames/ASFFreeGamesPlugin.cs b/ASFFreeGames/ASFFreeGamesPlugin.cs index 24123ab..a9e5b99 100644 --- a/ASFFreeGames/ASFFreeGamesPlugin.cs +++ b/ASFFreeGames/ASFFreeGamesPlugin.cs @@ -59,7 +59,7 @@ internal static PluginContext Context { public ASFFreeGamesPlugin() { CommandDispatcher = new CommandDispatcher(Options); CollectIntervalManager = new CollectIntervalManager(this); - _context.Value = new PluginContext(Bots, BotContextRegistry, Options, LoggerFilter, new Lazy(() => CancellationTokenSourceLazy.Value.Token)); + _context.Value = new PluginContext(Bots, BotContextRegistry, Options, LoggerFilter) { CancellationTokenLazy = new Lazy(() => CancellationTokenSourceLazy.Value.Token) }; } public async Task OnASFInit(IReadOnlyDictionary? additionalConfigProperties = null) { @@ -110,26 +110,29 @@ public async void CollectGamesOnClock(object? source) { return; } - Bot[] reorderedBots; - IContextRegistry botContexts = Context.BotContexts; + // ReSharper disable once AccessToDisposedClosure + using (Context.TemporaryChangeCancellationToken(() => cts.Token)) { + Bot[] reorderedBots; + IContextRegistry botContexts = Context.BotContexts; - lock (botContexts) { - long orderByRunKeySelector(Bot bot) => botContexts.GetBotContext(bot)?.RunElapsedMilli ?? long.MaxValue; - int comparison(Bot x, Bot y) => orderByRunKeySelector(y).CompareTo(orderByRunKeySelector(x)); // sort in descending order - reorderedBots = Bots.ToArray(); - Array.Sort(reorderedBots, comparison); - } + lock (botContexts) { + long orderByRunKeySelector(Bot bot) => botContexts.GetBotContext(bot)?.RunElapsedMilli ?? long.MaxValue; + int comparison(Bot x, Bot y) => orderByRunKeySelector(y).CompareTo(orderByRunKeySelector(x)); // sort in descending order + reorderedBots = Bots.ToArray(); + Array.Sort(reorderedBots, comparison); + } - if (!cts.IsCancellationRequested) { - string cmd = $"FREEGAMES {FreeGamesCommand.CollectInternalCommandString} " + string.Join(' ', reorderedBots.Select(static bot => bot.BotName)); - await OnBotCommand(null!, EAccess.None, cmd, cmd.Split()).ConfigureAwait(false); + if (!cts.IsCancellationRequested) { + string cmd = $"FREEGAMES {FreeGamesCommand.CollectInternalCommandString} " + string.Join(' ', reorderedBots.Select(static bot => bot.BotName)); + await OnBotCommand(null!, EAccess.None, cmd, cmd.Split()).ConfigureAwait(false); + } } } /// /// Creates a new PluginContext instance and assigns it to the Context property. /// - private void CreateContext() => Context = new PluginContext(Bots, BotContextRegistry, Options, LoggerFilter, new Lazy(() => CancellationTokenSourceLazy.Value.Token), true); + private void CreateContext() => Context = new PluginContext(Bots, BotContextRegistry, Options, LoggerFilter, true) { CancellationTokenLazy = new Lazy(() => CancellationTokenSourceLazy.Value.Token) }; private async Task RegisterBot(Bot bot) { Bots.Add(bot); @@ -166,11 +169,26 @@ private async Task RemoveBot(Bot bot) { LoggerFilter.RemoveFilters(bot); } - private async Task SaveOptions(CancellationToken cancellationToken) { + private async Task SaveOptions(CancellationToken cancellationToken) { if (!cancellationToken.IsCancellationRequested) { const string cmd = $"FREEGAMES {FreeGamesCommand.SaveOptionsInternalCommandString}"; - await OnBotCommand(Bots.FirstOrDefault()!, EAccess.None, cmd, cmd.Split()).ConfigureAwait(false); + async Task continuation() => await OnBotCommand(Bots.FirstOrDefault()!, EAccess.None, cmd, cmd.Split()).ConfigureAwait(false); + + string? result; + + if (Context.Valid) { + using (Context.TemporaryChangeCancellationToken(() => cancellationToken)) { + result = await continuation().ConfigureAwait(false); + } + } + else { + result = await continuation().ConfigureAwait(false); + } + + return result; } + + return null; } private void StartTimerIfNeeded() => CollectIntervalManager.StartTimerIfNeeded(); diff --git a/ASFFreeGames/PluginContext.cs b/ASFFreeGames/PluginContext.cs index 314012e..17fef20 100644 --- a/ASFFreeGames/PluginContext.cs +++ b/ASFFreeGames/PluginContext.cs @@ -5,6 +5,43 @@ namespace Maxisoft.ASF; -internal readonly record struct PluginContext(IReadOnlyCollection Bots, IContextRegistry BotContexts, ASFFreeGamesOptions Options, LoggerFilter LoggerFilter, Lazy CancellationTokenLazy, bool Valid = false) { +internal sealed record PluginContext(IReadOnlyCollection Bots, IContextRegistry BotContexts, ASFFreeGamesOptions Options, LoggerFilter LoggerFilter, bool Valid = false) { + /// + /// Gets the cancellation token associated with this context. + /// public CancellationToken CancellationToken => CancellationTokenLazy.Value; + + internal Lazy CancellationTokenLazy { private get; set; } = new(default(CancellationToken)); + + /// + /// A struct that implements IDisposable and temporarily changes the cancellation token of the PluginContext instance. + /// + public readonly struct CancellationTokenChanger : IDisposable { + private readonly PluginContext Context; + private readonly Lazy Original; + + /// + /// Initializes a new instance of the struct with the specified context and factory. + /// + /// The PluginContext instance to change. + /// The function that creates a new cancellation token. + public CancellationTokenChanger(PluginContext context, Func factory) { + Context = context; + Original = context.CancellationTokenLazy; + context.CancellationTokenLazy = new Lazy(factory); + } + + /// + /// + /// Restores the original cancellation token to the PluginContext instance. + /// + public void Dispose() => Context.CancellationTokenLazy = Original; + } + + /// + /// Creates a new instance of the struct with the specified factory. + /// + /// The function that creates a new cancellation token. + /// A new instance of the struct. + public CancellationTokenChanger TemporaryChangeCancellationToken(Func factory) => new(this, factory); } From 0615926be58b574e4f209c8ecedcbe5efb2c2975 Mon Sep 17 00:00:00 2001 From: maxisoft Date: Tue, 31 Oct 2023 14:41:08 +0100 Subject: [PATCH 10/10] Fix CS8603 error in ASFFreeGamesPlugin.cs This commit resolves the CS8603 error: "Possible null reference return" in the ASFFreeGamesPlugin.cs file. The error was caused by the possibility of returning a null value from the Context property. The fix involves returning a default empty invalid value to ensure the _context.Value is not null. --- ASFFreeGames/ASFFreeGamesPlugin.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ASFFreeGames/ASFFreeGamesPlugin.cs b/ASFFreeGames/ASFFreeGamesPlugin.cs index a9e5b99..3493d2c 100644 --- a/ASFFreeGames/ASFFreeGamesPlugin.cs +++ b/ASFFreeGames/ASFFreeGamesPlugin.cs @@ -31,7 +31,7 @@ internal sealed class ASFFreeGamesPlugin : IASF, IBot, IBotConnection, IBotComma private const int CollectGamesTimeout = 3 * 60 * 1000; internal static PluginContext Context { - get => _context.Value; + get => _context.Value ?? new PluginContext(Array.Empty(), new ContextRegistry(), new ASFFreeGamesOptions(), new LoggerFilter()); private set => _context.Value = value; } @@ -169,6 +169,7 @@ private async Task RemoveBot(Bot bot) { LoggerFilter.RemoveFilters(bot); } + // ReSharper disable once UnusedMethodReturnValue.Local private async Task SaveOptions(CancellationToken cancellationToken) { if (!cancellationToken.IsCancellationRequested) { const string cmd = $"FREEGAMES {FreeGamesCommand.SaveOptionsInternalCommandString}";