diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5bd967..4b89ffe 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 6d6b042..7b3c6a0 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 1d8f885..a5fc2e2 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 }} 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..49b1f6f 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,20 @@ 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")!; +#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(stream); - using JsonTextReader jsonTextReader = new(reader); - 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..cd96e28 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); @@ -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); @@ -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 60% rename from ASFFreeGames/Commands/GetIPCommand.cs rename to ASFFreeGames/Commands/GetIp/GetIPCommand.cs index 8419944..62f98e2 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,16 @@ internal sealed class GetIPCommand : IBotCommand { } try { - ObjectResponse? result = await web.UrlGetToJsonObject(new Uri(GetIPAddressUrl)).ConfigureAwait(false); - string origin = result?.Content?.Value("origin") ?? ""; +#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; } + + 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..bb8a636 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,8 @@ 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); + fs.SetLength(fs.Position); } 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 e9ef9ae..ac9f713 100644 --- a/ASFFreeGames/RandomUtils.cs +++ b/ASFFreeGames/RandomUtils.cs @@ -1,77 +1,83 @@ using System; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using System.Security.Cryptography; +using System.Threading; 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 { - 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); + private void GetNonZeroBytes(Span data) { + Span bytes = stackalloc byte[sizeof(long)]; - public override void GetNonZeroBytes(byte[] data) => Fill(data); + 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); + } - private double NextDouble() { - if (HasNextGaussian) { - HasNextGaussian = false; + fill(bytes); + int c = 0; + + for (int i = 0; i < data.Length; i++) { + byte value; + do { + value = bytes[c]; + c++; + + if (c >= bytes.Length) { + fill(bytes); + c = 0; + } + } while (value == 0); + + data[i] = value; + } + } + + private double NextDouble() { + if (Interlocked.CompareExchange(ref HasNextGaussian, False, True) == True) { 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[2 * sizeof(long)]; + + Span ulongs = MemoryMarshal.Cast(bytes); + double u1; + + do { + GetNonZeroBytes(bytes); + u1 = ulongs[0] / (double) ulong.MaxValue; + } while (u1 <= double.Epsilon); - // Apply the Box-Muller formula - float r = MathF.Sqrt(-2.0f * MathF.Log(u1)); - float theta = 2.0f * MathF.PI * u2; + double u2 = ulongs[1] / (double) ulong.MaxValue; - // Store one of the values for next time - NextGaussianValue = r * MathF.Sin(theta); - HasNextGaussian = true; + // Box-Muller formula + double r = Math.Sqrt(-2.0 * Math.Log(u1)); + double theta = 2.0 * Math.PI * u2; - // Return the other value - return r * MathF.Cos(theta); + if (Interlocked.CompareExchange(ref HasNextGaussian, True, False) == False) { + NextGaussianValue = r * Math.Sin(theta); + } + + return r * Math.Cos(theta); } /// @@ -83,9 +89,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); + } } } diff --git a/ASFFreeGames/Reddit/RedditHelper.cs b/ASFFreeGames/Reddit/RedditHelper.cs index 323a06d..be5c0b9 100644 --- a/ASFFreeGames/Reddit/RedditHelper.cs +++ b/ASFFreeGames/Reddit/RedditHelper.cs @@ -2,31 +2,35 @@ using System.Buffers; using System.Collections.Generic; 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 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 +38,26 @@ public static async ValueTask> GetGames() { return result; } - ObjectResponse? jsonPayload = null; + JsonNode jsonPayload = await GetPayload(webBrowser, cancellationToken).ConfigureAwait(false) ?? JsonNode.Parse("{}")!; try { - jsonPayload = await TryGetPayload(webBrowser).ConfigureAwait(false); - } - catch (Exception exception) when (exception is JsonException or IOException) { - return result; + 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"); - if (jsonPayload is null) { return result; } - // 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); - } - } + JsonNode? childrenElement = jsonPayload["data"]?["children"]; - return result; // Return early if children is not found or not an array + 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 +65,39 @@ 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; + + try { + date = checked((long) (commentData["created_utc"]?.GetValue() ?? 0)); + } + catch (Exception e) when (e is FormatException or InvalidOperationException) { + date = 0; + } + + if (!double.IsNormal(date) || (date <= 0)) { + 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 +118,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 +147,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 +166,58 @@ 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) { - try { - return await webBrowser.UrlGetToJsonObject(GetUrl(), rateLimitingDelay: 500).ConfigureAwait(false); - } + private static async ValueTask GetPayload(WebBrowser webBrowser, CancellationToken cancellationToken, uint retry = 5) { + StreamResponse? stream = null; + + for (int t = 0; t < retry; t++) { + try { + 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 (stream.StatusCode.IsServerErrorCode()) { + throw new RedditServerException($"Reddit server error: {stream.StatusCode}", stream.StatusCode); + } - 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 (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); + } + + stream = null; } - // If no RedditServerException was thrown, re-throw the original JsonReaderException - throw; + await Task.Delay((2 << t) * 100, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); } + + return JsonNode.Parse("{}"); + } + + /// + /// 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 25420d7..6bf6713 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,8 +3,8 @@ ASFFreeGames - 1.4.1.0 - net7.0 + 1.5.1.0 + net8.0 diff --git a/Directory.Packages.props b/Directory.Packages.props index a4d766d..c120255 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,9 +1,10 @@ - + +