From 19bd782bdf7ab45389463c2da83a094b3ed4d357 Mon Sep 17 00:00:00 2001 From: maxisoft Date: Sun, 19 Nov 2023 11:46:10 +0100 Subject: [PATCH 01/14] Optimize RandomUtils to use Math and MemoryMarshal.Cast This commit modifies the GaussianRandom class to use Math instead of MathF and MemoryMarshal.Cast instead of BitConverter. This allows the plugin to be compatible with trimmed ASF binaries that do not include those methods. This also improves the performance by reducing the number of calls to Fill(bytes) from 2 to 1. This fixes the issue #46 (https://github.com/maxisoft/ASFFreeGames/issues/46) that was reported. --- ASFFreeGames/RandomUtils.cs | 52 ++++++++----------------------------- 1 file changed, 11 insertions(+), 41 deletions(-) diff --git a/ASFFreeGames/RandomUtils.cs b/ASFFreeGames/RandomUtils.cs index e9ef9ae..de69199 100644 --- a/ASFFreeGames/RandomUtils.cs +++ b/ASFFreeGames/RandomUtils.cs @@ -1,6 +1,8 @@ using System; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using System.Security.Cryptography; namespace Maxisoft.ASF; @@ -8,35 +10,6 @@ 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; @@ -55,23 +28,20 @@ private double NextDouble() { 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; + Span bytes = stackalloc byte[16]; + Fill(bytes); + Span ulongs = MemoryMarshal.Cast(bytes); + double u1 = ulongs[0] / (double) ulong.MaxValue; + double u2 = ulongs[1] / (double) ulong.MaxValue; // Apply the Box-Muller formula - float r = MathF.Sqrt(-2.0f * MathF.Log(u1)); - float theta = 2.0f * MathF.PI * u2; + double r = Math.Sqrt(-2.0f * Math.Log(u1)); + double theta = 2.0 * Math.PI * u2; - // Store one of the values for next time - NextGaussianValue = r * MathF.Sin(theta); + NextGaussianValue = r * Math.Sin(theta); HasNextGaussian = true; - // Return the other value - return r * MathF.Cos(theta); + return r * Math.Cos(theta); } /// From aef813b02680c3402e87e4cae20e5e63571c5216 Mon Sep 17 00:00:00 2001 From: maxisoft Date: Sun, 19 Nov 2023 12:10:27 +0100 Subject: [PATCH 02/14] Enhance GaussianRandom class thread-safety and compliance with GetNonZeroBytes specification This commit modifies the GaussianRandom class to use Interlocked.CompareExchange instead of a bool field to ensure thread-safety when accessing the stored value for the next Gaussian number. This commit also changes the GetNonZeroBytes method to use a Span parameter and a stack-allocated buffer to ensure that no zero bytes are generated, as required by the RandomNumberGenerator base class. Thus, the compliance and performance of the GaussianRandom class are improved. --- ASFFreeGames/RandomUtils.cs | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/ASFFreeGames/RandomUtils.cs b/ASFFreeGames/RandomUtils.cs index de69199..378d6aa 100644 --- a/ASFFreeGames/RandomUtils.cs +++ b/ASFFreeGames/RandomUtils.cs @@ -4,6 +4,7 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Security.Cryptography; +using System.Threading; namespace Maxisoft.ASF; @@ -12,19 +13,32 @@ namespace Maxisoft.ASF; public static class RandomUtils { internal sealed class GaussianRandom : RandomNumberGenerator { // A flag to indicate if there is a stored value for the next Gaussian number - private bool HasNextGaussian; + private int HasNextGaussian; + + private const int True = 1; + private const int False = 0; // 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); + public override void GetNonZeroBytes(Span data) { + Fill(data); + Span buffer = stackalloc byte[1]; - private double NextDouble() { - if (HasNextGaussian) { - HasNextGaussian = false; + for (int i = 0; i < data.Length; i++) { + while (data[i] == default(byte)) { + Fill(buffer); + data[i] = buffer[0]; + } + } + } + public override void GetNonZeroBytes(byte[] data) => GetNonZeroBytes((Span) data); + + private double NextDouble() { + if (Interlocked.CompareExchange(ref HasNextGaussian, False, True) == True) { return NextGaussianValue; } @@ -34,12 +48,13 @@ private double NextDouble() { double u1 = ulongs[0] / (double) ulong.MaxValue; double u2 = ulongs[1] / (double) ulong.MaxValue; - // Apply the Box-Muller formula + // Box-Muller formula double r = Math.Sqrt(-2.0f * Math.Log(u1)); double theta = 2.0 * Math.PI * u2; - NextGaussianValue = r * Math.Sin(theta); - HasNextGaussian = true; + if (Interlocked.CompareExchange(ref HasNextGaussian, True, False) == False) { + NextGaussianValue = r * Math.Sin(theta); + } return r * Math.Cos(theta); } From c58408b3aabfd4fd0e32d6d2b8fd69026ed40937 Mon Sep 17 00:00:00 2001 From: maxisoft Date: Sun, 19 Nov 2023 12:46:54 +0100 Subject: [PATCH 03/14] Modify GaussianRandom class to match Wikipedia C++ implementation of Box-Muller formula This commit modifies the GaussianRandom class to use the same logic as the C++ implementation of the Box-Muller formula that is shown on Wikipedia. This involves using a do-while loop to generate a non-zero uniform random number u1, and checking if it is greater than the smallest positive double value (double.Epsilon). This ensures that the logarithm and square root operations do not produce NaN or infinity values. This improves the robustness and accuracy of the GaussianRandom class. --- ASFFreeGames/RandomUtils.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/ASFFreeGames/RandomUtils.cs b/ASFFreeGames/RandomUtils.cs index 378d6aa..b4f8dec 100644 --- a/ASFFreeGames/RandomUtils.cs +++ b/ASFFreeGames/RandomUtils.cs @@ -43,9 +43,14 @@ private double NextDouble() { } Span bytes = stackalloc byte[16]; - Fill(bytes); Span ulongs = MemoryMarshal.Cast(bytes); - double u1 = ulongs[0] / (double) ulong.MaxValue; + double u1; + + do { + GetNonZeroBytes(bytes); + u1 = ulongs[0] / (double) ulong.MaxValue; + } while (u1 <= double.Epsilon); + double u2 = ulongs[1] / (double) ulong.MaxValue; // Box-Muller formula From e765d8d4e19bc2c5ca8e8861afe1f74c49561a27 Mon Sep 17 00:00:00 2001 From: maxisoft Date: Sun, 19 Nov 2023 13:06:22 +0100 Subject: [PATCH 04/14] Improve GaussianRandom class to handle edge cases This commit improves the GaussianRandom class to handle some edge cases. It does the following changes: - It uses 2 * sizeof(long) instead of 16 as the size of the byte span to avoid hard-coding the value and make it more readable. - It uses -2.0 instead of -2.0f as the coefficient of the logarithm in the Box-Muller formula to use double precision instead of float precision. - It adds a do-while loop to check if the generated random number is finite and not NaN or infinity, and repeats the generation if it is not. This prevents the NextGaussian method from returning invalid values. --- ASFFreeGames/RandomUtils.cs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/ASFFreeGames/RandomUtils.cs b/ASFFreeGames/RandomUtils.cs index b4f8dec..e3c48a0 100644 --- a/ASFFreeGames/RandomUtils.cs +++ b/ASFFreeGames/RandomUtils.cs @@ -42,7 +42,7 @@ private double NextDouble() { return NextGaussianValue; } - Span bytes = stackalloc byte[16]; + Span bytes = stackalloc byte[2 * sizeof(long)]; Span ulongs = MemoryMarshal.Cast(bytes); double u1; @@ -54,7 +54,7 @@ private double NextDouble() { double u2 = ulongs[1] / (double) ulong.MaxValue; // Box-Muller formula - double r = Math.Sqrt(-2.0f * Math.Log(u1)); + double r = Math.Sqrt(-2.0 * Math.Log(u1)); double theta = 2.0 * Math.PI * u2; if (Interlocked.CompareExchange(ref HasNextGaussian, True, False) == False) { @@ -73,9 +73,15 @@ private double NextDouble() { /// /// This method uses the overridden NextDouble method to get a normally distributed random number. /// - public double NextGaussian(double mean, double standardDeviation) => + public double NextGaussian(double mean, double standardDeviation) { + // Use the overridden NextDouble method to get a normally distributed random + double rnd; - // Use the overridden NextDouble method to get a normally distributed random number - mean + (standardDeviation * NextDouble()); + do { + rnd = NextDouble(); + } while (!double.IsFinite(rnd)); + + return mean + (standardDeviation * rnd); + } } } From e4fc3aeaeb26e345e96150bee268633e2c1f7b04 Mon Sep 17 00:00:00 2001 From: maxisoft Date: Sat, 4 May 2024 20:24:18 +0200 Subject: [PATCH 05/14] Upgrade plugin to align with latest ASF version - Upgraded target framework to .NET 8.0 to match ASF requirements. - Updated ASF submodule to the latest stable release. - Transitioned from Newtonsoft.Json to System.Text.Json for improved compatibility with ASF. - Refined GaussianRandom implementation to function with the latest trimmed ASF binary. - Enhanced RedditHelper to utilize System.Text.Json, improving JSON handling. - Modified GaussianRandom to utilize a more reliable RNG method compatible with ASF's trimmed version. - Various improvements and code cleanups in line with ASF's updated codebase. --- ASFFreeGames.Tests/ASFFreeGames.Tests.csproj | 2 + ASFFreeGames.Tests/RandomUtilsTests.cs | 2 +- .../Reddit/RedditHelperTests.cs | 53 ++++---- ASFFreeGames.sln.DotSettings | 5 + ASFFreeGames/ASFFreeGames.csproj | 2 +- ASFFreeGames/ASFFreeGamesPlugin.cs | 21 +-- .../BloomFilters/StringBloomFilterSpan.cs | 4 +- ASFFreeGames/Commands/CommandDispatcher.cs | 2 + ASFFreeGames/Commands/FreeGamesCommand.cs | 3 +- .../Commands/{ => GetIp}/GetIPCommand.cs | 16 ++- ASFFreeGames/Commands/GetIp/GetIpReponse.cs | 3 + .../Commands/GetIp/GetIpReponseContext.cs | 7 + .../Configurations/ASFFreeGamesOptions.cs | 56 ++++---- .../ASFFreeGamesOptionsContext.cs | 8 ++ .../ASFFreeGamesOptionsLoader.cs | 9 +- ASFFreeGames/GameIdentifierParser.cs | 2 +- ASFFreeGames/LoggerFilter.cs | 12 +- ASFFreeGames/PluginContext.cs | 1 + ASFFreeGames/RandomUtils.cs | 38 ++++-- ASFFreeGames/Reddit/RedditHelper.cs | 120 ++++++++++++------ ArchiSteamFarm | 2 +- Directory.Build.props | 2 +- Directory.Packages.props | 2 +- 23 files changed, 235 insertions(+), 137 deletions(-) rename ASFFreeGames/Commands/{ => GetIp}/GetIPCommand.cs (65%) create mode 100644 ASFFreeGames/Commands/GetIp/GetIpReponse.cs create mode 100644 ASFFreeGames/Commands/GetIp/GetIpReponseContext.cs create mode 100644 ASFFreeGames/Configurations/ASFFreeGamesOptionsContext.cs diff --git a/ASFFreeGames.Tests/ASFFreeGames.Tests.csproj b/ASFFreeGames.Tests/ASFFreeGames.Tests.csproj index 97ad63d..b00c897 100644 --- a/ASFFreeGames.Tests/ASFFreeGames.Tests.csproj +++ b/ASFFreeGames.Tests/ASFFreeGames.Tests.csproj @@ -4,6 +4,8 @@ enable false + + net8.0 diff --git a/ASFFreeGames.Tests/RandomUtilsTests.cs b/ASFFreeGames.Tests/RandomUtilsTests.cs index 7222f09..5398497 100644 --- a/ASFFreeGames.Tests/RandomUtilsTests.cs +++ b/ASFFreeGames.Tests/RandomUtilsTests.cs @@ -23,7 +23,7 @@ public static TheoryData 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(); + RandomUtils.GaussianRandom rng = new(); // Act // Generate a large number of samples from the normal distribution diff --git a/ASFFreeGames.Tests/Reddit/RedditHelperTests.cs b/ASFFreeGames.Tests/Reddit/RedditHelperTests.cs index ed37a32..061c0df 100644 --- a/ASFFreeGames.Tests/Reddit/RedditHelperTests.cs +++ b/ASFFreeGames.Tests/Reddit/RedditHelperTests.cs @@ -1,36 +1,37 @@ using System; +using System.Buffers; +using System.Collections.Generic; using System.IO; +using System.Linq; using System.Reflection; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; using Maxisoft.ASF.Reddit; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; +using Maxisoft.Utils.Collections.Spans; using Xunit; -namespace ASFFreeGames.Tests.Reddit; +namespace Maxisoft.ASF.Tests.Reddit; public sealed class RedditHelperTests { - private static readonly Lazy ASFinfo = new(LoadAsfinfoJson); - [Fact] - public void TestNotEmpty() { - JToken payload = ASFinfo.Value; - RedditGameEntry[] entries = RedditHelper.LoadMessages(payload.Value("data")!["children"]!); + public async Task TestNotEmpty() { + RedditGameEntry[] entries = await LoadAsfinfoEntries().ConfigureAwait(false); Assert.NotEmpty(entries); } [Theory] [InlineData("s/762440")] [InlineData("a/1601550")] - public void TestContains(string appid) { - JToken payload = ASFinfo.Value; - RedditGameEntry[] entries = RedditHelper.LoadMessages(payload.Value("data")!["children"]!); + public async Task TestContains(string appid) { + RedditGameEntry[] entries = await LoadAsfinfoEntries().ConfigureAwait(false); Assert.Contains(new RedditGameEntry(appid, default(ERedditGameEntryKind), long.MaxValue), entries, new GameEntryIdentifierEqualityComparer()); } [Fact] - public void TestMaintainOrder() { - JToken payload = ASFinfo.Value; - RedditGameEntry[] entries = RedditHelper.LoadMessages(payload.Value("data")!["children"]!); + public async Task TestMaintainOrder() { + RedditGameEntry[] entries = await LoadAsfinfoEntries().ConfigureAwait(false); int app762440 = Array.FindIndex(entries, static entry => entry.Identifier == "s/762440"); int app1601550 = Array.FindIndex(entries, static entry => entry.Identifier == "a/1601550"); Assert.InRange(app762440, 0, long.MaxValue); @@ -42,9 +43,8 @@ public void TestMaintainOrder() { } [Fact] - public void TestFreeToPlayParsing() { - JToken payload = ASFinfo.Value; - RedditGameEntry[] entries = RedditHelper.LoadMessages(payload.Value("data")!["children"]!); + public async Task TestFreeToPlayParsing() { + RedditGameEntry[] entries = await LoadAsfinfoEntries().ConfigureAwait(false); RedditGameEntry f2pEntry = Array.Find(entries, static entry => entry.Identifier == "a/1631250"); Assert.True(f2pEntry.IsFreeToPlay); @@ -70,9 +70,8 @@ public void TestFreeToPlayParsing() { } [Fact] - public void TestDlcParsing() { - JToken payload = ASFinfo.Value; - RedditGameEntry[] entries = RedditHelper.LoadMessages(payload.Value("data")!["children"]!); + public async Task TestDlcParsing() { + RedditGameEntry[] entries = await LoadAsfinfoEntries().ConfigureAwait(false); RedditGameEntry f2pEntry = Array.Find(entries, static entry => entry.Identifier == "a/1631250"); Assert.False(f2pEntry.IsForDlc); @@ -97,14 +96,18 @@ public void TestDlcParsing() { Assert.False(paidEntry.IsForDlc); } - private static JToken LoadAsfinfoJson() { + private static async Task LoadAsfinfoEntries() { Assembly assembly = Assembly.GetExecutingAssembly(); - using Stream stream = assembly.GetManifestResourceStream($"{assembly.GetName().Name}.ASFinfo.json")!; + await using Stream stream = assembly.GetManifestResourceStream($"{assembly.GetName().Name}.ASFinfo.json")!; + JsonNode jsonNode = await JsonNode.ParseAsync(stream).ConfigureAwait(false) ?? JsonNode.Parse("{}")!; + + return RedditHelper.LoadMessages(jsonNode["data"]?["children"]!); + } - using StreamReader reader = new(stream); - using JsonTextReader jsonTextReader = new(reader); + private static async Task ReadToEndAsync(Stream stream, CancellationToken cancellationToken) { + using StreamReader reader = new StreamReader(stream); - return JToken.Load(jsonTextReader); + return await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); } } diff --git a/ASFFreeGames.sln.DotSettings b/ASFFreeGames.sln.DotSettings index eaf19de..760550c 100644 --- a/ASFFreeGames.sln.DotSettings +++ b/ASFFreeGames.sln.DotSettings @@ -715,6 +715,10 @@ <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> <Policy Inspect="True" Prefix="T" Suffix="" Style="AaBb" /> + <Policy><Descriptor Staticness="Any" AccessRightKinds="Private" Description="Constant fields (private)"><ElementKinds><Kind Name="CONSTANT_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="I" Suffix="" Style="AaBb" /></Policy></Policy> + <Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Type parameters"><ElementKinds><Kind Name="TYPE_PARAMETER" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="T" Suffix="" Style="AaBb" /></Policy> + <Policy><Descriptor Staticness="Instance" AccessRightKinds="Private" Description="Instance fields (private)"><ElementKinds><Kind Name="FIELD" /><Kind Name="READONLY_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /></Policy> + <Policy><Descriptor Staticness="Static" AccessRightKinds="Private" Description="Static fields (private)"><ElementKinds><Kind Name="FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /></Policy> True OnlyMarkers @@ -761,6 +765,7 @@ True True True + True True True True diff --git a/ASFFreeGames/ASFFreeGames.csproj b/ASFFreeGames/ASFFreeGames.csproj index 58140e4..c9fa9d1 100644 --- a/ASFFreeGames/ASFFreeGames.csproj +++ b/ASFFreeGames/ASFFreeGames.csproj @@ -4,6 +4,7 @@ true True pdbonly + net8.0 @@ -11,7 +12,6 @@ - diff --git a/ASFFreeGames/ASFFreeGamesPlugin.cs b/ASFFreeGames/ASFFreeGamesPlugin.cs index 3493d2c..8482ccc 100644 --- a/ASFFreeGames/ASFFreeGamesPlugin.cs +++ b/ASFFreeGames/ASFFreeGamesPlugin.cs @@ -2,15 +2,16 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using ArchiSteamFarm.Collections; using ArchiSteamFarm.Plugins.Interfaces; using ArchiSteamFarm.Steam; using ASFFreeGames.Commands; +using ASFFreeGames.Configurations; using JetBrains.Annotations; using Maxisoft.ASF.Configurations; -using Newtonsoft.Json.Linq; using SteamKit2; using static ArchiSteamFarm.Core.ASF; @@ -24,7 +25,6 @@ internal interface IASFFreeGamesPlugin { } #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, IASFFreeGamesPlugin { internal const string StaticName = nameof(ASFFreeGamesPlugin); @@ -62,12 +62,6 @@ public ASFFreeGamesPlugin() { _context.Value = new PluginContext(Bots, BotContextRegistry, Options, LoggerFilter) { CancellationTokenLazy = new Lazy(() => CancellationTokenSourceLazy.Value.Token) }; } - public async Task OnASFInit(IReadOnlyDictionary? additionalConfigProperties = null) { - 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) { if (!Context.Valid) { CreateContext(); @@ -92,6 +86,17 @@ public Task OnLoaded() { return Task.CompletedTask; } + public async Task OnASFInit(IReadOnlyDictionary? additionalConfigProperties = null) { + ASFFreeGamesOptionsLoader.Bind(ref OptionsField); + JsonElement? jsonElement = GlobalDatabase?.LoadFromJsonStorage($"{Name}.Verbose"); + + if (jsonElement?.ValueKind is JsonValueKind.True) { + Options.VerboseLog = true; + } + + await SaveOptions(CancellationToken).ConfigureAwait(false); + } + public async Task OnUpdateFinished(Version currentVersion, Version newVersion) => await SaveOptions(Context.CancellationToken).ConfigureAwait(false); public Task OnUpdateProceeding(Version currentVersion, Version newVersion) => Task.CompletedTask; diff --git a/ASFFreeGames/BloomFilters/StringBloomFilterSpan.cs b/ASFFreeGames/BloomFilters/StringBloomFilterSpan.cs index 97c0a1f..da695f8 100644 --- a/ASFFreeGames/BloomFilters/StringBloomFilterSpan.cs +++ b/ASFFreeGames/BloomFilters/StringBloomFilterSpan.cs @@ -43,7 +43,7 @@ public StringBloomFilterSpan(BitSpan bitSpan, int k = 1) { /// Adds a new item to the filter. It cannot be removed. /// /// The item. - public void Add([JetBrains.Annotations.NotNull] in string item) { + public void Add(in string item) { // start flipping bits for each hash of item #pragma warning disable CA1062 int primaryHash = item.GetHashCode(StringComparison.Ordinal); @@ -61,7 +61,7 @@ public void Add([JetBrains.Annotations.NotNull] in string item) { /// /// The item. /// The . - public bool Contains([JetBrains.Annotations.NotNull] in string item) { + public bool Contains(in string item) { #pragma warning disable CA1062 int primaryHash = item.GetHashCode(StringComparison.Ordinal); #pragma warning restore CA1062 diff --git a/ASFFreeGames/Commands/CommandDispatcher.cs b/ASFFreeGames/Commands/CommandDispatcher.cs index a1d6679..491ac83 100644 --- a/ASFFreeGames/Commands/CommandDispatcher.cs +++ b/ASFFreeGames/Commands/CommandDispatcher.cs @@ -3,6 +3,8 @@ using System.Threading; using System.Threading.Tasks; using ArchiSteamFarm.Steam; +using ASFFreeGames.Commands.GetIp; +using ASFFreeGames.Configurations; using Maxisoft.ASF; namespace ASFFreeGames.Commands { diff --git a/ASFFreeGames/Commands/FreeGamesCommand.cs b/ASFFreeGames/Commands/FreeGamesCommand.cs index d3372c3..6d6b5f1 100644 --- a/ASFFreeGames/Commands/FreeGamesCommand.cs +++ b/ASFFreeGames/Commands/FreeGamesCommand.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using ArchiSteamFarm.Steam; +using ASFFreeGames.Configurations; using Maxisoft.ASF; using Maxisoft.ASF.Configurations; using Maxisoft.ASF.Reddit; @@ -190,7 +191,7 @@ private async Task CollectGames(IEnumerable bots, ECollectGameRequestS int res = 0; try { - ICollection games = await RedditHelper.GetGames().ConfigureAwait(false); + ICollection games = await RedditHelper.GetGames(cancellationToken).ConfigureAwait(false); LogNewGameCount(games, VerboseLog || requestSource is ECollectGameRequestSource.RequestedByUser); diff --git a/ASFFreeGames/Commands/GetIPCommand.cs b/ASFFreeGames/Commands/GetIp/GetIPCommand.cs similarity index 65% rename from ASFFreeGames/Commands/GetIPCommand.cs rename to ASFFreeGames/Commands/GetIp/GetIPCommand.cs index 8419944..fb14572 100644 --- a/ASFFreeGames/Commands/GetIPCommand.cs +++ b/ASFFreeGames/Commands/GetIp/GetIPCommand.cs @@ -1,18 +1,18 @@ using System; using System.Globalization; using System.IO; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using ArchiSteamFarm.Localization; using ArchiSteamFarm.Steam; using ArchiSteamFarm.Web; using ArchiSteamFarm.Web.Responses; -using Maxisoft.ASF; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; +using JsonSerializer = System.Text.Json.JsonSerializer; -namespace ASFFreeGames.Commands; +namespace ASFFreeGames.Commands.GetIp; +// ReSharper disable once ClassNeverInstantiated.Local internal sealed class GetIPCommand : IBotCommand { private const string GetIPAddressUrl = "https://httpbin.org/ip"; @@ -28,8 +28,12 @@ internal sealed class GetIPCommand : IBotCommand { } try { - ObjectResponse? result = await web.UrlGetToJsonObject(new Uri(GetIPAddressUrl)).ConfigureAwait(false); - string origin = result?.Content?.Value("origin") ?? ""; + await using StreamResponse? result = await web.UrlGetToStream(new Uri(GetIPAddressUrl), cancellationToken: cancellationToken).ConfigureAwait(false); + + if (result?.Content is null) { return null; } + + GetIpReponse? reponse = await JsonSerializer.DeserializeAsync(result.Content, cancellationToken: cancellationToken).ConfigureAwait(false); + string? origin = reponse?.Origin; if (!string.IsNullOrWhiteSpace(origin)) { return IBotCommand.FormatBotResponse(bot, origin); diff --git a/ASFFreeGames/Commands/GetIp/GetIpReponse.cs b/ASFFreeGames/Commands/GetIp/GetIpReponse.cs new file mode 100644 index 0000000..3d556ce --- /dev/null +++ b/ASFFreeGames/Commands/GetIp/GetIpReponse.cs @@ -0,0 +1,3 @@ +namespace ASFFreeGames.Commands.GetIp; + +internal record GetIpReponse(string Origin) { } diff --git a/ASFFreeGames/Commands/GetIp/GetIpReponseContext.cs b/ASFFreeGames/Commands/GetIp/GetIpReponseContext.cs new file mode 100644 index 0000000..94e0083 --- /dev/null +++ b/ASFFreeGames/Commands/GetIp/GetIpReponseContext.cs @@ -0,0 +1,7 @@ +using System.Text.Json.Serialization; + +namespace ASFFreeGames.Commands.GetIp; + +//[JsonSourceGenerationOptions(WriteIndented = false, UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)] +//[JsonSerializable(typeof(GetIpReponse))] +//internal partial class GetIpReponseContext : JsonSerializerContext { } diff --git a/ASFFreeGames/Configurations/ASFFreeGamesOptions.cs b/ASFFreeGames/Configurations/ASFFreeGamesOptions.cs index bebd9b7..733c020 100644 --- a/ASFFreeGames/Configurations/ASFFreeGamesOptions.cs +++ b/ASFFreeGames/Configurations/ASFFreeGamesOptions.cs @@ -2,44 +2,46 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Text.Json.Serialization; using ArchiSteamFarm.Steam; using Maxisoft.ASF; -using Newtonsoft.Json; -namespace Maxisoft.ASF { - public class ASFFreeGamesOptions { - // Use TimeSpan instead of long for representing time intervals - [JsonProperty("recheckInterval")] - public TimeSpan RecheckInterval { get; set; } = TimeSpan.FromMinutes(30); +namespace ASFFreeGames.Configurations; - // Use Nullable instead of bool? for nullable value types - [JsonProperty("randomizeRecheckInterval")] - public Nullable RandomizeRecheckInterval { get; set; } +public class ASFFreeGamesOptions { + // Use TimeSpan instead of long for representing time intervals + [JsonPropertyName("recheckInterval")] + public TimeSpan RecheckInterval { get; set; } = TimeSpan.FromMinutes(30); - [JsonProperty("skipFreeToPlay")] - public Nullable SkipFreeToPlay { get; set; } + // Use Nullable instead of bool? for nullable value types + [JsonPropertyName("randomizeRecheckInterval")] + public bool? RandomizeRecheckInterval { get; set; } - // ReSharper disable once InconsistentNaming - [JsonProperty("skipDLC")] - public Nullable SkipDLC { get; set; } + [JsonPropertyName("skipFreeToPlay")] + public bool? SkipFreeToPlay { get; set; } - // Use IReadOnlyCollection instead of HashSet for blacklist property - [JsonProperty("blacklist")] - public IReadOnlyCollection Blacklist { get; set; } = new HashSet(); + // ReSharper disable once InconsistentNaming + [JsonPropertyName("skipDLC")] + public bool? SkipDLC { get; set; } - [JsonProperty("verboseLog")] - public Nullable VerboseLog { get; set; } + // Use IReadOnlyCollection instead of HashSet for blacklist property + [JsonPropertyName("blacklist")] + public IReadOnlyCollection Blacklist { get; set; } = new HashSet(); - #region IsBlacklisted - public bool IsBlacklisted(in GameIdentifier gid) { - if (Blacklist.Count <= 0) { - return false; - } + [JsonPropertyName("verboseLog")] + public bool? VerboseLog { get; set; } - return Blacklist.Contains(gid.ToString()) || Blacklist.Contains(gid.Id.ToString(NumberFormatInfo.InvariantInfo)); + #region IsBlacklisted + public bool IsBlacklisted(in GameIdentifier gid) { + if (Blacklist.Count <= 0) { + return false; } - public bool IsBlacklisted(in Bot? bot) => bot is null || ((Blacklist.Count > 0) && Blacklist.Contains($"bot/{bot.BotName}")); - #endregion + return Blacklist.Contains(gid.ToString()) || Blacklist.Contains(gid.Id.ToString(CultureInfo.InvariantCulture)); } + + public bool IsBlacklisted(in Bot? bot) => bot is null || ((Blacklist.Count > 0) && Blacklist.Contains($"bot/{bot.BotName}")); + #endregion } + + diff --git a/ASFFreeGames/Configurations/ASFFreeGamesOptionsContext.cs b/ASFFreeGames/Configurations/ASFFreeGamesOptionsContext.cs new file mode 100644 index 0000000..0a35785 --- /dev/null +++ b/ASFFreeGames/Configurations/ASFFreeGamesOptionsContext.cs @@ -0,0 +1,8 @@ +using System.Text.Json.Serialization; +using ASFFreeGames.Configurations; + +namespace Maxisoft.ASF.Configurations; + +//[JsonSourceGenerationOptions(WriteIndented = false, UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +//[JsonSerializable(typeof(ASFFreeGamesOptions))] +//internal partial class ASFFreeGamesOptionsContext : JsonSerializerContext { } diff --git a/ASFFreeGames/Configurations/ASFFreeGamesOptionsLoader.cs b/ASFFreeGames/Configurations/ASFFreeGamesOptionsLoader.cs index 947c4ae..ec6d076 100644 --- a/ASFFreeGames/Configurations/ASFFreeGamesOptionsLoader.cs +++ b/ASFFreeGames/Configurations/ASFFreeGamesOptionsLoader.cs @@ -5,6 +5,8 @@ using System.Threading; using System.Threading.Tasks; using ArchiSteamFarm; +using ASFFreeGames.Commands.GetIp; +using ASFFreeGames.Configurations; using Microsoft.Extensions.Configuration; namespace Maxisoft.ASF.Configurations; @@ -58,12 +60,7 @@ public static async Task Save(ASFFreeGamesOptions options, CancellationToken can #pragma warning restore CAC001 // Use JsonSerializerOptions.PropertyNamingPolicy to specify the JSON property naming convention - await JsonSerializer.SerializeAsync( - fs, options, new JsonSerializerOptions { - WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }, cancellationToken - ).ConfigureAwait(false); + await JsonSerializer.SerializeAsync(fs, options, cancellationToken: cancellationToken).ConfigureAwait(false); } finally { Semaphore.Release(); diff --git a/ASFFreeGames/GameIdentifierParser.cs b/ASFFreeGames/GameIdentifierParser.cs index 801cfa7..d831af5 100644 --- a/ASFFreeGames/GameIdentifierParser.cs +++ b/ASFFreeGames/GameIdentifierParser.cs @@ -13,7 +13,7 @@ internal static class GameIdentifierParser { /// The query string to parse. /// The resulting game identifier if the parsing was successful. /// True if the parsing was successful; otherwise, false. - public static bool TryParse([NotNull] ReadOnlySpan query, out GameIdentifier result) { + public static bool TryParse(ReadOnlySpan query, out GameIdentifier result) { if (query.IsEmpty) // Check for empty query first { result = default(GameIdentifier); diff --git a/ASFFreeGames/LoggerFilter.cs b/ASFFreeGames/LoggerFilter.cs index 45b5bc0..a9b53b3 100644 --- a/ASFFreeGames/LoggerFilter.cs +++ b/ASFFreeGames/LoggerFilter.cs @@ -124,16 +124,10 @@ private static Logger GetLogger(ArchiLogger logger, string name = "ASF") { private bool RemoveFilters(BotName botName) => Filters.TryRemove(botName, out _); // A class that implements a disposable object for removing filters - private sealed class LoggerRemoveFilterDisposable : IDisposable { - private readonly LinkedListNode> Node; - - public LoggerRemoveFilterDisposable(LinkedListNode> node) => Node = node; - - public void Dispose() => Node.List?.Remove(Node); + private sealed class LoggerRemoveFilterDisposable(LinkedListNode> node) : IDisposable { + public void Dispose() => node.List?.Remove(node); } // A class that implements a custom filter that invokes a method - private class MarkedWhenMethodFilter : WhenMethodFilter { - public MarkedWhenMethodFilter(Func filterMethod) : base(filterMethod) { } - } + private class MarkedWhenMethodFilter(Func filterMethod) : WhenMethodFilter(filterMethod); } diff --git a/ASFFreeGames/PluginContext.cs b/ASFFreeGames/PluginContext.cs index 5684e42..f9e6631 100644 --- a/ASFFreeGames/PluginContext.cs +++ b/ASFFreeGames/PluginContext.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Threading; using ArchiSteamFarm.Steam; +using ASFFreeGames.Configurations; namespace Maxisoft.ASF; diff --git a/ASFFreeGames/RandomUtils.cs b/ASFFreeGames/RandomUtils.cs index e3c48a0..ac9f713 100644 --- a/ASFFreeGames/RandomUtils.cs +++ b/ASFFreeGames/RandomUtils.cs @@ -11,7 +11,8 @@ namespace Maxisoft.ASF; #nullable enable public static class RandomUtils { - internal sealed class GaussianRandom : RandomNumberGenerator { + internal sealed class GaussianRandom { + // A flag to indicate if there is a stored value for the next Gaussian number private int HasNextGaussian; @@ -21,28 +22,43 @@ internal sealed class GaussianRandom : RandomNumberGenerator { // The stored value for the next Gaussian number private double NextGaussianValue; - public override void GetBytes(byte[] data) => Fill(data); + private void GetNonZeroBytes(Span data) { + Span bytes = stackalloc byte[sizeof(long)]; + + static void fill(Span bytes) { + // use this method to use a RNGs function that's still included with the ASF trimmed binary + // do not try to refactor or optimize this without testing + byte[] rng = RandomNumberGenerator.GetBytes(bytes.Length); + ((ReadOnlySpan) rng).CopyTo(bytes); + } - public override void GetNonZeroBytes(Span data) { - Fill(data); - Span buffer = stackalloc byte[1]; + fill(bytes); + int c = 0; for (int i = 0; i < data.Length; i++) { - while (data[i] == default(byte)) { - Fill(buffer); - data[i] = buffer[0]; - } + byte value; + + do { + value = bytes[c]; + c++; + + if (c >= bytes.Length) { + fill(bytes); + c = 0; + } + } while (value == 0); + + data[i] = value; } } - public override void GetNonZeroBytes(byte[] data) => GetNonZeroBytes((Span) data); - private double NextDouble() { if (Interlocked.CompareExchange(ref HasNextGaussian, False, True) == True) { return NextGaussianValue; } Span bytes = stackalloc byte[2 * sizeof(long)]; + Span ulongs = MemoryMarshal.Cast(bytes); double u1; diff --git a/ASFFreeGames/Reddit/RedditHelper.cs b/ASFFreeGames/Reddit/RedditHelper.cs index 323a06d..884c3a3 100644 --- a/ASFFreeGames/Reddit/RedditHelper.cs +++ b/ASFFreeGames/Reddit/RedditHelper.cs @@ -2,31 +2,34 @@ using System.Buffers; using System.Collections.Generic; using System.IO; +using System.Linq; +using System.Net; +using System.Text; +using System.Text.Json; // Not using System.Text.Json for JsonDocument +using System.Text.Json.Nodes; // Using System.Text.Json.Nodes for JsonNode using System.Text.RegularExpressions; +using System.Threading; using System.Threading.Tasks; using ArchiSteamFarm.Core; +using ArchiSteamFarm.Helpers.Json; using ArchiSteamFarm.Web; using ArchiSteamFarm.Web.Responses; using BloomFilter; using Maxisoft.Utils.Collections.Spans; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; namespace Maxisoft.ASF.Reddit; internal sealed partial class RedditHelper { private const int BloomFilterBufferSize = 8; - private const int PoolMaxGameEntry = 1024; private const string User = "ASFinfo"; private static readonly ArrayPool ArrayPool = ArrayPool.Create(PoolMaxGameEntry, 1); - /// A method that gets a collection of Reddit game entries from a JSON object /// /// Gets a collection of Reddit game entries from a JSON object. /// /// A collection of Reddit game entries. - public static async ValueTask> GetGames() { + public static async ValueTask> GetGames(CancellationToken cancellationToken) { WebBrowser? webBrowser = ArchiSteamFarm.Core.ASF.WebBrowser; RedditGameEntry[] result = Array.Empty(); @@ -34,36 +37,35 @@ public static async ValueTask> GetGames() { return result; } - ObjectResponse? jsonPayload = null; + JsonNode jsonPayload; try { - jsonPayload = await TryGetPayload(webBrowser).ConfigureAwait(false); + ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericInfo("b4 the payload"); + jsonPayload = await GetPayload(webBrowser, cancellationToken).ConfigureAwait(false) ?? JsonNode.Parse("{}")!; + ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericInfo($"got the payload"); } catch (Exception exception) when (exception is JsonException or IOException) { return result; } - if (jsonPayload is null) { - return result; + try { + if ((jsonPayload["kind"]?.GetValue() != "Listing") || + jsonPayload["data"] is null) { + return result; + } } + catch (Exception e) when (e is FormatException or InvalidOperationException) { + ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericInfo("invalid json"); - // Use pattern matching to check for null and type - if (jsonPayload.Content is JObject jObject && - jObject.TryGetValue("kind", out JToken? kind) && - (kind.Value() == "Listing") && - jObject.TryGetValue("data", out JToken? data) && - data is JObject) { - JToken? children = data["children"]; - - if (children is not null) { - return LoadMessages(children); - } + return result; } - return result; // Return early if children is not found or not an array + JsonNode? childrenElement = jsonPayload["data"]?["children"]; + + return childrenElement is null ? result : LoadMessages(childrenElement); } - internal static RedditGameEntry[] LoadMessages(JToken children) { + internal static RedditGameEntry[] LoadMessages(JsonNode children) { Span bloomFilterBuffer = stackalloc long[BloomFilterBufferSize]; StringBloomFilterSpan bloomFilter = new(bloomFilterBuffer, 3); RedditGameEntry[] buffer = ArrayPool.Rent(PoolMaxGameEntry / 2); @@ -71,10 +73,34 @@ internal static RedditGameEntry[] LoadMessages(JToken children) { try { SpanList list = new(buffer); - foreach (JObject comment in children.Children()) { - JToken? commentData = comment.GetValue("data", StringComparison.InvariantCulture); - string text = commentData?.Value("body") ?? string.Empty; - long date = commentData?.Value("created_utc") ?? commentData?.Value("created") ?? 0; + // ReSharper disable once LoopCanBePartlyConvertedToQuery + foreach (JsonNode? comment in children.AsArray()) { + JsonNode? commentData = comment?["data"]; + + if (commentData is null) { + continue; + } + + long date; + string text; + + try { + text = commentData["body"]?.GetValue() ?? string.Empty; + + date = checked((long) (commentData["created_utc"]?.GetValue() ?? 0)); + + if (!double.IsNormal(date)) { + date = checked((long) (commentData["created"]?.GetValue() ?? 0)); + } + } + catch (Exception e) when (e is FormatException or InvalidOperationException) { + continue; + } + + if (!double.IsNormal(date) || (date <= 0)) { + continue; + } + MatchCollection matches = CommandRegex().Matches(text); foreach (Match match in matches) { @@ -95,7 +121,6 @@ internal static RedditGameEntry[] LoadMessages(JToken children) { foreach (Capture capture in matchGroup.Captures) { RedditGameEntry gameEntry = new(capture.Value, kind, date); - int index = -1; if (bloomFilter.Contains(gameEntry.Identifier)) { @@ -125,7 +150,6 @@ internal static RedditGameEntry[] LoadMessages(JToken children) { return list.ToArray(); } finally { - // Use a finally block to ensure that the buffer is returned to the pool ArrayPool.Return(buffer); } } @@ -145,24 +169,48 @@ internal static RedditGameEntry[] LoadMessages(JToken children) { /// Tries to get a JSON object from Reddit. /// /// The web browser instance to use. + /// /// A JSON object response or null if failed. /// Thrown when Reddit returns a server error. /// This method is based on this GitHub issue: https://github.com/maxisoft/ASFFreeGames/issues/28 - private static async Task?> TryGetPayload(WebBrowser webBrowser) { + private static async ValueTask GetPayload(WebBrowser webBrowser, CancellationToken cancellationToken) { + StreamResponse? stream = null; + try { - return await webBrowser.UrlGetToJsonObject(GetUrl(), rateLimitingDelay: 500).ConfigureAwait(false); - } + stream = await webBrowser.UrlGetToStream(GetUrl(), rateLimitingDelay: 500, cancellationToken: cancellationToken).ConfigureAwait(false); - catch (JsonReaderException) { - // ReSharper disable once UseAwaitUsing - using StreamResponse? response = await webBrowser.UrlGetToStream(GetUrl(), rateLimitingDelay: 500).ConfigureAwait(false); + if (stream?.Content is null) { + throw new RedditServerException("Reddit server error: content is null", stream?.StatusCode ?? HttpStatusCode.InternalServerError); + } - if (response is not null && response.StatusCode.IsServerErrorCode()) { - throw new RedditServerException($"Reddit server error: {response.StatusCode}", response.StatusCode); + return await ParseJsonNode(stream, cancellationToken).ConfigureAwait(false); + } + catch (JsonException) { + if (stream is not null && stream.StatusCode.IsServerErrorCode()) { + throw new RedditServerException($"Reddit server error: {stream.StatusCode}", stream.StatusCode); } // If no RedditServerException was thrown, re-throw the original JsonReaderException throw; } + finally { + if (stream is not null) { + await stream.DisposeAsync().ConfigureAwait(false); + } + + stream = null; + } + } + + /// + /// Parses a JSON object from a stream response. Using not straightforward for ASF trimmed compatibility reasons + /// + /// The stream response containing the JSON data. + /// The cancellation token. + /// The parsed JSON object, or null if parsing fails. + private static async Task ParseJsonNode(StreamResponse stream, CancellationToken cancellationToken) { + using StreamReader reader = new(stream.Content!, Encoding.UTF8); + + return JsonNode.Parse(await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false)); } } diff --git a/ArchiSteamFarm b/ArchiSteamFarm index 113e0c9..efb7262 160000 --- a/ArchiSteamFarm +++ b/ArchiSteamFarm @@ -1 +1 @@ -Subproject commit 113e0c9b3c5758ebb04fa1c4a3cac5fd006730fc +Subproject commit efb726211381a781da086415a6414ae3038d98bd diff --git a/Directory.Build.props b/Directory.Build.props index ab75cfc..1c398af 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,7 +4,7 @@ ASFFreeGames 1.4.0.0 - net7.0 + net8.0 diff --git a/Directory.Packages.props b/Directory.Packages.props index a4d766d..90ed91c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,7 +1,7 @@ - + From 465bcb931de5802b3efd9d38a8b6684a4cd146c5 Mon Sep 17 00:00:00 2001 From: maxisoft Date: Sat, 4 May 2024 20:26:14 +0200 Subject: [PATCH 06/14] fix(ASFFreeGamesOptions): Ensure correct file size when saving configuration - Updated `Save` method to address potential issue with file size after writing JSON data. - Implemented option to set file size explicitly after writing using `fs.SetLength(fs.Position)`. This change prevents potential corruption in the saved configuration file by ensuring the correct size reflects the actual JSON content. --- ASFFreeGames/Configurations/ASFFreeGamesOptionsLoader.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/ASFFreeGames/Configurations/ASFFreeGamesOptionsLoader.cs b/ASFFreeGames/Configurations/ASFFreeGamesOptionsLoader.cs index ec6d076..bb8a636 100644 --- a/ASFFreeGames/Configurations/ASFFreeGamesOptionsLoader.cs +++ b/ASFFreeGames/Configurations/ASFFreeGamesOptionsLoader.cs @@ -61,6 +61,7 @@ public static async Task Save(ASFFreeGamesOptions options, CancellationToken can // Use JsonSerializerOptions.PropertyNamingPolicy to specify the JSON property naming convention await JsonSerializer.SerializeAsync(fs, options, cancellationToken: cancellationToken).ConfigureAwait(false); + fs.SetLength(fs.Position); } finally { Semaphore.Release(); From 01b324cf925521b41ae55bb53ecb4dec8009702d Mon Sep 17 00:00:00 2001 From: maxisoft Date: Sat, 4 May 2024 20:28:12 +0200 Subject: [PATCH 07/14] Bump to version 1.5.0 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 3f9eeac..440f822 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ ASFFreeGames - 1.4.1.0 + 1.5.0.0 net8.0 From b437015ce35e0bf526a3a7c4d03833331f54bb17 Mon Sep 17 00:00:00 2001 From: maxisoft Date: Sat, 4 May 2024 20:35:50 +0200 Subject: [PATCH 08/14] Add Microsoft.NET.Test.Sdk version to package versions - Included the version number for Microsoft.NET.Test.Sdk in Directory.Packages.props to align with project dependencies and resolve NU1010 build error. --- Directory.Packages.props | 1 + 1 file changed, 1 insertion(+) diff --git a/Directory.Packages.props b/Directory.Packages.props index 90ed91c..c120255 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,5 +5,6 @@ + From a367e4d260653da79427a83d19663bb965bbacad Mon Sep 17 00:00:00 2001 From: maxisoft Date: Sat, 4 May 2024 20:37:42 +0200 Subject: [PATCH 09/14] Refactor code to ensure successful compilation in publish mode - Changed the type of CollectIntervalManager from an interface to a concrete class in ASFFreeGamesPlugin. - Suppressed specific warnings in GetIPCommand to prevent compilation issues related to asynchronous calls and code analysis rules. --- ASFFreeGames/ASFFreeGamesPlugin.cs | 2 +- ASFFreeGames/Commands/GetIp/GetIPCommand.cs | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/ASFFreeGames/ASFFreeGamesPlugin.cs b/ASFFreeGames/ASFFreeGamesPlugin.cs index 8482ccc..cd96e28 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 ICollectIntervalManager CollectIntervalManager; + private readonly CollectIntervalManager CollectIntervalManager; public ASFFreeGamesPlugin() { CommandDispatcher = new CommandDispatcher(Options); diff --git a/ASFFreeGames/Commands/GetIp/GetIPCommand.cs b/ASFFreeGames/Commands/GetIp/GetIPCommand.cs index fb14572..62f98e2 100644 --- a/ASFFreeGames/Commands/GetIp/GetIPCommand.cs +++ b/ASFFreeGames/Commands/GetIp/GetIPCommand.cs @@ -28,7 +28,11 @@ internal sealed class GetIPCommand : IBotCommand { } try { +#pragma warning disable CAC001 +#pragma warning disable CA2007 await using StreamResponse? result = await web.UrlGetToStream(new Uri(GetIPAddressUrl), cancellationToken: cancellationToken).ConfigureAwait(false); +#pragma warning restore CA2007 +#pragma warning restore CAC001 if (result?.Content is null) { return null; } From 411fdaa9c4c128bf492939877898cdf7da3b7b6f Mon Sep 17 00:00:00 2001 From: maxisoft Date: Sat, 4 May 2024 20:41:47 +0200 Subject: [PATCH 10/14] Update GitHub Actions workflows to use .NET 8.0 SDK - Aligned DOTNET_SDK_VERSION with the upgraded project framework across CI, publish, and test integration workflows. - Ensured consistency in .NET versioning to facilitate correct environment setup for build and test processes. --- .github/workflows/ci.yml | 4 ++-- .github/workflows/publish.yml | 4 ++-- .github/workflows/test_integration.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 901ed3f..861c3bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,8 +5,8 @@ on: [push, pull_request] env: DOTNET_CLI_TELEMETRY_OPTOUT: true DOTNET_NOLOGO: true - DOTNET_SDK_VERSION: 7.0.x - DOTNET_FRAMEWORK: net7.0 + DOTNET_SDK_VERSION: 8.0.x + DOTNET_FRAMEWORK: net8.0 jobs: main: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5c2eae8..f120d0d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,8 +6,8 @@ env: CONFIGURATION: Release DOTNET_CLI_TELEMETRY_OPTOUT: true DOTNET_NOLOGO: true - DOTNET_SDK_VERSION: 7.0.x - NET_CORE_VERSION: net7.0 + DOTNET_SDK_VERSION: 8.0.x + NET_CORE_VERSION: net8.0 NET_FRAMEWORK_VERSION: net48 PLUGIN_NAME: ASFFreeGames diff --git a/.github/workflows/test_integration.yml b/.github/workflows/test_integration.yml index 6d4f2f9..604956a 100644 --- a/.github/workflows/test_integration.yml +++ b/.github/workflows/test_integration.yml @@ -11,7 +11,7 @@ on: env: DOTNET_CLI_TELEMETRY_OPTOUT: true DOTNET_NOLOGO: true - DOTNET_SDK_VERSION: 7.0.x + DOTNET_SDK_VERSION: 8.0.x concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} From 45831a7c8006e49498cc749c689217db406be612 Mon Sep 17 00:00:00 2001 From: maxisoft Date: Sat, 4 May 2024 21:05:28 +0200 Subject: [PATCH 11/14] Suppressed CA2007 on a unit test warning that was preventing CI builds. --- ASFFreeGames.Tests/Reddit/RedditHelperTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ASFFreeGames.Tests/Reddit/RedditHelperTests.cs b/ASFFreeGames.Tests/Reddit/RedditHelperTests.cs index 061c0df..49b1f6f 100644 --- a/ASFFreeGames.Tests/Reddit/RedditHelperTests.cs +++ b/ASFFreeGames.Tests/Reddit/RedditHelperTests.cs @@ -99,14 +99,16 @@ public async Task TestDlcParsing() { private static async Task LoadAsfinfoEntries() { Assembly assembly = Assembly.GetExecutingAssembly(); +#pragma warning disable CA2007 await using Stream stream = assembly.GetManifestResourceStream($"{assembly.GetName().Name}.ASFinfo.json")!; +#pragma warning restore CA2007 JsonNode jsonNode = await JsonNode.ParseAsync(stream).ConfigureAwait(false) ?? JsonNode.Parse("{}")!; return RedditHelper.LoadMessages(jsonNode["data"]?["children"]!); } private static async Task ReadToEndAsync(Stream stream, CancellationToken cancellationToken) { - using StreamReader reader = new StreamReader(stream); + using StreamReader reader = new(stream); return await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); } From 6098d75b0bf890cb3cc2dcc2d46dd7eeca922954 Mon Sep 17 00:00:00 2001 From: maxisoft Date: Sun, 5 May 2024 11:23:18 +0200 Subject: [PATCH 12/14] Remove debug logging from RedditHelper - Deleted unnecessary debug log statements from the GetGames method. --- ASFFreeGames/Reddit/RedditHelper.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/ASFFreeGames/Reddit/RedditHelper.cs b/ASFFreeGames/Reddit/RedditHelper.cs index 884c3a3..93edb18 100644 --- a/ASFFreeGames/Reddit/RedditHelper.cs +++ b/ASFFreeGames/Reddit/RedditHelper.cs @@ -40,9 +40,7 @@ public static async ValueTask> GetGames(Cancellatio JsonNode jsonPayload; try { - ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericInfo("b4 the payload"); jsonPayload = await GetPayload(webBrowser, cancellationToken).ConfigureAwait(false) ?? JsonNode.Parse("{}")!; - ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericInfo($"got the payload"); } catch (Exception exception) when (exception is JsonException or IOException) { return result; From 9bb37217287538a5fd8b47784e71635b0309b5ce Mon Sep 17 00:00:00 2001 From: maxisoft Date: Sun, 5 May 2024 11:47:14 +0200 Subject: [PATCH 13/14] Refine error handling and retry logic in RedditHelper - Simplified exception handling by removing redundant try-catch blocks. - Implemented retry logic with exponential backoff for fetching payload. - Streamlined JsonNode parsing and error handling for more robust operation. --- ASFFreeGames/Reddit/RedditHelper.cs | 65 ++++++++++++++++------------- 1 file changed, 37 insertions(+), 28 deletions(-) diff --git a/ASFFreeGames/Reddit/RedditHelper.cs b/ASFFreeGames/Reddit/RedditHelper.cs index 93edb18..be5c0b9 100644 --- a/ASFFreeGames/Reddit/RedditHelper.cs +++ b/ASFFreeGames/Reddit/RedditHelper.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Net; +using System.Net.Http; using System.Text; using System.Text.Json; // Not using System.Text.Json for JsonDocument using System.Text.Json.Nodes; // Using System.Text.Json.Nodes for JsonNode @@ -37,14 +38,7 @@ public static async ValueTask> GetGames(Cancellatio return result; } - JsonNode jsonPayload; - - try { - jsonPayload = await GetPayload(webBrowser, cancellationToken).ConfigureAwait(false) ?? JsonNode.Parse("{}")!; - } - catch (Exception exception) when (exception is JsonException or IOException) { - return result; - } + JsonNode jsonPayload = await GetPayload(webBrowser, cancellationToken).ConfigureAwait(false) ?? JsonNode.Parse("{}")!; try { if ((jsonPayload["kind"]?.GetValue() != "Listing") || @@ -85,9 +79,14 @@ internal static RedditGameEntry[] LoadMessages(JsonNode children) { try { text = commentData["body"]?.GetValue() ?? string.Empty; - date = checked((long) (commentData["created_utc"]?.GetValue() ?? 0)); + try { + date = checked((long) (commentData["created_utc"]?.GetValue() ?? 0)); + } + catch (Exception e) when (e is FormatException or InvalidOperationException) { + date = 0; + } - if (!double.IsNormal(date)) { + if (!double.IsNormal(date) || (date <= 0)) { date = checked((long) (commentData["created"]?.GetValue() ?? 0)); } } @@ -168,36 +167,46 @@ internal static RedditGameEntry[] LoadMessages(JsonNode children) { /// /// The web browser instance to use. /// + /// /// A JSON object response or null if failed. /// Thrown when Reddit returns a server error. /// This method is based on this GitHub issue: https://github.com/maxisoft/ASFFreeGames/issues/28 - private static async ValueTask GetPayload(WebBrowser webBrowser, CancellationToken cancellationToken) { + private static async ValueTask GetPayload(WebBrowser webBrowser, CancellationToken cancellationToken, uint retry = 5) { StreamResponse? stream = null; - try { - stream = await webBrowser.UrlGetToStream(GetUrl(), rateLimitingDelay: 500, cancellationToken: cancellationToken).ConfigureAwait(false); + for (int t = 0; t < retry; t++) { + try { + stream = await webBrowser.UrlGetToStream(GetUrl(), rateLimitingDelay: 500, cancellationToken: cancellationToken).ConfigureAwait(false); - if (stream?.Content is null) { - throw new RedditServerException("Reddit server error: content is null", stream?.StatusCode ?? HttpStatusCode.InternalServerError); - } + if (stream?.Content is null) { + throw new RedditServerException("Reddit server error: content is null", stream?.StatusCode ?? HttpStatusCode.InternalServerError); + } - return await ParseJsonNode(stream, cancellationToken).ConfigureAwait(false); - } - catch (JsonException) { - if (stream is not null && stream.StatusCode.IsServerErrorCode()) { - throw new RedditServerException($"Reddit server error: {stream.StatusCode}", stream.StatusCode); + if (stream.StatusCode.IsServerErrorCode()) { + throw new RedditServerException($"Reddit server error: {stream.StatusCode}", stream.StatusCode); + } + + return await ParseJsonNode(stream, cancellationToken).ConfigureAwait(false); + } + catch (Exception e) when (e is JsonException or IOException or RedditServerException or HttpRequestException) { + // If no RedditServerException was thrown, re-throw the original Exception + if (t + 1 == retry) { + throw; + } } + finally { + if (stream is not null) { + await stream.DisposeAsync().ConfigureAwait(false); + } - // If no RedditServerException was thrown, re-throw the original JsonReaderException - throw; - } - finally { - if (stream is not null) { - await stream.DisposeAsync().ConfigureAwait(false); + stream = null; } - stream = null; + await Task.Delay((2 << t) * 100, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); } + + return JsonNode.Parse("{}"); } /// From 45e97338ee2ce2c03ca7ad33c0f27b50f7c4a87a Mon Sep 17 00:00:00 2001 From: maxisoft Date: Sun, 5 May 2024 12:02:06 +0200 Subject: [PATCH 14/14] Bump to version 1.5.1 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 440f822..6bf6713 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ ASFFreeGames - 1.5.0.0 + 1.5.1.0 net8.0