Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Randomize collect interval (#18) and improve code readability #41

Merged
merged 11 commits into from
Nov 4, 2023
Merged
77 changes: 77 additions & 0 deletions ASFFreeGames.Tests/RandomUtilsTests.cs
Original file line number Diff line number Diff line change
@@ -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<double, double, int, double> GetTestData() =>
new TheoryData<double, double, int, double> {
// 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<double> 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<double> 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
115 changes: 71 additions & 44 deletions ASFFreeGames/ASFFreeGamesPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Bot>(), new ContextRegistry(), new ASFFreeGamesOptions(), new LoggerFilter());
private set => _context.Value = value;
}

Expand All @@ -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<CancellationToken>(() => CancellationTokenSourceLazy.Value.Token));
CollectIntervalManager = new CollectIntervalManager(this);
_context.Value = new PluginContext(Bots, BotContextRegistry, Options, LoggerFilter) { CancellationTokenLazy = new Lazy<CancellationToken>(() => CancellationTokenSourceLazy.Value.Token) };
}

public async Task OnASFInit(IReadOnlyDictionary<string, JToken>? additionalConfigProperties = null) {
ASFFreeGamesOptionsLoader.Bind(ref Options);
ASFFreeGamesOptionsLoader.Bind(ref OptionsField);
Options.VerboseLog ??= GlobalDatabase?.LoadFromJsonStorage($"{Name}.Verbose")?.ToObject<bool?>() ?? Options.VerboseLog;
await SaveOptions(CancellationToken).ConfigureAwait(false);
}

public async Task<string?> OnBotCommand(Bot bot, EAccess access, string message, string[] args, ulong steamID = 0) => await CommandDispatcher.Execute(bot, message, args, steamID).ConfigureAwait(false);
public async Task<string?> 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);

Expand All @@ -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<CancellationToken>(() => 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);
}
}
}

/// <summary>
/// Creates a new PluginContext instance and assigns it to the Context property.
/// </summary>
private void CreateContext() => Context = new PluginContext(Bots, BotContextRegistry, Options, LoggerFilter, true) { CancellationTokenLazy = new Lazy<CancellationToken>(() => 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);
}
}

Expand All @@ -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<string?> 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<string?> 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
128 changes: 128 additions & 0 deletions ASFFreeGames/CollectIntervalManager.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
using System;
using System.Threading;

namespace Maxisoft.ASF;

// The interface that defines the contract for the CollectIntervalManager class
/// <inheritdoc />
/// <summary>
/// An interface that provides methods to manage the collect interval for the ASFFreeGamesPlugin.
/// </summary>
internal interface ICollectIntervalManager : IDisposable {
/// <summary>
/// Starts the timer with a random initial and regular delay if it is not already started.
/// </summary>
void StartTimerIfNeeded();

/// <summary>
/// Changes the collect interval to a new random value and resets the timer.
/// </summary>
/// <param name="source">The source object passed to the timer callback.</param>
/// <returns>The new random collect interval.</returns>
TimeSpan RandomlyChangeCollectInterval(object? source);

/// <summary>
/// Stops the timer and disposes it.
/// </summary>
void StopTimer();
}

internal sealed class CollectIntervalManager : ICollectIntervalManager {
private static readonly RandomUtils.GaussianRandom Random = new();

/// <summary>
/// Gets a value that indicates whether to randomize the collect interval or not.
/// </summary>
/// <value>
/// A value of 1 if Options.RandomizeRecheckInterval is true or null, or a value of 0 otherwise.
/// </value>
/// <remarks>
/// 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.
/// </remarks>
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);
}
}

/// <summary>
/// Calculates a random delay using a normal distribution with a mean of Options.RecheckInterval.TotalSeconds and a standard deviation of 7 minutes.
/// </summary>
/// <returns>The randomized delay.</returns>
/// <seealso cref="GetRandomizedTimerDelay(double, double, double, double)" />
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);

/// <summary>
/// Calculates a random delay using a normal distribution with a given mean and standard deviation.
/// </summary>
/// <param name="meanSeconds">The mean of the normal distribution in seconds.</param>
/// <param name="stdSeconds">The standard deviation of the normal distribution in seconds.</param>
/// <param name="minSeconds">The minimum value of the random delay in seconds. The default value is 11 minutes.</param>
/// <param name="maxSeconds">The maximum value of the random delay in seconds. The default value is 1 hour.</param>
/// <returns>The randomized delay.</returns>
/// <remarks>
/// 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#.
/// </remarks>
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<Timer?>? newTimerFactory) {
Timer?.Dispose();
Timer = null;

if (newTimerFactory is not null) {
Timer = newTimerFactory();
}
}
}
Loading