diff --git a/ASFFreeGames.Tests/RandomUtilsTests.cs b/ASFFreeGames.Tests/RandomUtilsTests.cs new file mode 100644 index 0000000..7222f09 --- /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, 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 + [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..3493d2c 100644 --- a/ASFFreeGames/ASFFreeGamesPlugin.cs +++ b/ASFFreeGames/ASFFreeGamesPlugin.cs @@ -16,15 +16,22 @@ 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; 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; } @@ -44,22 +51,30 @@ 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 ICollectIntervalManager CollectIntervalManager; public ASFFreeGamesPlugin() { CommandDispatcher = new CommandDispatcher(Options); - _context.Value = new PluginContext(Bots, BotContextRegistry, Options, LoggerFilter, new Lazy(() => CancellationTokenSourceLazy.Value.Token)); + CollectIntervalManager = new CollectIntervalManager(this); + _context.Value = new PluginContext(Bots, BotContextRegistry, Options, LoggerFilter) { CancellationTokenLazy = 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); } - 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); @@ -81,44 +96,54 @@ public Task OnLoaded() { public Task OnUpdateProceeding(Version currentVersion, Version newVersion) => Task.CompletedTask; - private async void CollectGamesOnClock(object? source) { - if ((Bots.Count > 0) && (Context.Bots.Count != Bots.Count)) { - Context = new PluginContext(Bots, BotContextRegistry, Options, LoggerFilter, new Lazy(() => CancellationTokenSourceLazy.Value.Token)); + public async void CollectGamesOnClock(object? source) { + CollectIntervalManager.RandomlyChangeCollectInterval(source); + + 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; } - 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, true) { CancellationTokenLazy = new Lazy(() => CancellationTokenSourceLazy.Value.Token) }; + private async Task RegisterBot(Bot bot) { Bots.Add(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); } } @@ -138,36 +163,38 @@ private async Task RemoveBot(Bot bot) { } if (Bots.Count == 0) { - ResetTimer(); + CollectIntervalManager.StopTimer(); } - Context.LoggerFilter.RemoveFilters(bot); + LoggerFilter.RemoveFilters(bot); } - private void ResetTimer(Timer? newTimer = null) { - Timer?.Dispose(); - Timer = newTimer; - } - - private async Task SaveOptions(CancellationToken cancellationToken) { + // ReSharper disable once UnusedMethodReturnValue.Local + 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; - private void StartTimerIfNeeded() { - if (Timer is null) { - TimeSpan delay = Options.RecheckInterval; - ResetTimer(new Timer(CollectGamesOnClock)); - Timer?.Change(TimeSpan.FromSeconds(30), delay); + if (Context.Valid) { + using (Context.TemporaryChangeCancellationToken(() => cancellationToken)) { + result = await continuation().ConfigureAwait(false); + } + } + else { + result = await continuation().ConfigureAwait(false); + } + + return result; } - } - ~ASFFreeGamesPlugin() { - Timer?.Dispose(); - Timer = null; + return null; } + + private void StartTimerIfNeeded() => CollectIntervalManager.StartTimerIfNeeded(); + + ~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..f4fb486 --- /dev/null +++ b/ASFFreeGames/CollectIntervalManager.cs @@ -0,0 +1,128 @@ +using System; +using System.Threading; + +namespace Maxisoft.ASF; + +// 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(); + + /// + /// 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); + + 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)); + + return delay; + } + + public 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(); + } + } +} 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/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(); diff --git a/ASFFreeGames/PluginContext.cs b/ASFFreeGames/PluginContext.cs index d8818a3..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) { +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); } 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()); + } +}