From c8c4497b115b80f736342bfc880586b432b233d9 Mon Sep 17 00:00:00 2001 From: peter9811 Date: Fri, 7 Mar 2025 13:45:03 +1000 Subject: [PATCH 1/7] Enhance configuration options: Add auto-blacklist, delay, and retry settings --- ASFFreeGames/Commands/FreeGamesCommand.cs | 201 +++++++++++++++--- .../Configurations/ASFFreeGamesOptions.cs | 41 ++++ README.md | 23 ++ 3 files changed, 237 insertions(+), 28 deletions(-) diff --git a/ASFFreeGames/Commands/FreeGamesCommand.cs b/ASFFreeGames/Commands/FreeGamesCommand.cs index 178f580..ecd94d5 100644 --- a/ASFFreeGames/Commands/FreeGamesCommand.cs +++ b/ASFFreeGames/Commands/FreeGamesCommand.cs @@ -69,6 +69,13 @@ public void Dispose() { return await HandleInternalSaveOptionsCommand(bot, cancellationToken).ConfigureAwait(false); case CollectInternalCommandString: return await HandleInternalCollectCommand(bot, args, cancellationToken).ConfigureAwait(false); + case "SHOWBLACKLIST": + if (Options.Blacklist.Count == 0) { + return FormatBotResponse(bot, "Blacklist is empty"); + } else { + string blacklistItems = string.Join(", ", Options.Blacklist); + return FormatBotResponse(bot, $"Current blacklist: {blacklistItems}"); + } } } @@ -119,7 +126,36 @@ public void Dispose() { await SaveOptions(cancellationToken).ConfigureAwait(false); return FormatBotResponse(bot, $"{ASFFreeGamesPlugin.StaticName} is now skipping dlc"); + case "CLEARBLACKLIST": + Options.ClearBlacklist(); + await SaveOptions(cancellationToken).ConfigureAwait(false); + return FormatBotResponse(bot, $"{ASFFreeGamesPlugin.StaticName} blacklist has been cleared"); + case "REMOVEBLACKLIST": + if (args.Length >= 4) { + string identifier = args[3]; + if (GameIdentifier.TryParse(identifier, out GameIdentifier gid)) { + bool removed = Options.RemoveFromBlacklist(in gid); + await SaveOptions(cancellationToken).ConfigureAwait(false); + + if (removed) { + return FormatBotResponse(bot, $"{ASFFreeGamesPlugin.StaticName} removed {gid} from blacklist"); + } else { + return FormatBotResponse(bot, $"{ASFFreeGamesPlugin.StaticName} could not find {gid} in blacklist"); + } + } else { + return FormatBotResponse(bot, $"Invalid game identifier format: {identifier}"); + } + } else { + return FormatBotResponse(bot, "Please provide a game identifier to remove from blacklist"); + } + case "SHOWBLACKLIST": + if (Options.Blacklist.Count == 0) { + return FormatBotResponse(bot, "Blacklist is empty"); + } else { + string blacklistItems = string.Join(", ", Options.Blacklist); + return FormatBotResponse(bot, $"Current blacklist: {blacklistItems}"); + } default: return FormatBotResponse(bot, $"Unknown \"{args[2]}\" variable to set"); } @@ -239,6 +275,9 @@ private async Task CollectGames(IEnumerable bots, ECollectGameRequestS PreviousSucessfulStrategy = PreviousSucessfulStrategy }; + // Cache of known invalid packages to avoid repeated failed attempts within the same collection run + HashSet knownInvalidPackages = new(); + try { #pragma warning disable CA2000 games = await Strategy.GetGames(strategyContext, cancellationToken).ConfigureAwait(false); @@ -312,6 +351,14 @@ private async Task CollectGames(IEnumerable bots, ECollectGameRequestS continue; } + // Skip packages that have already failed in this collection run + if (knownInvalidPackages.Contains(gid.ToString())) { + if (VerboseLog) { + bot.ArchiLogger.LogGenericDebug($"Skipping previously failed package in this run: {gid}", nameof(CollectGames)); + } + continue; + } + string? resp; string cmd = $"ADDLICENSE {bot.BotName} {gid}"; @@ -320,47 +367,145 @@ private async Task CollectGames(IEnumerable bots, ECollectGameRequestS bot.ArchiLogger.LogGenericDebug($"Trying to perform command \"{cmd}\"", nameof(CollectGames)); } - using (LoggerFilter.DisableLoggingForAddLicenseCommonErrors(_ => !VerboseLog && (requestSource is not ECollectGameRequestSource.RequestedByUser) && context.ShouldHideErrorLogForApp(in gid), bot)) { - resp = await bot.Commands.Response(EAccess.Operator, cmd).ConfigureAwait(false); - } + int retryAttempts = 0; + int maxRetries = Options.MaxRetryAttempts ?? 1; + bool isTransientError = false; - bool success = false; + do { + if (retryAttempts > 0) { + // Add delay before retry + int retryDelay = Options.RetryDelayMilliseconds ?? 2000; + await Task.Delay(retryDelay, cancellationToken).ConfigureAwait(false); - if (!string.IsNullOrWhiteSpace(resp)) { - success = resp!.Contains("collected game", StringComparison.InvariantCultureIgnoreCase); - success |= resp!.Contains("OK", StringComparison.InvariantCultureIgnoreCase); - - if (success || VerboseLog || requestSource is ECollectGameRequestSource.RequestedByUser || !context.ShouldHideErrorLogForApp(in gid)) { - bot.ArchiLogger.LogGenericInfo($"[FreeGames] {resp}", nameof(CollectGames)); + if (VerboseLog || requestSource is ECollectGameRequestSource.RequestedByUser) { + bot.ArchiLogger.LogGenericInfo($"[FreeGames] Retry attempt {retryAttempts} for {gid}", nameof(CollectGames)); + } } - } - if (success) { - lock (context) { - context.RegisterApp(in gid); + using (LoggerFilter.DisableLoggingForAddLicenseCommonErrors(_ => !VerboseLog && (requestSource is not ECollectGameRequestSource.RequestedByUser) && context.ShouldHideErrorLogForApp(in gid), bot)) { + resp = await bot.Commands.Response(EAccess.Operator, cmd).ConfigureAwait(false); } - save = true; - res++; - } - else { - if ((requestSource != ECollectGameRequestSource.RequestedByUser) && (resp?.Contains("RateLimited", StringComparison.InvariantCultureIgnoreCase) ?? false)) { - if (VerboseLog) { - bot.ArchiLogger.LogGenericWarning("[FreeGames] Rate limit reached ! Skipping remaining games...", nameof(CollectGames)); + bool success = false; + + if (!string.IsNullOrWhiteSpace(resp)) { + success = resp!.Contains("collected game", StringComparison.InvariantCultureIgnoreCase); + success |= resp!.Contains("OK", StringComparison.InvariantCultureIgnoreCase); + + // Check if this is a transient error that should be retried + isTransientError = !success && + (resp.Contains("timeout", StringComparison.InvariantCultureIgnoreCase) || + resp.Contains("connection error", StringComparison.InvariantCultureIgnoreCase) || + resp.Contains("service unavailable", StringComparison.InvariantCultureIgnoreCase)); + + // Don't retry if we got a clear "Forbidden" or other definitive error + if (resp.Contains("Forbidden", StringComparison.InvariantCultureIgnoreCase) || + resp.Contains("RateLimited", StringComparison.InvariantCultureIgnoreCase) || + resp.Contains("no eligible accounts", StringComparison.InvariantCultureIgnoreCase)) { + isTransientError = false; } - break; + // Log the result regardless of success if it's verbose or user-requested + if (success || (!isTransientError && (VerboseLog || requestSource is ECollectGameRequestSource.RequestedByUser || !context.ShouldHideErrorLogForApp(in gid)))) { + string statusMessage; + if (success) { + statusMessage = "Success"; + } else if (resp.Contains("Forbidden", StringComparison.InvariantCultureIgnoreCase)) { + statusMessage = "AccessDenied/InvalidPackage"; + } else if (resp.Contains("RateLimited", StringComparison.InvariantCultureIgnoreCase)) { + statusMessage = "RateLimited"; + } else if (resp.Contains("timeout", StringComparison.InvariantCultureIgnoreCase)) { + statusMessage = "Timeout"; + } else if (resp.Contains("no eligible accounts", StringComparison.InvariantCultureIgnoreCase)) { + statusMessage = "NoEligibleAccounts"; + } else { + statusMessage = "Failed"; + } + + bot.ArchiLogger.LogGenericInfo($"[FreeGames] <{bot.BotName}> ID: {gid} | Status: {statusMessage}{(isTransientError && retryAttempts < maxRetries ? " (Will retry)" : "")}", nameof(CollectGames)); + } } - if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() - time > DayInSeconds) { - lock (context) { - context.AppTickCount(in gid, increment: true); + // If request was successful or this is not a transient error, break the loop + if (success || !isTransientError) { + + if (success) { + lock (context) { + context.RegisterApp(in gid); + } + + save = true; + res++; + } + else { + // Add the game to the processed list even if it failed with Forbidden to avoid retrying + if (resp?.Contains("Forbidden", StringComparison.InvariantCultureIgnoreCase) ?? false) { + lock (context) { + // Register the app as attempted but failed due to access restrictions + context.RegisterApp(in gid); + } + save = true; + + // Add to the known invalid packages for this collection run + knownInvalidPackages.Add(gid.ToString()); + + // Optionally blacklist this game ID if auto-blacklisting is enabled + if (Options.AutoBlacklistForbiddenPackages ?? true) { + Options.AddToBlacklist(in gid); + + if (VerboseLog || requestSource is ECollectGameRequestSource.RequestedByUser) { + bot.ArchiLogger.LogGenericInfo($"[FreeGames] Adding {gid} to blacklist due to Forbidden response", nameof(CollectGames)); + } + + // Save the updated options to persist the blacklist + _ = Task.Run(async () => { + try { + await SaveOptions(cancellationToken).ConfigureAwait(false); + } catch (Exception ex) { + if (VerboseLog || requestSource is ECollectGameRequestSource.RequestedByUser) { + bot.ArchiLogger.LogGenericWarning($"Failed to save options after blacklisting: {ex.Message}", nameof(CollectGames)); + } + } + }); + } + + if (VerboseLog || requestSource is ECollectGameRequestSource.RequestedByUser) { + bot.ArchiLogger.LogGenericWarning($"[FreeGames] Access denied for {gid}. The package may no longer be available or there are restrictions.", nameof(CollectGames)); + } + } + + if ((requestSource != ECollectGameRequestSource.RequestedByUser) && (resp?.Contains("RateLimited", StringComparison.InvariantCultureIgnoreCase) ?? false)) { + if (VerboseLog) { + bot.ArchiLogger.LogGenericWarning("[FreeGames] Rate limit reached ! Skipping remaining games...", nameof(CollectGames)); + } + + break; + } + } + + // Check if we need to update app tick counts or register invalid apps + if ((!success || isTransientError) && resp != null) { + if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() - time > DayInSeconds) { + lock (context) { + context.AppTickCount(in gid, increment: true); + } + } + + if (InvalidAppPurchaseRegex.Value.IsMatch(resp)) { + save |= context.RegisterInvalidApp(in gid); + } } - } - if (InvalidAppPurchaseRegex.Value.IsMatch(resp ?? "")) { - save |= context.RegisterInvalidApp(in gid); + break; } + + retryAttempts++; + } while (isTransientError && retryAttempts <= maxRetries); + + // Add a delay between requests to avoid hitting rate limits + int delay = Options.DelayBetweenRequests ?? 500; + if (delay > 0) { + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); } } diff --git a/ASFFreeGames/Configurations/ASFFreeGamesOptions.cs b/ASFFreeGames/Configurations/ASFFreeGamesOptions.cs index ac94937..93f4b3d 100644 --- a/ASFFreeGames/Configurations/ASFFreeGamesOptions.cs +++ b/ASFFreeGames/Configurations/ASFFreeGamesOptions.cs @@ -33,6 +33,18 @@ public class ASFFreeGamesOptions { [JsonPropertyName("verboseLog")] public bool? VerboseLog { get; set; } + [JsonPropertyName("autoBlacklistForbiddenPackages")] + public bool? AutoBlacklistForbiddenPackages { get; set; } = true; + + [JsonPropertyName("delayBetweenRequests")] + public int? DelayBetweenRequests { get; set; } = 500; // Default 500ms delay between requests + + [JsonPropertyName("maxRetryAttempts")] + public int? MaxRetryAttempts { get; set; } = 1; // Default 1 retry attempt for transient errors + + [JsonPropertyName("retryDelayMilliseconds")] + public int? RetryDelayMilliseconds { get; set; } = 2000; // Default 2 second delay between retries + #region IsBlacklisted public bool IsBlacklisted(in GameIdentifier gid) { if (Blacklist.Count <= 0) { @@ -43,6 +55,35 @@ public bool IsBlacklisted(in GameIdentifier gid) { } public bool IsBlacklisted(in Bot? bot) => bot is null || ((Blacklist.Count > 0) && Blacklist.Contains($"bot/{bot.BotName}")); + + public void AddToBlacklist(in GameIdentifier gid) { + if (Blacklist is HashSet blacklist) { + blacklist.Add(gid.ToString()); + } else { + Blacklist = new HashSet(Blacklist) { gid.ToString() }; + } + } + + public bool RemoveFromBlacklist(in GameIdentifier gid) { + if (Blacklist is HashSet blacklist) { + return blacklist.Remove(gid.ToString()) || blacklist.Remove(gid.Id.ToString(CultureInfo.InvariantCulture)); + } else { + HashSet newBlacklist = new(Blacklist); + bool removed = newBlacklist.Remove(gid.ToString()) || newBlacklist.Remove(gid.Id.ToString(CultureInfo.InvariantCulture)); + if (removed) { + Blacklist = newBlacklist; + } + return removed; + } + } + + public void ClearBlacklist() { + if (Blacklist is HashSet blacklist) { + blacklist.Clear(); + } else { + Blacklist = new HashSet(); + } + } #endregion #region proxy diff --git a/README.md b/README.md index 0520797..daaffe8 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,32 @@ The plugin behavior is configurable via command - `freegames set f2p` to ☑️**allow** the plugin to collect **f2p** (the default) - `freegames set nodlc` to ⛔**prevent** the plugin from collecting **dlc** - `freegames set dlc` to ☑️**allow** the plugin to collect **dlc** (the default) +- `freegames set clearblacklist` to 🗑️**clear** all entries from the **blacklist** +- `freegames set removeblacklist s/######` to 🔄**remove** a specific package from the **blacklist** +- `freegames set showblacklist` to 📋**display** all entries in the current **blacklist** In addition to the commands above, the configuration is stored in a 📖`config/freegames.json.config` JSON file, which one may 🖊 edit using a text editor to suit their needs. +#### Additional Configuration Options + +The following options can be set in the `freegames.json.config` file: + +```json +{ + "autoBlacklistForbiddenPackages": true, // Automatically blacklist packages that return Forbidden errors + "delayBetweenRequests": 500, // Delay in milliseconds between license requests (helps avoid rate limits) + "maxRetryAttempts": 1, // Number of retry attempts for transient errors (like timeouts) + "retryDelayMilliseconds": 2000 // Delay in milliseconds before retrying a failed request +} +``` + +**Option Descriptions:** + +- `autoBlacklistForbiddenPackages`: When true, packages that return "Forbidden" errors are automatically added to the blacklist to prevent future attempts. +- `delayBetweenRequests`: Adds a delay between license requests to reduce the chance of hitting Steam's rate limits. +- `maxRetryAttempts`: Number of times to retry requests that fail due to transient errors (e.g., timeouts). +- `retryDelayMilliseconds`: How long to wait before retrying a failed request. + ## Proxy Setup The plugin can be configured to use a proxy (HTTP(S), SOCKS4, or SOCKS5) for its HTTP requests to Reddit. You can achieve this in two ways: From 7fcd2c1a50a3f0c4186aab62a8d014bd8ca11965 Mon Sep 17 00:00:00 2001 From: peter9811 Date: Fri, 7 Mar 2025 13:47:03 +1000 Subject: [PATCH 2/7] Update .gitignore: Add .vscode directory to ignore list --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 5aacdb0..78e08e4 100644 --- a/.gitignore +++ b/.gitignore @@ -527,3 +527,5 @@ $RECYCLE.BIN/ # Windows shortcuts *.lnk + +.vscode/ From 932d78fd73a415a05319f129cd80fb59d3404095 Mon Sep 17 00:00:00 2001 From: peter9811 Date: Fri, 7 Mar 2025 14:19:47 +1000 Subject: [PATCH 3/7] Refactor: Improve formatting and structure in various files --- .github/CODE_OF_CONDUCT.md | 23 +- .github/FUNDING.yml | 2 +- .github/ISSUE_TEMPLATE/bug_report.md | 24 +- .github/ISSUE_TEMPLATE/feature_request.md | 7 +- .github/PULL_REQUEST_TEMPLATE.md | 1 - .github/renovate.json5 | 10 +- .github/workflows/bump-asf-reference.yml | 82 +- .github/workflows/ci.yml | 46 +- .github/workflows/publish.yml | 390 ++-- .github/workflows/test_integration.yml | 182 +- ASFFreeGames.Tests/ASFinfo.json | 2038 ++++++++++++++++- ASFFreeGames/Commands/FreeGamesCommand.cs | 44 +- .../Configurations/ASFFreeGamesOptions.cs | 57 +- README.md | 12 +- 14 files changed, 2485 insertions(+), 433 deletions(-) diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md index 079a32b..58296ab 100644 --- a/.github/CODE_OF_CONDUCT.md +++ b/.github/CODE_OF_CONDUCT.md @@ -1,4 +1,3 @@ - # Contributor Covenant Code of Conduct ## Our Pledge @@ -18,23 +17,23 @@ diverse, inclusive, and healthy community. Examples of behavior that contributes to a positive environment for our community include: -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience -* Focusing on what is best not just for us as individuals, but for the +- Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: -* The use of sexualized language or imagery, and sexual attention or +- The use of sexualized language or imagery, and sexual attention or advances of any kind -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or email +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a +- Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities @@ -106,7 +105,7 @@ Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an +standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 5088166..653b0ef 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,3 +1,3 @@ # These are supported funding model platforms -github: maxisoft \ No newline at end of file +github: maxisoft diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index dd84ea7..9b77ea7 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,10 +1,9 @@ --- name: Bug report about: Create a report to help us improve -title: '' -labels: '' -assignees: '' - +title: "" +labels: "" +assignees: "" --- **Describe the bug** @@ -12,6 +11,7 @@ A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: + 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' @@ -24,15 +24,17 @@ A clear and concise description of what you expected to happen. If applicable, add screenshots to help explain your problem. **Desktop (please complete the following information):** - - OS: [e.g. iOS] - - Browser [e.g. chrome, safari] - - Version [e.g. 22] + +- OS: [e.g. iOS] +- Browser [e.g. chrome, safari] +- Version [e.g. 22] **Smartphone (please complete the following information):** - - Device: [e.g. iPhone6] - - OS: [e.g. iOS8.1] - - Browser [e.g. stock browser, safari] - - Version [e.g. 22] + +- Device: [e.g. iPhone6] +- OS: [e.g. iOS8.1] +- Browser [e.g. stock browser, safari] +- Version [e.g. 22] **Additional context** Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index bbcbbe7..2bc5d5f 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,10 +1,9 @@ --- name: Feature request about: Suggest an idea for this project -title: '' -labels: '' -assignees: '' - +title: "" +labels: "" +assignees: "" --- **Is your feature request related to a problem? Please describe.** diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 263eaad..16646f4 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,2 +1 @@ ## Pull request - diff --git a/.github/renovate.json5 b/.github/renovate.json5 index ae41fb7..d7085bd 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -1,6 +1,6 @@ { - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ + $schema: "https://docs.renovatebot.com/renovate-schema.json", + extends: [ "config:base", ":assignee(JustArchi)", ":automergeBranch", @@ -8,9 +8,9 @@ ":automergeMinor", ":disableDependencyDashboard", ":disableRateLimiting", - ":label(🤖 Automatic)" + ":label(🤖 Automatic)", ], "git-submodules": { - "enabled": true - } + enabled: true, + }, } diff --git a/.github/workflows/bump-asf-reference.yml b/.github/workflows/bump-asf-reference.yml index d9f3c9e..f2dca00 100644 --- a/.github/workflows/bump-asf-reference.yml +++ b/.github/workflows/bump-asf-reference.yml @@ -5,7 +5,7 @@ name: Plugin-bump-asf-reference on: schedule: - - cron: '17 1 * * *' + - cron: "17 1 * * *" workflow_dispatch: @@ -19,52 +19,52 @@ jobs: runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v4.2.2 - with: - token: ${{ env.PUSH_GITHUB_TOKEN }} + - name: Checkout code + uses: actions/checkout@v4.2.2 + with: + token: ${{ env.PUSH_GITHUB_TOKEN }} - - name: Fetch latest ArchiSteamFarm release - id: asf-release - uses: pozetroninc/github-action-get-latest-release@v0.8.0 - with: - owner: JustArchiNET - repo: ArchiSteamFarm - excludes: draft,prerelease + - name: Fetch latest ArchiSteamFarm release + id: asf-release + uses: pozetroninc/github-action-get-latest-release@v0.8.0 + with: + owner: JustArchiNET + repo: ArchiSteamFarm + excludes: draft,prerelease - - name: Import GPG key for signing - uses: crazy-max/ghaction-import-gpg@v6.2.0 - if: ${{ env.GPG_PRIVATE_KEY != null }} - with: - gpg_private_key: ${{ env.GPG_PRIVATE_KEY }} - git_user_signingkey: true - git_commit_gpgsign: true + - name: Import GPG key for signing + uses: crazy-max/ghaction-import-gpg@v6.2.0 + if: ${{ env.GPG_PRIVATE_KEY != null }} + with: + gpg_private_key: ${{ env.GPG_PRIVATE_KEY }} + git_user_signingkey: true + git_commit_gpgsign: true - - name: Update ASF reference if needed - env: - LATEST_ASF_RELEASE: ${{ steps.asf-release.outputs.release }} - shell: sh - run: | - set -eu + - name: Update ASF reference if needed + env: + LATEST_ASF_RELEASE: ${{ steps.asf-release.outputs.release }} + shell: sh + run: | + set -eu - git config -f .gitmodules submodule.ArchiSteamFarm.branch "$LATEST_ASF_RELEASE" + git config -f .gitmodules submodule.ArchiSteamFarm.branch "$LATEST_ASF_RELEASE" - git add -A ".gitmodules" + git add -A ".gitmodules" - if ! git diff --cached --quiet; then - if ! git config --get user.email > /dev/null; then - git config --local user.email "${{ github.repository_owner }}@users.noreply.github.com" - fi + if ! git diff --cached --quiet; then + if ! git config --get user.email > /dev/null; then + git config --local user.email "${{ github.repository_owner }}@users.noreply.github.com" + fi - if ! git config --get user.name > /dev/null; then - git config --local user.name "${{ github.repository_owner }}" - fi + if ! git config --get user.name > /dev/null; then + git config --local user.name "${{ github.repository_owner }}" + fi - git commit -m "Automatic ArchiSteamFarm reference update to ${LATEST_ASF_RELEASE}" - fi + git commit -m "Automatic ArchiSteamFarm reference update to ${LATEST_ASF_RELEASE}" + fi - - name: Push changes to the repo - uses: ad-m/github-push-action@v0.8.0 - with: - github_token: ${{ env.PUSH_GITHUB_TOKEN }} - branch: ${{ github.ref }} + - name: Push changes to the repo + uses: ad-m/github-push-action@v0.8.0 + with: + github_token: ${{ env.PUSH_GITHUB_TOKEN }} + branch: ${{ github.ref }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 140da79..d80fe6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,26 +19,26 @@ jobs: runs-on: ${{ matrix.os }} steps: - - name: Checkout code - uses: actions/checkout@v4.2.2 - with: - submodules: recursive - - - name: Setup .NET Core - uses: actions/setup-dotnet@v4 - with: - dotnet-version: ${{ env.DOTNET_SDK_VERSION }} - - - name: Verify .NET Core - run: dotnet --info - - - name: Build ${{ matrix.configuration }} - uses: nick-fields/retry@v3 - with: - timeout_minutes: 10 - max_attempts: 10 - shell: pwsh - command: dotnet build -c "${{ matrix.configuration }}" -p:ContinuousIntegrationBuild=true -p:UseAppHost=false -p:isolate=true --nologo --framework=${{ env.DOTNET_FRAMEWORK }} - - - name: Test ${{ matrix.configuration }} - run: dotnet test --no-build --verbosity normal -c "${{ matrix.configuration }}" -p:ContinuousIntegrationBuild=true -p:UseAppHost=false --nologo --framework=${{ env.DOTNET_FRAMEWORK }} + - name: Checkout code + uses: actions/checkout@v4.2.2 + with: + submodules: recursive + + - name: Setup .NET Core + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_SDK_VERSION }} + + - name: Verify .NET Core + run: dotnet --info + + - name: Build ${{ matrix.configuration }} + uses: nick-fields/retry@v3 + with: + timeout_minutes: 10 + max_attempts: 10 + shell: pwsh + command: dotnet build -c "${{ matrix.configuration }}" -p:ContinuousIntegrationBuild=true -p:UseAppHost=false -p:isolate=true --nologo --framework=${{ env.DOTNET_FRAMEWORK }} + + - name: Test ${{ matrix.configuration }} + run: dotnet test --no-build --verbosity normal -c "${{ matrix.configuration }}" -p:ContinuousIntegrationBuild=true -p:UseAppHost=false --nologo --framework=${{ env.DOTNET_FRAMEWORK }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b8bc6bc..918f6c7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -20,210 +20,210 @@ jobs: macos-latest, ubuntu-latest, #windows-latest - ] + ] runs-on: ${{ matrix.os }} steps: - - name: Checkout code - uses: actions/checkout@v4.2.2 - with: - submodules: recursive - - - name: Setup .NET Core - uses: actions/setup-dotnet@v4 - with: - dotnet-version: ${{ env.DOTNET_SDK_VERSION }} - - - name: Verify .NET Core - run: dotnet --info - - - name: Restore packages in preparation for plugin publishing - run: dotnet restore ${{ env.PLUGIN_NAME }} -p:ContinuousIntegrationBuild=true --nologo - - - name: Publish plugin on Unix - if: startsWith(matrix.os, 'macos-') || startsWith(matrix.os, 'ubuntu-') - env: - VARIANTS: generic - shell: sh - run: | - set -eu - - publish() { - dotnet publish "$PLUGIN_NAME" -c "$CONFIGURATION" -f "$NET_CORE_VERSION" -o "out/${1}/${PLUGIN_NAME}" -p:ContinuousIntegrationBuild=true -p:TargetLatestRuntimePatch=false -p:UseAppHost=false --no-restore --nologo - - # By default use fastest compression - seven_zip_args="-mx=1" - zip_args="-1" - - # Remove useless dlls - rm -rf out/${1}/${PLUGIN_NAME}/System.IO.Hashing.dll out/${1}/${PLUGIN_NAME}/NLog.dll out/${1}/${PLUGIN_NAME}/SteamKit2.dll out/${1}/${PLUGIN_NAME}/System.IO.Hashing.dll out/${1}/${PLUGIN_NAME}/protobuf-net.Core.dll out/${1}/${PLUGIN_NAME}/protobuf-net.dll - - # Include extra logic for builds marked for release - case "$GITHUB_REF" in - "refs/tags/"*) - # Tweak compression args for release publishing - seven_zip_args="-mx=9 -mfb=258 -mpass=15" - zip_args="-9" - ;; - esac - - # Create the final zip file - case "$(uname -s)" in - "Darwin") - # We prefer to use zip on OS X as 7z implementation on that OS doesn't handle file permissions (chmod +x) - if command -v zip >/dev/null; then - ( - cd "${GITHUB_WORKSPACE}/out/${1}" - zip -q -r $zip_args "../${PLUGIN_NAME}-${1}.zip" . - ) - elif command -v 7z >/dev/null; then - 7z a -bd -slp -tzip -mm=Deflate $seven_zip_args "out/${PLUGIN_NAME}-${1}.zip" "${GITHUB_WORKSPACE}/out/${1}/*" - else - echo "ERROR: No supported zip tool!" - return 1 - fi - ;; - *) - if command -v 7z >/dev/null; then - 7z a -bd -slp -tzip -mm=Deflate $seven_zip_args "out/${PLUGIN_NAME}-${1}.zip" "${GITHUB_WORKSPACE}/out/${1}/*" - elif command -v zip >/dev/null; then - ( - cd "${GITHUB_WORKSPACE}/out/${1}" - zip -q -r $zip_args "../${PLUGIN_NAME}-${1}.zip" . - ) - else - echo "ERROR: No supported zip tool!" - return 1 - fi - ;; - esac - } - - jobs="" - - for variant in $VARIANTS; do - publish "$variant" & - jobs="$jobs $!" - done - - for job in $jobs; do - wait "$job" - done - - - name: Publish plugin on Windows - if: startsWith(matrix.os, 'windows-') - env: - VARIANTS: generic - shell: pwsh - run: | - Set-StrictMode -Version Latest - $ErrorActionPreference = 'Stop' - $ProgressPreference = 'SilentlyContinue' - - $PublishBlock = { - param($variant) - - Set-StrictMode -Version Latest - $ErrorActionPreference = 'Stop' - $ProgressPreference = 'SilentlyContinue' - - Set-Location "$env:GITHUB_WORKSPACE" - - if ($variant -like '*-netf') { - $targetFramework = $env:NET_FRAMEWORK_VERSION - } else { - $targetFramework = $env:NET_CORE_VERSION - } - - dotnet publish "$env:PLUGIN_NAME" -c "$env:CONFIGURATION" -f "$targetFramework" -o "out\$variant\$env:PLUGIN_NAME" -p:ContinuousIntegrationBuild=true -p:TargetLatestRuntimePatch=false -p:UseAppHost=false --no-restore --nologo - - if ($LastExitCode -ne 0) { - throw "Last command failed." - } - - # By default use fastest compression - $compressionArgs = '-mx=1' - - # Include extra logic for builds marked for release - if ($env:GITHUB_REF -like 'refs/tags/*') { - # Tweak compression args for release publishing - $compressionArgs = '-mx=9', '-mfb=258', '-mpass=15' - } - - # Remove useless dlls - Get-Item "$env:GITHUB_WORKSPACE\out\$variant\$env:PLUGIN_NAME\System.IO.Hashing.dll" -ErrorAction SilentlyContinue | Remove-Item - Get-Item "$env:GITHUB_WORKSPACE\out\$variant\$env:PLUGIN_NAME\NLog.dll" -ErrorAction SilentlyContinue | Remove-Item - Get-Item "$env:GITHUB_WORKSPACE\out\$variant\$env:PLUGIN_NAME\SteamKit2.dll" -ErrorAction SilentlyContinue | Remove-Item - Get-Item "$env:GITHUB_WORKSPACE\out\$variant\$env:PLUGIN_NAME\System.IO.Hashing.dll" -ErrorAction SilentlyContinue | Remove-Item - Get-Item "$env:GITHUB_WORKSPACE\out\$variant\$env:PLUGIN_NAME\protobuf-net.Core.dll" -ErrorAction SilentlyContinue | Remove-Item - Get-Item "$env:GITHUB_WORKSPACE\out\$variant\$env:PLUGIN_NAME\protobuf-net.dll" -ErrorAction SilentlyContinue | Remove-Item - - # Create the final zip file - 7z a -bd -slp -tzip -mm=Deflate $compressionArgs "out\$env:PLUGIN_NAME-$variant.zip" "$env:GITHUB_WORKSPACE\out\$variant\*" - - if ($LastExitCode -ne 0) { - throw "Last command failed." - } - } - - foreach ($variant in $env:VARIANTS.Split([char[]] $null, [System.StringSplitOptions]::RemoveEmptyEntries)) { - Start-Job -Name "$variant" $PublishBlock -ArgumentList "$variant" - } - - Get-Job | Receive-Job -Wait - - - name: Upload generic - continue-on-error: true - uses: actions/upload-artifact@v4.6.1 - with: - name: ${{ matrix.os }}_${{ env.PLUGIN_NAME }}-generic - path: out/${{ env.PLUGIN_NAME }}-generic.zip + - name: Checkout code + uses: actions/checkout@v4.2.2 + with: + submodules: recursive + + - name: Setup .NET Core + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_SDK_VERSION }} + + - name: Verify .NET Core + run: dotnet --info + + - name: Restore packages in preparation for plugin publishing + run: dotnet restore ${{ env.PLUGIN_NAME }} -p:ContinuousIntegrationBuild=true --nologo + + - name: Publish plugin on Unix + if: startsWith(matrix.os, 'macos-') || startsWith(matrix.os, 'ubuntu-') + env: + VARIANTS: generic + shell: sh + run: | + set -eu + + publish() { + dotnet publish "$PLUGIN_NAME" -c "$CONFIGURATION" -f "$NET_CORE_VERSION" -o "out/${1}/${PLUGIN_NAME}" -p:ContinuousIntegrationBuild=true -p:TargetLatestRuntimePatch=false -p:UseAppHost=false --no-restore --nologo + + # By default use fastest compression + seven_zip_args="-mx=1" + zip_args="-1" + + # Remove useless dlls + rm -rf out/${1}/${PLUGIN_NAME}/System.IO.Hashing.dll out/${1}/${PLUGIN_NAME}/NLog.dll out/${1}/${PLUGIN_NAME}/SteamKit2.dll out/${1}/${PLUGIN_NAME}/System.IO.Hashing.dll out/${1}/${PLUGIN_NAME}/protobuf-net.Core.dll out/${1}/${PLUGIN_NAME}/protobuf-net.dll + + # Include extra logic for builds marked for release + case "$GITHUB_REF" in + "refs/tags/"*) + # Tweak compression args for release publishing + seven_zip_args="-mx=9 -mfb=258 -mpass=15" + zip_args="-9" + ;; + esac + + # Create the final zip file + case "$(uname -s)" in + "Darwin") + # We prefer to use zip on OS X as 7z implementation on that OS doesn't handle file permissions (chmod +x) + if command -v zip >/dev/null; then + ( + cd "${GITHUB_WORKSPACE}/out/${1}" + zip -q -r $zip_args "../${PLUGIN_NAME}-${1}.zip" . + ) + elif command -v 7z >/dev/null; then + 7z a -bd -slp -tzip -mm=Deflate $seven_zip_args "out/${PLUGIN_NAME}-${1}.zip" "${GITHUB_WORKSPACE}/out/${1}/*" + else + echo "ERROR: No supported zip tool!" + return 1 + fi + ;; + *) + if command -v 7z >/dev/null; then + 7z a -bd -slp -tzip -mm=Deflate $seven_zip_args "out/${PLUGIN_NAME}-${1}.zip" "${GITHUB_WORKSPACE}/out/${1}/*" + elif command -v zip >/dev/null; then + ( + cd "${GITHUB_WORKSPACE}/out/${1}" + zip -q -r $zip_args "../${PLUGIN_NAME}-${1}.zip" . + ) + else + echo "ERROR: No supported zip tool!" + return 1 + fi + ;; + esac + } + + jobs="" + + for variant in $VARIANTS; do + publish "$variant" & + jobs="$jobs $!" + done + + for job in $jobs; do + wait "$job" + done + + - name: Publish plugin on Windows + if: startsWith(matrix.os, 'windows-') + env: + VARIANTS: generic + shell: pwsh + run: | + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + $ProgressPreference = 'SilentlyContinue' + + $PublishBlock = { + param($variant) + + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + $ProgressPreference = 'SilentlyContinue' + + Set-Location "$env:GITHUB_WORKSPACE" + + if ($variant -like '*-netf') { + $targetFramework = $env:NET_FRAMEWORK_VERSION + } else { + $targetFramework = $env:NET_CORE_VERSION + } + + dotnet publish "$env:PLUGIN_NAME" -c "$env:CONFIGURATION" -f "$targetFramework" -o "out\$variant\$env:PLUGIN_NAME" -p:ContinuousIntegrationBuild=true -p:TargetLatestRuntimePatch=false -p:UseAppHost=false --no-restore --nologo + + if ($LastExitCode -ne 0) { + throw "Last command failed." + } + + # By default use fastest compression + $compressionArgs = '-mx=1' + + # Include extra logic for builds marked for release + if ($env:GITHUB_REF -like 'refs/tags/*') { + # Tweak compression args for release publishing + $compressionArgs = '-mx=9', '-mfb=258', '-mpass=15' + } + + # Remove useless dlls + Get-Item "$env:GITHUB_WORKSPACE\out\$variant\$env:PLUGIN_NAME\System.IO.Hashing.dll" -ErrorAction SilentlyContinue | Remove-Item + Get-Item "$env:GITHUB_WORKSPACE\out\$variant\$env:PLUGIN_NAME\NLog.dll" -ErrorAction SilentlyContinue | Remove-Item + Get-Item "$env:GITHUB_WORKSPACE\out\$variant\$env:PLUGIN_NAME\SteamKit2.dll" -ErrorAction SilentlyContinue | Remove-Item + Get-Item "$env:GITHUB_WORKSPACE\out\$variant\$env:PLUGIN_NAME\System.IO.Hashing.dll" -ErrorAction SilentlyContinue | Remove-Item + Get-Item "$env:GITHUB_WORKSPACE\out\$variant\$env:PLUGIN_NAME\protobuf-net.Core.dll" -ErrorAction SilentlyContinue | Remove-Item + Get-Item "$env:GITHUB_WORKSPACE\out\$variant\$env:PLUGIN_NAME\protobuf-net.dll" -ErrorAction SilentlyContinue | Remove-Item + + # Create the final zip file + 7z a -bd -slp -tzip -mm=Deflate $compressionArgs "out\$env:PLUGIN_NAME-$variant.zip" "$env:GITHUB_WORKSPACE\out\$variant\*" + + if ($LastExitCode -ne 0) { + throw "Last command failed." + } + } + + foreach ($variant in $env:VARIANTS.Split([char[]] $null, [System.StringSplitOptions]::RemoveEmptyEntries)) { + Start-Job -Name "$variant" $PublishBlock -ArgumentList "$variant" + } + + Get-Job | Receive-Job -Wait + + - name: Upload generic + continue-on-error: true + uses: actions/upload-artifact@v4.6.1 + with: + name: ${{ matrix.os }}_${{ env.PLUGIN_NAME }}-generic + path: out/${{ env.PLUGIN_NAME }}-generic.zip release: if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') }} needs: publish runs-on: ubuntu-latest permissions: - id-token: write - attestations: write - packages: write - contents: write + id-token: write + attestations: write + packages: write + contents: write steps: - - name: Checkout code - uses: actions/checkout@v4.2.2 - - - name: Download generic artifact from ubuntu-latest - uses: actions/download-artifact@v4.1.9 - with: - name: ubuntu-latest_${{ env.PLUGIN_NAME }}-generic - path: out - - - name: Unzip and copy generic artifact - run: | - mkdir -p attest_provenance - unzip out/${{ env.PLUGIN_NAME }}-generic.zip -d attest_provenance - cp --archive out/${{ env.PLUGIN_NAME }}-generic.zip attest_provenance - - - name: Clean up dll files - run: | - pushd attest_provenance/${{ env.PLUGIN_NAME }} - rm -rf NLog.dll SteamKit2.dll System.IO.Hashing.dll protobuf-net.Core.dll protobuf-net.dll - popd - - - uses: actions/attest-build-provenance@v2 - with: - subject-path: 'attest_provenance/*' - - - name: Create GitHub release - id: github_release - uses: softprops/action-gh-release@v2.2.1 - with: - tag_name: ${{ github.ref }} - name: ${{ env.PLUGIN_NAME }} ${{ github.ref }} - body_path: .github/RELEASE_TEMPLATE.md - prerelease: true - files: | - out/${{ env.PLUGIN_NAME }}-generic.zip - attest_provenance/${{ env.PLUGIN_NAME }}/ASFFreeGames.dll + - name: Checkout code + uses: actions/checkout@v4.2.2 + + - name: Download generic artifact from ubuntu-latest + uses: actions/download-artifact@v4.1.9 + with: + name: ubuntu-latest_${{ env.PLUGIN_NAME }}-generic + path: out + + - name: Unzip and copy generic artifact + run: | + mkdir -p attest_provenance + unzip out/${{ env.PLUGIN_NAME }}-generic.zip -d attest_provenance + cp --archive out/${{ env.PLUGIN_NAME }}-generic.zip attest_provenance + + - name: Clean up dll files + run: | + pushd attest_provenance/${{ env.PLUGIN_NAME }} + rm -rf NLog.dll SteamKit2.dll System.IO.Hashing.dll protobuf-net.Core.dll protobuf-net.dll + popd + + - uses: actions/attest-build-provenance@v2 + with: + subject-path: "attest_provenance/*" + + - name: Create GitHub release + id: github_release + uses: softprops/action-gh-release@v2.2.1 + with: + tag_name: ${{ github.ref }} + name: ${{ env.PLUGIN_NAME }} ${{ github.ref }} + body_path: .github/RELEASE_TEMPLATE.md + prerelease: true + files: | + out/${{ env.PLUGIN_NAME }}-generic.zip + attest_provenance/${{ env.PLUGIN_NAME }}/ASFFreeGames.dll diff --git a/.github/workflows/test_integration.yml b/.github/workflows/test_integration.yml index a2aec7e..3a3c0c5 100644 --- a/.github/workflows/test_integration.yml +++ b/.github/workflows/test_integration.yml @@ -6,7 +6,7 @@ on: - main - dev schedule: - - cron: '55 22 */3 * *' + - cron: "55 22 */3 * *" workflow_dispatch: @@ -33,88 +33,88 @@ jobs: runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v4.2.2 - timeout-minutes: 5 - with: - submodules: recursive - - - name: Setup .NET Core - timeout-minutes: 5 - uses: actions/setup-dotnet@v4 - with: - dotnet-version: ${{ env.DOTNET_SDK_VERSION }} - - - name: Verify .NET Core - run: dotnet --info - - - name: Build ${{ matrix.configuration }} - uses: nick-fields/retry@v3 - with: - timeout_minutes: 10 - max_attempts: 10 - shell: pwsh - command: dotnet build -c "${{ matrix.configuration }}" -p:ContinuousIntegrationBuild=true -p:UseAppHost=false -p:isolate=true --nologo - - - name: Populate config.zip - shell: python - run: | - import base64 - - data = rb'''${{ secrets.CONFIGZIP_B64 }}''' - with open('config.zip', 'wb') as f: - f.write(base64.b64decode(data)) - - - name: Extract config.zip - run: unzip -qq config.zip - - - name: Create plugin dir - run: | - mkdir -p plugins/ASFFreeGames - cp --archive -f ASFFreeGames/bin/${{ matrix.configuration }}/*/ASFFreeGames.* plugins/ASFFreeGames/ - du -h plugins - - - name: run docker - shell: python - timeout-minutes: 60 - run: | - import subprocess - import sys - - cmd = r"""docker run -e "ASF_CRYPTKEY=${{ secrets.ASF_CRYPTKEY }}" -v `pwd`/config:/app/config -v `pwd`/plugins:/app/plugins --name asf --pull always justarchi/archisteamfarm:${{ matrix.asf_docker_tag }}""" - - with open('out.txt', 'ab+') as out, subprocess.Popen(cmd, shell=True, stdout=out, stderr=out) as process: - def flush_out() -> str: - out.flush() - out.seek(0) - output = out.read() - output = output.decode() - print(output) - return output - - exit_code = None - try: - exit_code = process.wait(timeout=120) - except (TimeoutError, subprocess.TimeoutExpired): - print("Process reached timeout as expected") - process.kill() - exit_code = process.wait(timeout=10) - if exit_code is None: - process.terminate() - output = flush_out() - assert 'CollectGames() [FreeGames] found' in output, "unable to start docker with ASFFreeGames installed" - sys.exit(0) - - print(f'Process stopped earlier than expected with {exit_code} code', file=sys.stderr) - flush_out() - if exit_code != 0: - sys.exit(exit_code) - sys.exit(111) - - - name: compress artifact files - continue-on-error: true - if: always() - run: | + - name: Checkout code + uses: actions/checkout@v4.2.2 + timeout-minutes: 5 + with: + submodules: recursive + + - name: Setup .NET Core + timeout-minutes: 5 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_SDK_VERSION }} + + - name: Verify .NET Core + run: dotnet --info + + - name: Build ${{ matrix.configuration }} + uses: nick-fields/retry@v3 + with: + timeout_minutes: 10 + max_attempts: 10 + shell: pwsh + command: dotnet build -c "${{ matrix.configuration }}" -p:ContinuousIntegrationBuild=true -p:UseAppHost=false -p:isolate=true --nologo + + - name: Populate config.zip + shell: python + run: | + import base64 + + data = rb'''${{ secrets.CONFIGZIP_B64 }}''' + with open('config.zip', 'wb') as f: + f.write(base64.b64decode(data)) + + - name: Extract config.zip + run: unzip -qq config.zip + + - name: Create plugin dir + run: | + mkdir -p plugins/ASFFreeGames + cp --archive -f ASFFreeGames/bin/${{ matrix.configuration }}/*/ASFFreeGames.* plugins/ASFFreeGames/ + du -h plugins + + - name: run docker + shell: python + timeout-minutes: 60 + run: | + import subprocess + import sys + + cmd = r"""docker run -e "ASF_CRYPTKEY=${{ secrets.ASF_CRYPTKEY }}" -v `pwd`/config:/app/config -v `pwd`/plugins:/app/plugins --name asf --pull always justarchi/archisteamfarm:${{ matrix.asf_docker_tag }}""" + + with open('out.txt', 'ab+') as out, subprocess.Popen(cmd, shell=True, stdout=out, stderr=out) as process: + def flush_out() -> str: + out.flush() + out.seek(0) + output = out.read() + output = output.decode() + print(output) + return output + + exit_code = None + try: + exit_code = process.wait(timeout=120) + except (TimeoutError, subprocess.TimeoutExpired): + print("Process reached timeout as expected") + process.kill() + exit_code = process.wait(timeout=10) + if exit_code is None: + process.terminate() + output = flush_out() + assert 'CollectGames() [FreeGames] found' in output, "unable to start docker with ASFFreeGames installed" + sys.exit(0) + + print(f'Process stopped earlier than expected with {exit_code} code', file=sys.stderr) + flush_out() + if exit_code != 0: + sys.exit(exit_code) + sys.exit(111) + + - name: compress artifact files + continue-on-error: true + if: always() + run: | mkdir -p tmp_7z openssl rand -base64 32 | tr -d '\r\n' > archive_pass.txt echo ::add-mask::$(cat archive_pass.txt) @@ -125,12 +125,10 @@ jobs: fi 7z a -t7z -m0=lzma2 -mx=9 -mhe=on -ms=on -p"${{ secrets.SEVENZIP_PASSWORD || env.SEVENZIP_PASSWORD }}" tmp_7z/output.7z config.zip out.txt - - name: Upload 7z artifact - continue-on-error: true - if: always() - uses: actions/upload-artifact@v4.6.1 - with: - name: ${{ matrix.configuration }}_${{ matrix.asf_docker_tag }}_stdout - path: tmp_7z/output.7z - - + - name: Upload 7z artifact + continue-on-error: true + if: always() + uses: actions/upload-artifact@v4.6.1 + with: + name: ${{ matrix.configuration }}_${{ matrix.asf_docker_tag }}_stdout + path: tmp_7z/output.7z diff --git a/ASFFreeGames.Tests/ASFinfo.json b/ASFFreeGames.Tests/ASFinfo.json index 9406578..2745997 100644 --- a/ASFFreeGames.Tests/ASFinfo.json +++ b/ASFFreeGames.Tests/ASFinfo.json @@ -1 +1,2037 @@ -{"kind": "Listing", "data": {"after": "t1_itw297a", "dist": 25, "modhash": "cgwiq9ohuob1afd390c72d342739e4a21bbcd7b90188edfbb7", "geo_filter": "", "children": [{"kind": "t1", "data": {"subreddit_id": "t5_30mv3", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "link_title": "[Steam] (Game) Phoning Home", "mod_reason_by": null, "banned_by": null, "ups": 0, "num_reports": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "FreeGameFindings", "link_author": "dchunk82", "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "iv9st5z", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": true, "author": "ASFinfo", "num_comments": 2, "can_mod_post": false, "send_replies": true, "parent_id": "t3_yno12c", "score": 0, "author_fullname": "t2_65g6k7nr", "over_18": false, "report_reasons": null, "removal_reason": null, "approved_by": null, "controversiality": 0, "body": " !addlicense asf a/431650,a/579730\nThis is most likely permanently free.\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", "edited": false, "top_awarded_type": null, "downs": 0, "author_flair_css_class": null, "is_submitter": false, "collapsed": false, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "<div class=\"md\"><pre><code>!addlicense asf a/431650,a/579730\n</code></pre>\n\n<p>This is most likely permanently free.</p>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", "gildings": {}, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": true, "link_id": "t3_yno12c", "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/FreeGameFindings/comments/yno12c/steam_game_phoning_home/iv9st5z/", "subreddit_type": "public", "link_permalink": "https://www.reddit.com/r/FreeGameFindings/comments/yno12c/steam_game_phoning_home/", "name": "t1_iv9st5z", "author_flair_template_id": null, "subreddit_name_prefixed": "r/FreeGameFindings", "author_flair_text": null, "treatment_tags": [], "created": 1667735752.0, "created_utc": 1667735752.0, "awarders": [], "all_awardings": [], "locked": false, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "quarantine": false, "mod_note": null, "link_url": "https://store.steampowered.com/app/431650/Phoning_Home/"}}, {"kind": "t1", "data": {"subreddit_id": "t5_2r32q", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "link_title": "[Steam] (game) Phoning Home", "mod_reason_by": null, "banned_by": null, "ups": 2, "num_reports": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "freegames", "link_author": "BlueCritterGames", "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "iv50ctp", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": true, "author": "ASFinfo", "num_comments": 1, "can_mod_post": false, "send_replies": true, "parent_id": "t3_ymqaw1", "score": 2, "author_fullname": "t2_65g6k7nr", "over_18": false, "report_reasons": null, "removal_reason": null, "approved_by": null, "controversiality": 0, "body": " !addlicense asf a/431650,a/579730\nThis is most likely permanently free.\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", "edited": false, "top_awarded_type": null, "downs": 0, "author_flair_css_class": null, "is_submitter": false, "collapsed": false, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "<div class=\"md\"><pre><code>!addlicense asf a/431650,a/579730\n</code></pre>\n\n<p>This is most likely permanently free.</p>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", "gildings": {}, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": true, "link_id": "t3_ymqaw1", "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/freegames/comments/ymqaw1/steam_game_phoning_home/iv50ctp/", "subreddit_type": "public", "link_permalink": "https://www.reddit.com/r/freegames/comments/ymqaw1/steam_game_phoning_home/", "name": "t1_iv50ctp", "author_flair_template_id": null, "subreddit_name_prefixed": "r/freegames", "author_flair_text": null, "treatment_tags": [], "created": 1667644562.0, "created_utc": 1667644562.0, "awarders": [], "all_awardings": [], "locked": false, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "quarantine": false, "mod_note": null, "link_url": "https://store.steampowered.com/app/431650/Phoning_Home/"}}, {"kind": "t1", "data": {"subreddit_id": "t5_30mv3", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "link_title": "[Steam] (DLC) Unicorn Tack Set - Horse Tales: Emerald Valley Ranch", "mod_reason_by": null, "banned_by": null, "ups": 0, "num_reports": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "FreeGameFindings", "link_author": "RegionalPrices", "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "iv0d91h", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": true, "author": "ASFinfo", "num_comments": 4, "can_mod_post": false, "send_replies": true, "parent_id": "t3_ylv8ji", "score": 0, "author_fullname": "t2_65g6k7nr", "over_18": false, "report_reasons": null, "removal_reason": null, "approved_by": null, "controversiality": 0, "body": " !addlicense asf s/791642,s/791643\nThere is a chance this is free DLC for a non-free game.\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", "edited": false, "top_awarded_type": null, "downs": 0, "author_flair_css_class": null, "is_submitter": false, "collapsed": false, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "<div class=\"md\"><pre><code>!addlicense asf s/791642,s/791643\n</code></pre>\n\n<p>There is a chance this is free DLC for a non-free game.</p>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", "gildings": {}, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": true, "link_id": "t3_ylv8ji", "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/FreeGameFindings/comments/ylv8ji/steam_dlc_unicorn_tack_set_horse_tales_emerald/iv0d91h/", "subreddit_type": "public", "link_permalink": "https://www.reddit.com/r/FreeGameFindings/comments/ylv8ji/steam_dlc_unicorn_tack_set_horse_tales_emerald/", "name": "t1_iv0d91h", "author_flair_template_id": null, "subreddit_name_prefixed": "r/FreeGameFindings", "author_flair_text": null, "treatment_tags": [], "created": 1667558247.0, "created_utc": 1667558247.0, "awarders": [], "all_awardings": [], "locked": false, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "quarantine": false, "mod_note": null, "link_url": "https://store.steampowered.com/app/2154320/Unicorn_Tack_Set__Horse_Tales_Emerald_Valley_Ranch/"}}, {"kind": "t1", "data": {"subreddit_id": "t5_301ik", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "link_title": "WRC Generations - Peugeot 206 WRC 2002", "mod_reason_by": null, "banned_by": null, "ups": 2, "num_reports": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "FreeGamesOnSteam", "link_author": "TBone_SK", "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "iuxi1a0", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": true, "author": "ASFinfo", "num_comments": 3, "can_mod_post": false, "send_replies": true, "parent_id": "t3_ylbc9v", "score": 2, "author_fullname": "t2_65g6k7nr", "over_18": false, "report_reasons": null, "removal_reason": null, "approved_by": null, "controversiality": 0, "body": " !addlicense asf s/791527\nThere is a chance this is free DLC for a non-free game.\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", "edited": false, "top_awarded_type": null, "downs": 0, "author_flair_css_class": null, "is_submitter": false, "collapsed": false, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "<div class=\"md\"><pre><code>!addlicense asf s/791527\n</code></pre>\n\n<p>There is a chance this is free DLC for a non-free game.</p>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", "gildings": {}, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": true, "link_id": "t3_ylbc9v", "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/FreeGamesOnSteam/comments/ylbc9v/wrc_generations_peugeot_206_wrc_2002/iuxi1a0/", "subreddit_type": "public", "link_permalink": "https://www.reddit.com/r/FreeGamesOnSteam/comments/ylbc9v/wrc_generations_peugeot_206_wrc_2002/", "name": "t1_iuxi1a0", "author_flair_template_id": null, "subreddit_name_prefixed": "r/FreeGamesOnSteam", "author_flair_text": null, "treatment_tags": [], "created": 1667501917.0, "created_utc": 1667501917.0, "awarders": [], "all_awardings": [], "locked": false, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "quarantine": false, "mod_note": null, "link_url": "https://store.steampowered.com/app/2074060/WRC_Generations__Peugeot_206_WRC_2002/"}}, {"kind": "t1", "data": {"subreddit_id": "t5_30mv3", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "link_title": "[Steam] (DLC) WRC Generations - Peugeot 206 WRC 2002", "mod_reason_by": null, "banned_by": null, "ups": 5, "num_reports": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "FreeGameFindings", "link_author": "RegionalPrices", "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "iuxhdd5", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": false, "author": "ASFinfo", "num_comments": 2, "can_mod_post": false, "send_replies": true, "parent_id": "t3_ylb829", "score": 5, "author_fullname": "t2_65g6k7nr", "over_18": false, "report_reasons": null, "removal_reason": null, "approved_by": null, "controversiality": 0, "body": " !addlicense asf s/791527\nThere is a chance this is free DLC for a non-free game.\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", "edited": false, "top_awarded_type": null, "downs": 0, "author_flair_css_class": null, "is_submitter": false, "collapsed": false, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "<div class=\"md\"><pre><code>!addlicense asf s/791527\n</code></pre>\n\n<p>There is a chance this is free DLC for a non-free game.</p>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", "gildings": {}, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": true, "link_id": "t3_ylb829", "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/FreeGameFindings/comments/ylb829/steam_dlc_wrc_generations_peugeot_206_wrc_2002/iuxhdd5/", "subreddit_type": "public", "link_permalink": "https://www.reddit.com/r/FreeGameFindings/comments/ylb829/steam_dlc_wrc_generations_peugeot_206_wrc_2002/", "name": "t1_iuxhdd5", "author_flair_template_id": null, "subreddit_name_prefixed": "r/FreeGameFindings", "author_flair_text": null, "treatment_tags": [], "created": 1667501667.0, "created_utc": 1667501667.0, "awarders": [], "all_awardings": [], "locked": false, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "quarantine": false, "mod_note": null, "link_url": "https://store.steampowered.com/app/2074060/WRC_Generations__Peugeot_206_WRC_2002/"}}, {"kind": "t1", "data": {"subreddit_id": "t5_30mv3", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "link_title": "[Steam/Epic/PS/Xbox] (DLC) Dying Light 2 - Dying Laugh Bundle", "mod_reason_by": null, "banned_by": null, "ups": 9, "num_reports": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "FreeGameFindings", "link_author": "Saulios", "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "iuxa7a4", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": false, "author": "ASFinfo", "num_comments": 7, "can_mod_post": false, "send_replies": true, "parent_id": "t3_yl9wr0", "score": 9, "author_fullname": "t2_65g6k7nr", "over_18": false, "report_reasons": null, "removal_reason": null, "approved_by": null, "controversiality": 0, "body": " !addlicense asf a/1864100,a/1864200,a/1864230,a/1864260,a/1864250,a/2156750,a/1864240\nThere is a chance this is free DLC for a non-free game.\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", "edited": false, "top_awarded_type": null, "downs": 0, "author_flair_css_class": null, "is_submitter": false, "collapsed": false, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "<div class=\"md\"><pre><code>!addlicense asf a/1864100,a/1864200,a/1864230,a/1864260,a/1864250,a/2156750,a/1864240\n</code></pre>\n\n<p>There is a chance this is free DLC for a non-free game.</p>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", "gildings": {}, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": true, "link_id": "t3_yl9wr0", "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/FreeGameFindings/comments/yl9wr0/steamepicpsxbox_dlc_dying_light_2_dying_laugh/iuxa7a4/", "subreddit_type": "public", "link_permalink": "https://www.reddit.com/r/FreeGameFindings/comments/yl9wr0/steamepicpsxbox_dlc_dying_light_2_dying_laugh/", "name": "t1_iuxa7a4", "author_flair_template_id": null, "subreddit_name_prefixed": "r/FreeGameFindings", "author_flair_text": null, "treatment_tags": [], "created": 1667498926.0, "created_utc": 1667498926.0, "awarders": [], "all_awardings": [], "locked": false, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "quarantine": false, "mod_note": null, "link_url": "https://www.reddit.com/r/FreeGameFindings/comments/yl9wr0/steamepicpsxbox_dlc_dying_light_2_dying_laugh/"}}, {"kind": "t1", "data": {"subreddit_id": "t5_23jv73", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "link_title": "[Steam] Warhammer: Vermintide 2", "mod_reason_by": null, "banned_by": null, "ups": 1, "num_reports": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "FreeGamesForSteam", "link_author": "starkundervatten", "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "iux3gbx", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": true, "author": "ASFinfo", "num_comments": 1, "can_mod_post": false, "send_replies": true, "parent_id": "t3_yl8ok2", "score": 1, "author_fullname": "t2_65g6k7nr", "over_18": false, "report_reasons": null, "removal_reason": null, "approved_by": null, "controversiality": 0, "body": " !addlicense asf s/762440,a/1601550\n\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", "edited": false, "top_awarded_type": null, "downs": 0, "author_flair_css_class": null, "is_submitter": false, "collapsed": false, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "<div class=\"md\"><pre><code>!addlicense asf s/762440,a/1601550\n</code></pre>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", "gildings": {}, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": true, "link_id": "t3_yl8ok2", "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/FreeGamesForSteam/comments/yl8ok2/steam_warhammer_vermintide_2/iux3gbx/", "subreddit_type": "restricted", "link_permalink": "https://www.reddit.com/r/FreeGamesForSteam/comments/yl8ok2/steam_warhammer_vermintide_2/", "name": "t1_iux3gbx", "author_flair_template_id": null, "subreddit_name_prefixed": "r/FreeGamesForSteam", "author_flair_text": null, "treatment_tags": [], "created": 1667496365.0, "created_utc": 1667496365.0, "awarders": [], "all_awardings": [], "locked": false, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "quarantine": false, "mod_note": null, "link_url": "https://www.reddit.com/r/FreeGamesForSteam/comments/yl8ok2/steam_warhammer_vermintide_2/"}}, {"kind": "t1", "data": {"subreddit_id": "t5_2r32q", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "link_title": "[Steam] Warhammer: Vermintide 2", "mod_reason_by": null, "banned_by": null, "ups": 3, "num_reports": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "freegames", "link_author": "LongLurking", "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "iux36kp", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": true, "author": "ASFinfo", "num_comments": 8, "can_mod_post": false, "send_replies": true, "parent_id": "t3_yl8n2m", "score": 3, "author_fullname": "t2_65g6k7nr", "over_18": false, "report_reasons": null, "removal_reason": null, "approved_by": null, "controversiality": 0, "body": " !addlicense asf s/762440,a/1601550\n\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", "edited": false, "top_awarded_type": null, "downs": 0, "author_flair_css_class": null, "is_submitter": false, "collapsed": false, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "<div class=\"md\"><pre><code>!addlicense asf s/762440,a/1601550\n</code></pre>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", "gildings": {}, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": true, "link_id": "t3_yl8n2m", "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/freegames/comments/yl8n2m/steam_warhammer_vermintide_2/iux36kp/", "subreddit_type": "public", "link_permalink": "https://www.reddit.com/r/freegames/comments/yl8n2m/steam_warhammer_vermintide_2/", "name": "t1_iux36kp", "author_flair_template_id": null, "subreddit_name_prefixed": "r/freegames", "author_flair_text": null, "treatment_tags": [], "created": 1667496265.0, "created_utc": 1667496265.0, "awarders": [], "all_awardings": [], "locked": false, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "quarantine": false, "mod_note": null, "link_url": "https://store.steampowered.com/app/552500/Warhammer_Vermintide_2/"}}, {"kind": "t1", "data": {"subreddit_id": "t5_25ikoh", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "link_title": "[Steam] Warhammer: Vermintide 2", "mod_reason_by": null, "banned_by": null, "ups": 1, "num_reports": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "FreeGamesForPC", "link_author": "starkundervatten", "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "iux31qr", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": true, "author": "ASFinfo", "num_comments": 1, "can_mod_post": false, "send_replies": true, "parent_id": "t3_yl8mdl", "score": 1, "author_fullname": "t2_65g6k7nr", "over_18": false, "report_reasons": null, "removal_reason": null, "approved_by": null, "controversiality": 0, "body": " !addlicense asf s/762440,a/1601550\n\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", "edited": false, "top_awarded_type": null, "downs": 0, "author_flair_css_class": null, "is_submitter": false, "collapsed": false, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "<div class=\"md\"><pre><code>!addlicense asf s/762440,a/1601550\n</code></pre>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", "gildings": {}, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": true, "link_id": "t3_yl8mdl", "unrepliable_reason": null, "author_flair_text_color": "dark", "score_hidden": false, "permalink": "/r/FreeGamesForPC/comments/yl8mdl/steam_warhammer_vermintide_2/iux31qr/", "subreddit_type": "restricted", "link_permalink": "https://www.reddit.com/r/FreeGamesForPC/comments/yl8mdl/steam_warhammer_vermintide_2/", "name": "t1_iux31qr", "author_flair_template_id": null, "subreddit_name_prefixed": "r/FreeGamesForPC", "author_flair_text": "BOT and HOT. ", "treatment_tags": [], "created": 1667496215.0, "created_utc": 1667496215.0, "awarders": [], "all_awardings": [], "locked": false, "author_flair_background_color": "", "collapsed_because_crowd_control": null, "mod_reports": [], "quarantine": false, "mod_note": null, "link_url": "https://www.reddit.com/r/FreeGamesForPC/comments/yl8mdl/steam_warhammer_vermintide_2/"}}, {"kind": "t1", "data": {"subreddit_id": "t5_301ik", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "link_title": "Warhammer: Vermintide 2", "mod_reason_by": null, "banned_by": null, "ups": 2, "num_reports": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "FreeGamesOnSteam", "link_author": "[deleted]", "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "iux0y3h", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": true, "author": "ASFinfo", "num_comments": 1, "can_mod_post": false, "send_replies": true, "parent_id": "t3_yl8a77", "score": 2, "author_fullname": "t2_65g6k7nr", "over_18": false, "report_reasons": null, "removal_reason": null, "approved_by": null, "controversiality": 0, "body": " !addlicense asf s/762440,a/1601550\n\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", "edited": false, "top_awarded_type": null, "downs": 0, "author_flair_css_class": null, "is_submitter": false, "collapsed": false, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "<div class=\"md\"><pre><code>!addlicense asf s/762440,a/1601550\n</code></pre>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", "gildings": {}, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": true, "link_id": "t3_yl8a77", "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/FreeGamesOnSteam/comments/yl8a77/warhammer_vermintide_2/iux0y3h/", "subreddit_type": "public", "link_permalink": "https://www.reddit.com/r/FreeGamesOnSteam/comments/yl8a77/warhammer_vermintide_2/", "name": "t1_iux0y3h", "author_flair_template_id": null, "subreddit_name_prefixed": "r/FreeGamesOnSteam", "author_flair_text": null, "treatment_tags": [], "created": 1667495414.0, "created_utc": 1667495414.0, "awarders": [], "all_awardings": [], "locked": false, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "quarantine": false, "mod_note": null, "link_url": "https://store.steampowered.com/app/552500/Warhammer_Vermintide_2/"}}, {"kind": "t1", "data": {"subreddit_id": "t5_301ik", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "link_title": "Warhammer: Vermintide 2", "mod_reason_by": null, "banned_by": null, "ups": 8, "num_reports": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "FreeGamesOnSteam", "link_author": "RegionalPrices", "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "iux0x7x", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": false, "author": "ASFinfo", "num_comments": 32, "can_mod_post": false, "send_replies": true, "parent_id": "t3_yl8a4k", "score": 8, "author_fullname": "t2_65g6k7nr", "over_18": false, "report_reasons": null, "removal_reason": null, "approved_by": null, "controversiality": 0, "body": " !addlicense asf s/762440,a/1601550\n\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", "edited": false, "top_awarded_type": null, "downs": 0, "author_flair_css_class": null, "is_submitter": false, "collapsed": false, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "<div class=\"md\"><pre><code>!addlicense asf s/762440,a/1601550\n</code></pre>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", "gildings": {}, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": true, "link_id": "t3_yl8a4k", "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/FreeGamesOnSteam/comments/yl8a4k/warhammer_vermintide_2/iux0x7x/", "subreddit_type": "public", "link_permalink": "https://www.reddit.com/r/FreeGamesOnSteam/comments/yl8a4k/warhammer_vermintide_2/", "name": "t1_iux0x7x", "author_flair_template_id": null, "subreddit_name_prefixed": "r/FreeGamesOnSteam", "author_flair_text": null, "treatment_tags": [], "created": 1667495405.0, "created_utc": 1667495405.0, "awarders": [], "all_awardings": [], "locked": false, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "quarantine": false, "mod_note": null, "link_url": "https://store.steampowered.com/app/552500/Warhammer_Vermintide_2/"}}, {"kind": "t1", "data": {"subreddit_id": "t5_30mv3", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "link_title": "[Steam] (Game) Warhammer: Vermintide 2", "mod_reason_by": null, "banned_by": null, "ups": 7, "num_reports": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "FreeGameFindings", "link_author": "Lobsterknight43", "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "iux0oqj", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": false, "author": "ASFinfo", "num_comments": 83, "can_mod_post": false, "send_replies": true, "parent_id": "t3_yl88rz", "score": 7, "author_fullname": "t2_65g6k7nr", "over_18": false, "report_reasons": null, "removal_reason": null, "approved_by": null, "controversiality": 0, "body": " !addlicense asf s/762440,a/1601550\n\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", "edited": false, "top_awarded_type": null, "downs": 0, "author_flair_css_class": null, "is_submitter": false, "collapsed": false, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "<div class=\"md\"><pre><code>!addlicense asf s/762440,a/1601550\n</code></pre>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", "gildings": {}, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": true, "link_id": "t3_yl88rz", "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/FreeGameFindings/comments/yl88rz/steam_game_warhammer_vermintide_2/iux0oqj/", "subreddit_type": "public", "link_permalink": "https://www.reddit.com/r/FreeGameFindings/comments/yl88rz/steam_game_warhammer_vermintide_2/", "name": "t1_iux0oqj", "author_flair_template_id": null, "subreddit_name_prefixed": "r/FreeGameFindings", "author_flair_text": null, "treatment_tags": [], "created": 1667495314.0, "created_utc": 1667495314.0, "awarders": [], "all_awardings": [], "locked": false, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "quarantine": false, "mod_note": null, "link_url": "https://store.steampowered.com/app/552500/Warhammer_Vermintide_2/"}}, {"kind": "t1", "data": {"subreddit_id": "t5_2r32q", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "link_title": "[PSA] Destroy All Humans! \u2013 Clone Carnage is now F2P on most platforms (read comments)", "mod_reason_by": null, "banned_by": null, "ups": 2, "num_reports": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "freegames", "link_author": "titomalkavian", "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "iux0gbx", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": true, "author": "ASFinfo", "num_comments": 2, "can_mod_post": false, "send_replies": true, "parent_id": "t3_yl87ia", "score": 2, "author_fullname": "t2_65g6k7nr", "over_18": false, "report_reasons": null, "removal_reason": null, "approved_by": null, "controversiality": 0, "body": " !addlicense asf a/1872550\nThis is most likely permanently free.\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", "edited": false, "top_awarded_type": null, "downs": 0, "author_flair_css_class": null, "is_submitter": false, "collapsed": false, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "<div class=\"md\"><pre><code>!addlicense asf a/1872550\n</code></pre>\n\n<p>This is most likely permanently free.</p>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", "gildings": {}, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": true, "link_id": "t3_yl87ia", "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/freegames/comments/yl87ia/psa_destroy_all_humans_clone_carnage_is_now_f2p/iux0gbx/", "subreddit_type": "public", "link_permalink": "https://www.reddit.com/r/freegames/comments/yl87ia/psa_destroy_all_humans_clone_carnage_is_now_f2p/", "name": "t1_iux0gbx", "author_flair_template_id": null, "subreddit_name_prefixed": "r/freegames", "author_flair_text": null, "treatment_tags": [], "created": 1667495224.0, "created_utc": 1667495224.0, "awarders": [], "all_awardings": [], "locked": false, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "quarantine": false, "mod_note": null, "link_url": "https://store.steampowered.com/app/1872550/Destroy_All_Humans__Clone_Carnage/"}}, {"kind": "t1", "data": {"subreddit_id": "t5_30mv3", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "link_title": "[PSA] Destroy All Humans! \u2013 Clone Carnage is now F2P on most platforms (read comments)", "mod_reason_by": null, "banned_by": null, "ups": 4, "num_reports": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "FreeGameFindings", "link_author": "titomalkavian", "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "iuvivh0", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": false, "author": "ASFinfo", "num_comments": 19, "can_mod_post": false, "send_replies": true, "parent_id": "t3_ykxopr", "score": 4, "author_fullname": "t2_65g6k7nr", "over_18": false, "report_reasons": null, "removal_reason": null, "approved_by": null, "controversiality": 0, "body": " !addlicense asf a/1872550\nThis is most likely permanently free.\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", "edited": false, "top_awarded_type": null, "downs": 0, "author_flair_css_class": null, "is_submitter": false, "collapsed": false, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "<div class=\"md\"><pre><code>!addlicense asf a/1872550\n</code></pre>\n\n<p>This is most likely permanently free.</p>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", "gildings": {}, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": true, "link_id": "t3_ykxopr", "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/FreeGameFindings/comments/ykxopr/psa_destroy_all_humans_clone_carnage_is_now_f2p/iuvivh0/", "subreddit_type": "public", "link_permalink": "https://www.reddit.com/r/FreeGameFindings/comments/ykxopr/psa_destroy_all_humans_clone_carnage_is_now_f2p/", "name": "t1_iuvivh0", "author_flair_template_id": null, "subreddit_name_prefixed": "r/FreeGameFindings", "author_flair_text": null, "treatment_tags": [], "created": 1667469376.0, "created_utc": 1667469376.0, "awarders": [], "all_awardings": [], "locked": false, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "quarantine": false, "mod_note": null, "link_url": "https://store.steampowered.com/app/1872550/Destroy_All_Humans__Clone_Carnage"}}, {"kind": "t1", "data": {"subreddit_id": "t5_301ik", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "link_title": "Need for Speed\u2122 Payback: Pontiac Firebird & Aston Martin DB5 Superbuild Bundle", "mod_reason_by": null, "banned_by": null, "ups": 10, "num_reports": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "FreeGamesOnSteam", "link_author": "TBone_SK", "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "iund354", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": false, "author": "ASFinfo", "num_comments": 6, "can_mod_post": false, "send_replies": true, "parent_id": "t3_yjeotb", "score": 10, "author_fullname": "t2_65g6k7nr", "over_18": false, "report_reasons": null, "removal_reason": null, "approved_by": null, "controversiality": 0, "body": " !addlicense asf s/771751\nThere is a chance this is free DLC for a non-free game.\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", "edited": false, "top_awarded_type": null, "downs": 0, "author_flair_css_class": null, "is_submitter": false, "collapsed": false, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "<div class=\"md\"><pre><code>!addlicense asf s/771751\n</code></pre>\n\n<p>There is a chance this is free DLC for a non-free game.</p>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", "gildings": {}, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": true, "link_id": "t3_yjeotb", "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/FreeGamesOnSteam/comments/yjeotb/need_for_speed_payback_pontiac_firebird_aston/iund354/", "subreddit_type": "public", "link_permalink": "https://www.reddit.com/r/FreeGamesOnSteam/comments/yjeotb/need_for_speed_payback_pontiac_firebird_aston/", "name": "t1_iund354", "author_flair_template_id": null, "subreddit_name_prefixed": "r/FreeGamesOnSteam", "author_flair_text": null, "treatment_tags": [], "created": 1667323053.0, "created_utc": 1667323053.0, "awarders": [], "all_awardings": [], "locked": false, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "quarantine": false, "mod_note": null, "link_url": "https://store.steampowered.com/app/1328031/Need_for_Speed_Payback_Pontiac_Firebird__Aston_Martin_DB5_Superbuild_Bundle/"}}, {"kind": "t1", "data": {"subreddit_id": "t5_30mv3", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "link_title": "[Steam] (DLC) Need for Speed\u2122 Payback: Pontiac Firebird & Aston Martin DB5 Superbuild Bundle", "mod_reason_by": null, "banned_by": null, "ups": 2, "num_reports": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "FreeGameFindings", "link_author": "RegionalPrices", "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "iunc8kl", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": true, "author": "ASFinfo", "num_comments": 10, "can_mod_post": false, "send_replies": true, "parent_id": "t3_yjej36", "score": 2, "author_fullname": "t2_65g6k7nr", "over_18": false, "report_reasons": null, "removal_reason": null, "approved_by": null, "controversiality": 0, "body": " !addlicense asf s/771751\nThere is a chance this is free DLC for a non-free game.\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", "edited": false, "top_awarded_type": null, "downs": 0, "author_flair_css_class": null, "is_submitter": false, "collapsed": false, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "<div class=\"md\"><pre><code>!addlicense asf s/771751\n</code></pre>\n\n<p>There is a chance this is free DLC for a non-free game.</p>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", "gildings": {}, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": true, "link_id": "t3_yjej36", "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/FreeGameFindings/comments/yjej36/steam_dlc_need_for_speed_payback_pontiac_firebird/iunc8kl/", "subreddit_type": "public", "link_permalink": "https://www.reddit.com/r/FreeGameFindings/comments/yjej36/steam_dlc_need_for_speed_payback_pontiac_firebird/", "name": "t1_iunc8kl", "author_flair_template_id": null, "subreddit_name_prefixed": "r/FreeGameFindings", "author_flair_text": null, "treatment_tags": [], "created": 1667322732.0, "created_utc": 1667322732.0, "awarders": [], "all_awardings": [], "locked": false, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "quarantine": false, "mod_note": null, "link_url": "https://store.steampowered.com/app/1328031/Need_for_Speed_Payback_Pontiac_Firebird__Aston_Martin_DB5_Superbuild_Bundle/"}}, {"kind": "t1", "data": {"subreddit_id": "t5_301ik", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "link_title": "The Elder Scrolls II: Daggerfall", "mod_reason_by": null, "banned_by": null, "ups": 1, "num_reports": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "FreeGamesOnSteam", "link_author": "[deleted]", "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "iu7jqkm", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": true, "author": "ASFinfo", "num_comments": 1, "can_mod_post": false, "send_replies": true, "parent_id": "t3_yg97x0", "score": 1, "author_fullname": "t2_65g6k7nr", "over_18": false, "report_reasons": null, "removal_reason": null, "approved_by": null, "controversiality": 0, "body": " !addlicense asf a/1812390\nThis is most likely permanently free.\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", "edited": false, "top_awarded_type": null, "downs": 0, "author_flair_css_class": null, "is_submitter": false, "collapsed": false, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "<div class=\"md\"><pre><code>!addlicense asf a/1812390\n</code></pre>\n\n<p>This is most likely permanently free.</p>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", "gildings": {}, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": true, "link_id": "t3_yg97x0", "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/FreeGamesOnSteam/comments/yg97x0/the_elder_scrolls_ii_daggerfall/iu7jqkm/", "subreddit_type": "public", "link_permalink": "https://www.reddit.com/r/FreeGamesOnSteam/comments/yg97x0/the_elder_scrolls_ii_daggerfall/", "name": "t1_iu7jqkm", "author_flair_template_id": null, "subreddit_name_prefixed": "r/FreeGamesOnSteam", "author_flair_text": null, "treatment_tags": [], "created": 1667015586.0, "created_utc": 1667015586.0, "awarders": [], "all_awardings": [], "locked": false, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "quarantine": false, "mod_note": null, "link_url": "https://store.steampowered.com/app/1812390/The_Elder_Scrolls_II_Daggerfall/?curator_clanid=33028765&curator_listid=35536"}}, {"kind": "t1", "data": {"subreddit_id": "t5_301ik", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "link_title": "Elder Scrolls Arena", "mod_reason_by": null, "banned_by": null, "ups": 1, "num_reports": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "FreeGamesOnSteam", "link_author": "[deleted]", "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "iu7jmyw", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": true, "author": "ASFinfo", "num_comments": 1, "can_mod_post": false, "send_replies": true, "parent_id": "t3_yg975b", "score": 1, "author_fullname": "t2_65g6k7nr", "over_18": false, "report_reasons": null, "removal_reason": null, "approved_by": null, "controversiality": 0, "body": " !addlicense asf a/1812290\nThis is most likely permanently free.\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", "edited": false, "top_awarded_type": null, "downs": 0, "author_flair_css_class": null, "is_submitter": false, "collapsed": false, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "<div class=\"md\"><pre><code>!addlicense asf a/1812290\n</code></pre>\n\n<p>This is most likely permanently free.</p>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", "gildings": {}, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": true, "link_id": "t3_yg975b", "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/FreeGamesOnSteam/comments/yg975b/elder_scrolls_arena/iu7jmyw/", "subreddit_type": "public", "link_permalink": "https://www.reddit.com/r/FreeGamesOnSteam/comments/yg975b/elder_scrolls_arena/", "name": "t1_iu7jmyw", "author_flair_template_id": null, "subreddit_name_prefixed": "r/FreeGamesOnSteam", "author_flair_text": null, "treatment_tags": [], "created": 1667015526.0, "created_utc": 1667015526.0, "awarders": [], "all_awardings": [], "locked": false, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "quarantine": false, "mod_note": null, "link_url": "https://store.steampowered.com/app/1812290/The_Elder_Scrolls_Arena/?curator_clanid=33028765&curator_listid=35536"}}, {"kind": "t1", "data": {"subreddit_id": "t5_30mv3", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "link_title": "[Steam] (Game) Sandwalkers: The Fourteenth Caravan", "mod_reason_by": null, "banned_by": null, "ups": 2, "num_reports": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "FreeGameFindings", "link_author": "[deleted]", "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "iu7irzh", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": true, "author": "ASFinfo", "num_comments": 4, "can_mod_post": false, "send_replies": true, "parent_id": "t3_yg91a3", "score": 2, "author_fullname": "t2_65g6k7nr", "over_18": false, "report_reasons": null, "removal_reason": null, "approved_by": null, "controversiality": 0, "body": " !addlicense asf a/1966290\nThis is most likely permanently free.\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", "edited": false, "top_awarded_type": null, "downs": 0, "author_flair_css_class": null, "is_submitter": false, "collapsed": false, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "<div class=\"md\"><pre><code>!addlicense asf a/1966290\n</code></pre>\n\n<p>This is most likely permanently free.</p>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", "gildings": {}, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": true, "link_id": "t3_yg91a3", "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/FreeGameFindings/comments/yg91a3/steam_game_sandwalkers_the_fourteenth_caravan/iu7irzh/", "subreddit_type": "public", "link_permalink": "https://www.reddit.com/r/FreeGameFindings/comments/yg91a3/steam_game_sandwalkers_the_fourteenth_caravan/", "name": "t1_iu7irzh", "author_flair_template_id": null, "subreddit_name_prefixed": "r/FreeGameFindings", "author_flair_text": null, "treatment_tags": [], "created": 1667015016.0, "created_utc": 1667015016.0, "awarders": [], "all_awardings": [], "locked": false, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "quarantine": false, "mod_note": null, "link_url": "https://store.steampowered.com/app/1966290/Sandwalkers_The_Fourteenth_Caravan/"}}, {"kind": "t1", "data": {"subreddit_id": "t5_301ik", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "link_title": "Arkanoid - Eternal Battle - SPACE SCOUT PACK", "mod_reason_by": null, "banned_by": null, "ups": 4, "num_reports": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "FreeGamesOnSteam", "link_author": "0xy1113311", "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "iu3qjyl", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": false, "author": "ASFinfo", "num_comments": 10, "can_mod_post": false, "send_replies": true, "parent_id": "t3_yfk1ll", "score": 4, "author_fullname": "t2_65g6k7nr", "over_18": false, "report_reasons": null, "removal_reason": null, "approved_by": null, "controversiality": 0, "body": " !addlicense asf s/788288\nThere is a chance this is free DLC for a non-free game.\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", "edited": false, "top_awarded_type": null, "downs": 0, "author_flair_css_class": null, "is_submitter": false, "collapsed": false, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "<div class=\"md\"><pre><code>!addlicense asf s/788288\n</code></pre>\n\n<p>There is a chance this is free DLC for a non-free game.</p>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", "gildings": {}, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": true, "link_id": "t3_yfk1ll", "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/FreeGamesOnSteam/comments/yfk1ll/arkanoid_eternal_battle_space_scout_pack/iu3qjyl/", "subreddit_type": "public", "link_permalink": "https://www.reddit.com/r/FreeGamesOnSteam/comments/yfk1ll/arkanoid_eternal_battle_space_scout_pack/", "name": "t1_iu3qjyl", "author_flair_template_id": null, "subreddit_name_prefixed": "r/FreeGamesOnSteam", "author_flair_text": null, "treatment_tags": [], "created": 1666951446.0, "created_utc": 1666951446.0, "awarders": [], "all_awardings": [], "locked": false, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "quarantine": false, "mod_note": null, "link_url": "https://store.steampowered.com/app/1982851/"}}, {"kind": "t1", "data": {"subreddit_id": "t5_30mv3", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "link_title": "[Steam] (Game) Destroy All Humans! \u2013 Clone Carnage", "mod_reason_by": null, "banned_by": null, "ups": 1, "num_reports": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "FreeGameFindings", "link_author": "th1lt92", "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "iu0g2d4", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": true, "author": "ASFinfo", "num_comments": 4, "can_mod_post": false, "send_replies": true, "parent_id": "t3_yexjm8", "score": 1, "author_fullname": "t2_65g6k7nr", "over_18": false, "report_reasons": null, "removal_reason": null, "approved_by": null, "controversiality": 0, "body": " !addlicense asf a/1872550\nThis is most likely permanently free.\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", "edited": false, "top_awarded_type": null, "downs": 0, "author_flair_css_class": null, "is_submitter": false, "collapsed": false, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "<div class=\"md\"><pre><code>!addlicense asf a/1872550\n</code></pre>\n\n<p>This is most likely permanently free.</p>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", "gildings": {}, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": true, "link_id": "t3_yexjm8", "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/FreeGameFindings/comments/yexjm8/steam_game_destroy_all_humans_clone_carnage/iu0g2d4/", "subreddit_type": "public", "link_permalink": "https://www.reddit.com/r/FreeGameFindings/comments/yexjm8/steam_game_destroy_all_humans_clone_carnage/", "name": "t1_iu0g2d4", "author_flair_template_id": null, "subreddit_name_prefixed": "r/FreeGameFindings", "author_flair_text": null, "treatment_tags": [], "created": 1666890705.0, "created_utc": 1666890705.0, "awarders": [], "all_awardings": [], "locked": false, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "quarantine": false, "mod_note": null, "link_url": "https://store.steampowered.com/app/1872550/Destroy_All_Humans__Clone_Carnage/"}}, {"kind": "t1", "data": {"subreddit_id": "t5_30mv3", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "link_title": "[Steam] (DLC) Arkanoid - Eternal Battle - Space Scout Pack", "mod_reason_by": null, "banned_by": null, "ups": 1, "num_reports": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "FreeGameFindings", "link_author": "mohsreg_", "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "iu069v7", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": true, "author": "ASFinfo", "num_comments": 5, "can_mod_post": false, "send_replies": true, "parent_id": "t3_yevwxh", "score": 1, "author_fullname": "t2_65g6k7nr", "over_18": false, "report_reasons": null, "removal_reason": null, "approved_by": null, "controversiality": 0, "body": " !addlicense asf s/788288\nThere is a chance this is free DLC for a non-free game.\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", "edited": false, "top_awarded_type": null, "downs": 0, "author_flair_css_class": null, "is_submitter": false, "collapsed": false, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "<div class=\"md\"><pre><code>!addlicense asf s/788288\n</code></pre>\n\n<p>There is a chance this is free DLC for a non-free game.</p>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", "gildings": {}, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": true, "link_id": "t3_yevwxh", "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/FreeGameFindings/comments/yevwxh/steam_dlc_arkanoid_eternal_battle_space_scout_pack/iu069v7/", "subreddit_type": "public", "link_permalink": "https://www.reddit.com/r/FreeGameFindings/comments/yevwxh/steam_dlc_arkanoid_eternal_battle_space_scout_pack/", "name": "t1_iu069v7", "author_flair_template_id": null, "subreddit_name_prefixed": "r/FreeGameFindings", "author_flair_text": null, "treatment_tags": [], "created": 1666886915.0, "created_utc": 1666886915.0, "awarders": [], "all_awardings": [], "locked": false, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "quarantine": false, "mod_note": null, "link_url": "https://store.steampowered.com/app/1982851/Arkanoid__Eternal_Battle__Space_Scout_Pack/"}}, {"kind": "t1", "data": {"subreddit_id": "t5_30mv3", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "link_title": "[PSA] [Steam] (Other) Dota2 The International Swag Bag : TI11 Battle pass (24 battlepass level if you already have it)+ 1 Arcana Set + 30 days of Dota plus subscription", "mod_reason_by": null, "banned_by": null, "ups": 2, "num_reports": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "FreeGameFindings", "link_author": "ArmanXZS", "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "itwov1j", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": true, "author": "ASFinfo", "num_comments": 15, "can_mod_post": false, "send_replies": true, "parent_id": "t3_ye8unk", "score": 2, "author_fullname": "t2_65g6k7nr", "over_18": false, "report_reasons": null, "removal_reason": null, "approved_by": null, "controversiality": 0, "body": " !addlicense asf s/197846,a/652720\n\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", "edited": false, "top_awarded_type": null, "downs": 0, "author_flair_css_class": null, "is_submitter": false, "collapsed": false, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "<div class=\"md\"><pre><code>!addlicense asf s/197846,a/652720\n</code></pre>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", "gildings": {}, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": true, "link_id": "t3_ye8unk", "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/FreeGameFindings/comments/ye8unk/psa_steam_other_dota2_the_international_swag_bag/itwov1j/", "subreddit_type": "public", "link_permalink": "https://www.reddit.com/r/FreeGameFindings/comments/ye8unk/psa_steam_other_dota2_the_international_swag_bag/", "name": "t1_itwov1j", "author_flair_template_id": null, "subreddit_name_prefixed": "r/FreeGameFindings", "author_flair_text": null, "treatment_tags": [], "created": 1666818907.0, "created_utc": 1666818907.0, "awarders": [], "all_awardings": [], "locked": false, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "quarantine": false, "mod_note": null, "link_url": "https://www.reddit.com/r/FreeGameFindings/comments/ye8unk/psa_steam_other_dota2_the_international_swag_bag/"}}, {"kind": "t1", "data": {"subreddit_id": "t5_30mv3", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "link_title": "[Steam] (game) Destroy All Humans: Clone Carnage", "mod_reason_by": null, "banned_by": null, "ups": 2, "num_reports": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "FreeGameFindings", "link_author": "[deleted]", "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "itw606g", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": true, "author": "ASFinfo", "num_comments": 5, "can_mod_post": false, "send_replies": true, "parent_id": "t3_ye6bs3", "score": 2, "author_fullname": "t2_65g6k7nr", "over_18": false, "report_reasons": null, "removal_reason": null, "approved_by": null, "controversiality": 0, "body": " !addlicense asf a/1872550\nThis is most likely permanently free.\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", "edited": false, "top_awarded_type": null, "downs": 0, "author_flair_css_class": null, "is_submitter": false, "collapsed": false, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "<div class=\"md\"><pre><code>!addlicense asf a/1872550\n</code></pre>\n\n<p>This is most likely permanently free.</p>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", "gildings": {}, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": true, "link_id": "t3_ye6bs3", "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/FreeGameFindings/comments/ye6bs3/steam_game_destroy_all_humans_clone_carnage/itw606g/", "subreddit_type": "public", "link_permalink": "https://www.reddit.com/r/FreeGameFindings/comments/ye6bs3/steam_game_destroy_all_humans_clone_carnage/", "name": "t1_itw606g", "author_flair_template_id": null, "subreddit_name_prefixed": "r/FreeGameFindings", "author_flair_text": null, "treatment_tags": [], "created": 1666811793.0, "created_utc": 1666811793.0, "awarders": [], "all_awardings": [], "locked": false, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "quarantine": false, "mod_note": null, "link_url": "https://store.steampowered.com/app/1872550/Destroy_All_Humans__Clone_Carnage/"}}, {"kind": "t1", "data": {"subreddit_id": "t5_2r32q", "approved_at_utc": null, "author_is_blocked": false, "comment_type": null, "link_title": "[Steam] [Game] Grimstar: Welcome to the savage planet", "mod_reason_by": null, "banned_by": null, "ups": 1, "num_reports": null, "author_flair_type": "text", "total_awards_received": 0, "subreddit": "freegames", "link_author": "JesseKellor", "likes": null, "replies": "", "user_reports": [], "saved": false, "id": "itw297a", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "collapsed_reason_code": null, "no_follow": true, "author": "ASFinfo", "num_comments": 3, "can_mod_post": false, "send_replies": true, "parent_id": "t3_ye5rfx", "score": 1, "author_fullname": "t2_65g6k7nr", "over_18": false, "report_reasons": null, "removal_reason": null, "approved_by": null, "controversiality": 0, "body": " !addlicense asf a/1631250\nThis is most likely permanently free.\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", "edited": false, "top_awarded_type": null, "downs": 0, "author_flair_css_class": null, "is_submitter": false, "collapsed": false, "author_flair_richtext": [], "author_patreon_flair": false, "body_html": "<div class=\"md\"><pre><code>!addlicense asf a/1631250\n</code></pre>\n\n<p>This is most likely permanently free.</p>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", "gildings": {}, "collapsed_reason": null, "distinguished": null, "associated_award": null, "stickied": false, "author_premium": false, "can_gild": true, "link_id": "t3_ye5rfx", "unrepliable_reason": null, "author_flair_text_color": null, "score_hidden": false, "permalink": "/r/freegames/comments/ye5rfx/steam_game_grimstar_welcome_to_the_savage_planet/itw297a/", "subreddit_type": "public", "link_permalink": "https://www.reddit.com/r/freegames/comments/ye5rfx/steam_game_grimstar_welcome_to_the_savage_planet/", "name": "t1_itw297a", "author_flair_template_id": null, "subreddit_name_prefixed": "r/freegames", "author_flair_text": null, "treatment_tags": [], "created": 1666810353.0, "created_utc": 1666810353.0, "awarders": [], "all_awardings": [], "locked": false, "author_flair_background_color": null, "collapsed_because_crowd_control": null, "mod_reports": [], "quarantine": false, "mod_note": null, "link_url": "https://store.steampowered.com/app/1631250/Grimstar_Welcome_to_the_savage_planet/"}}], "before": null}} \ No newline at end of file +{ + "kind": "Listing", + "data": { + "after": "t1_itw297a", + "dist": 25, + "modhash": "cgwiq9ohuob1afd390c72d342739e4a21bbcd7b90188edfbb7", + "geo_filter": "", + "children": [ + { + "kind": "t1", + "data": { + "subreddit_id": "t5_30mv3", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "link_title": "[Steam] (Game) Phoning Home", + "mod_reason_by": null, + "banned_by": null, + "ups": 0, + "num_reports": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "FreeGameFindings", + "link_author": "dchunk82", + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "iv9st5z", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "ASFinfo", + "num_comments": 2, + "can_mod_post": false, + "send_replies": true, + "parent_id": "t3_yno12c", + "score": 0, + "author_fullname": "t2_65g6k7nr", + "over_18": false, + "report_reasons": null, + "removal_reason": null, + "approved_by": null, + "controversiality": 0, + "body": " !addlicense asf a/431650,a/579730\nThis is most likely permanently free.\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", + "edited": false, + "top_awarded_type": null, + "downs": 0, + "author_flair_css_class": null, + "is_submitter": false, + "collapsed": false, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><pre><code>!addlicense asf a/431650,a/579730\n</code></pre>\n\n<p>This is most likely permanently free.</p>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", + "gildings": {}, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "link_id": "t3_yno12c", + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": false, + "permalink": "/r/FreeGameFindings/comments/yno12c/steam_game_phoning_home/iv9st5z/", + "subreddit_type": "public", + "link_permalink": "https://www.reddit.com/r/FreeGameFindings/comments/yno12c/steam_game_phoning_home/", + "name": "t1_iv9st5z", + "author_flair_template_id": null, + "subreddit_name_prefixed": "r/FreeGameFindings", + "author_flair_text": null, + "treatment_tags": [], + "created": 1667735752.0, + "created_utc": 1667735752.0, + "awarders": [], + "all_awardings": [], + "locked": false, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "quarantine": false, + "mod_note": null, + "link_url": "https://store.steampowered.com/app/431650/Phoning_Home/" + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_2r32q", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "link_title": "[Steam] (game) Phoning Home", + "mod_reason_by": null, + "banned_by": null, + "ups": 2, + "num_reports": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "freegames", + "link_author": "BlueCritterGames", + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "iv50ctp", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "ASFinfo", + "num_comments": 1, + "can_mod_post": false, + "send_replies": true, + "parent_id": "t3_ymqaw1", + "score": 2, + "author_fullname": "t2_65g6k7nr", + "over_18": false, + "report_reasons": null, + "removal_reason": null, + "approved_by": null, + "controversiality": 0, + "body": " !addlicense asf a/431650,a/579730\nThis is most likely permanently free.\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", + "edited": false, + "top_awarded_type": null, + "downs": 0, + "author_flair_css_class": null, + "is_submitter": false, + "collapsed": false, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><pre><code>!addlicense asf a/431650,a/579730\n</code></pre>\n\n<p>This is most likely permanently free.</p>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", + "gildings": {}, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "link_id": "t3_ymqaw1", + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": false, + "permalink": "/r/freegames/comments/ymqaw1/steam_game_phoning_home/iv50ctp/", + "subreddit_type": "public", + "link_permalink": "https://www.reddit.com/r/freegames/comments/ymqaw1/steam_game_phoning_home/", + "name": "t1_iv50ctp", + "author_flair_template_id": null, + "subreddit_name_prefixed": "r/freegames", + "author_flair_text": null, + "treatment_tags": [], + "created": 1667644562.0, + "created_utc": 1667644562.0, + "awarders": [], + "all_awardings": [], + "locked": false, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "quarantine": false, + "mod_note": null, + "link_url": "https://store.steampowered.com/app/431650/Phoning_Home/" + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_30mv3", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "link_title": "[Steam] (DLC) Unicorn Tack Set - Horse Tales: Emerald Valley Ranch", + "mod_reason_by": null, + "banned_by": null, + "ups": 0, + "num_reports": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "FreeGameFindings", + "link_author": "RegionalPrices", + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "iv0d91h", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "ASFinfo", + "num_comments": 4, + "can_mod_post": false, + "send_replies": true, + "parent_id": "t3_ylv8ji", + "score": 0, + "author_fullname": "t2_65g6k7nr", + "over_18": false, + "report_reasons": null, + "removal_reason": null, + "approved_by": null, + "controversiality": 0, + "body": " !addlicense asf s/791642,s/791643\nThere is a chance this is free DLC for a non-free game.\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", + "edited": false, + "top_awarded_type": null, + "downs": 0, + "author_flair_css_class": null, + "is_submitter": false, + "collapsed": false, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><pre><code>!addlicense asf s/791642,s/791643\n</code></pre>\n\n<p>There is a chance this is free DLC for a non-free game.</p>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", + "gildings": {}, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "link_id": "t3_ylv8ji", + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": false, + "permalink": "/r/FreeGameFindings/comments/ylv8ji/steam_dlc_unicorn_tack_set_horse_tales_emerald/iv0d91h/", + "subreddit_type": "public", + "link_permalink": "https://www.reddit.com/r/FreeGameFindings/comments/ylv8ji/steam_dlc_unicorn_tack_set_horse_tales_emerald/", + "name": "t1_iv0d91h", + "author_flair_template_id": null, + "subreddit_name_prefixed": "r/FreeGameFindings", + "author_flair_text": null, + "treatment_tags": [], + "created": 1667558247.0, + "created_utc": 1667558247.0, + "awarders": [], + "all_awardings": [], + "locked": false, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "quarantine": false, + "mod_note": null, + "link_url": "https://store.steampowered.com/app/2154320/Unicorn_Tack_Set__Horse_Tales_Emerald_Valley_Ranch/" + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_301ik", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "link_title": "WRC Generations - Peugeot 206 WRC 2002", + "mod_reason_by": null, + "banned_by": null, + "ups": 2, + "num_reports": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "FreeGamesOnSteam", + "link_author": "TBone_SK", + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "iuxi1a0", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "ASFinfo", + "num_comments": 3, + "can_mod_post": false, + "send_replies": true, + "parent_id": "t3_ylbc9v", + "score": 2, + "author_fullname": "t2_65g6k7nr", + "over_18": false, + "report_reasons": null, + "removal_reason": null, + "approved_by": null, + "controversiality": 0, + "body": " !addlicense asf s/791527\nThere is a chance this is free DLC for a non-free game.\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", + "edited": false, + "top_awarded_type": null, + "downs": 0, + "author_flair_css_class": null, + "is_submitter": false, + "collapsed": false, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><pre><code>!addlicense asf s/791527\n</code></pre>\n\n<p>There is a chance this is free DLC for a non-free game.</p>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", + "gildings": {}, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "link_id": "t3_ylbc9v", + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": false, + "permalink": "/r/FreeGamesOnSteam/comments/ylbc9v/wrc_generations_peugeot_206_wrc_2002/iuxi1a0/", + "subreddit_type": "public", + "link_permalink": "https://www.reddit.com/r/FreeGamesOnSteam/comments/ylbc9v/wrc_generations_peugeot_206_wrc_2002/", + "name": "t1_iuxi1a0", + "author_flair_template_id": null, + "subreddit_name_prefixed": "r/FreeGamesOnSteam", + "author_flair_text": null, + "treatment_tags": [], + "created": 1667501917.0, + "created_utc": 1667501917.0, + "awarders": [], + "all_awardings": [], + "locked": false, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "quarantine": false, + "mod_note": null, + "link_url": "https://store.steampowered.com/app/2074060/WRC_Generations__Peugeot_206_WRC_2002/" + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_30mv3", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "link_title": "[Steam] (DLC) WRC Generations - Peugeot 206 WRC 2002", + "mod_reason_by": null, + "banned_by": null, + "ups": 5, + "num_reports": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "FreeGameFindings", + "link_author": "RegionalPrices", + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "iuxhdd5", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": false, + "author": "ASFinfo", + "num_comments": 2, + "can_mod_post": false, + "send_replies": true, + "parent_id": "t3_ylb829", + "score": 5, + "author_fullname": "t2_65g6k7nr", + "over_18": false, + "report_reasons": null, + "removal_reason": null, + "approved_by": null, + "controversiality": 0, + "body": " !addlicense asf s/791527\nThere is a chance this is free DLC for a non-free game.\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", + "edited": false, + "top_awarded_type": null, + "downs": 0, + "author_flair_css_class": null, + "is_submitter": false, + "collapsed": false, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><pre><code>!addlicense asf s/791527\n</code></pre>\n\n<p>There is a chance this is free DLC for a non-free game.</p>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", + "gildings": {}, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "link_id": "t3_ylb829", + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": false, + "permalink": "/r/FreeGameFindings/comments/ylb829/steam_dlc_wrc_generations_peugeot_206_wrc_2002/iuxhdd5/", + "subreddit_type": "public", + "link_permalink": "https://www.reddit.com/r/FreeGameFindings/comments/ylb829/steam_dlc_wrc_generations_peugeot_206_wrc_2002/", + "name": "t1_iuxhdd5", + "author_flair_template_id": null, + "subreddit_name_prefixed": "r/FreeGameFindings", + "author_flair_text": null, + "treatment_tags": [], + "created": 1667501667.0, + "created_utc": 1667501667.0, + "awarders": [], + "all_awardings": [], + "locked": false, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "quarantine": false, + "mod_note": null, + "link_url": "https://store.steampowered.com/app/2074060/WRC_Generations__Peugeot_206_WRC_2002/" + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_30mv3", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "link_title": "[Steam/Epic/PS/Xbox] (DLC) Dying Light 2 - Dying Laugh Bundle", + "mod_reason_by": null, + "banned_by": null, + "ups": 9, + "num_reports": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "FreeGameFindings", + "link_author": "Saulios", + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "iuxa7a4", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": false, + "author": "ASFinfo", + "num_comments": 7, + "can_mod_post": false, + "send_replies": true, + "parent_id": "t3_yl9wr0", + "score": 9, + "author_fullname": "t2_65g6k7nr", + "over_18": false, + "report_reasons": null, + "removal_reason": null, + "approved_by": null, + "controversiality": 0, + "body": " !addlicense asf a/1864100,a/1864200,a/1864230,a/1864260,a/1864250,a/2156750,a/1864240\nThere is a chance this is free DLC for a non-free game.\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", + "edited": false, + "top_awarded_type": null, + "downs": 0, + "author_flair_css_class": null, + "is_submitter": false, + "collapsed": false, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><pre><code>!addlicense asf a/1864100,a/1864200,a/1864230,a/1864260,a/1864250,a/2156750,a/1864240\n</code></pre>\n\n<p>There is a chance this is free DLC for a non-free game.</p>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", + "gildings": {}, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "link_id": "t3_yl9wr0", + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": false, + "permalink": "/r/FreeGameFindings/comments/yl9wr0/steamepicpsxbox_dlc_dying_light_2_dying_laugh/iuxa7a4/", + "subreddit_type": "public", + "link_permalink": "https://www.reddit.com/r/FreeGameFindings/comments/yl9wr0/steamepicpsxbox_dlc_dying_light_2_dying_laugh/", + "name": "t1_iuxa7a4", + "author_flair_template_id": null, + "subreddit_name_prefixed": "r/FreeGameFindings", + "author_flair_text": null, + "treatment_tags": [], + "created": 1667498926.0, + "created_utc": 1667498926.0, + "awarders": [], + "all_awardings": [], + "locked": false, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "quarantine": false, + "mod_note": null, + "link_url": "https://www.reddit.com/r/FreeGameFindings/comments/yl9wr0/steamepicpsxbox_dlc_dying_light_2_dying_laugh/" + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_23jv73", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "link_title": "[Steam] Warhammer: Vermintide 2", + "mod_reason_by": null, + "banned_by": null, + "ups": 1, + "num_reports": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "FreeGamesForSteam", + "link_author": "starkundervatten", + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "iux3gbx", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "ASFinfo", + "num_comments": 1, + "can_mod_post": false, + "send_replies": true, + "parent_id": "t3_yl8ok2", + "score": 1, + "author_fullname": "t2_65g6k7nr", + "over_18": false, + "report_reasons": null, + "removal_reason": null, + "approved_by": null, + "controversiality": 0, + "body": " !addlicense asf s/762440,a/1601550\n\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", + "edited": false, + "top_awarded_type": null, + "downs": 0, + "author_flair_css_class": null, + "is_submitter": false, + "collapsed": false, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><pre><code>!addlicense asf s/762440,a/1601550\n</code></pre>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", + "gildings": {}, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "link_id": "t3_yl8ok2", + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": false, + "permalink": "/r/FreeGamesForSteam/comments/yl8ok2/steam_warhammer_vermintide_2/iux3gbx/", + "subreddit_type": "restricted", + "link_permalink": "https://www.reddit.com/r/FreeGamesForSteam/comments/yl8ok2/steam_warhammer_vermintide_2/", + "name": "t1_iux3gbx", + "author_flair_template_id": null, + "subreddit_name_prefixed": "r/FreeGamesForSteam", + "author_flair_text": null, + "treatment_tags": [], + "created": 1667496365.0, + "created_utc": 1667496365.0, + "awarders": [], + "all_awardings": [], + "locked": false, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "quarantine": false, + "mod_note": null, + "link_url": "https://www.reddit.com/r/FreeGamesForSteam/comments/yl8ok2/steam_warhammer_vermintide_2/" + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_2r32q", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "link_title": "[Steam] Warhammer: Vermintide 2", + "mod_reason_by": null, + "banned_by": null, + "ups": 3, + "num_reports": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "freegames", + "link_author": "LongLurking", + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "iux36kp", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "ASFinfo", + "num_comments": 8, + "can_mod_post": false, + "send_replies": true, + "parent_id": "t3_yl8n2m", + "score": 3, + "author_fullname": "t2_65g6k7nr", + "over_18": false, + "report_reasons": null, + "removal_reason": null, + "approved_by": null, + "controversiality": 0, + "body": " !addlicense asf s/762440,a/1601550\n\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", + "edited": false, + "top_awarded_type": null, + "downs": 0, + "author_flair_css_class": null, + "is_submitter": false, + "collapsed": false, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><pre><code>!addlicense asf s/762440,a/1601550\n</code></pre>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", + "gildings": {}, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "link_id": "t3_yl8n2m", + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": false, + "permalink": "/r/freegames/comments/yl8n2m/steam_warhammer_vermintide_2/iux36kp/", + "subreddit_type": "public", + "link_permalink": "https://www.reddit.com/r/freegames/comments/yl8n2m/steam_warhammer_vermintide_2/", + "name": "t1_iux36kp", + "author_flair_template_id": null, + "subreddit_name_prefixed": "r/freegames", + "author_flair_text": null, + "treatment_tags": [], + "created": 1667496265.0, + "created_utc": 1667496265.0, + "awarders": [], + "all_awardings": [], + "locked": false, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "quarantine": false, + "mod_note": null, + "link_url": "https://store.steampowered.com/app/552500/Warhammer_Vermintide_2/" + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_25ikoh", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "link_title": "[Steam] Warhammer: Vermintide 2", + "mod_reason_by": null, + "banned_by": null, + "ups": 1, + "num_reports": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "FreeGamesForPC", + "link_author": "starkundervatten", + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "iux31qr", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "ASFinfo", + "num_comments": 1, + "can_mod_post": false, + "send_replies": true, + "parent_id": "t3_yl8mdl", + "score": 1, + "author_fullname": "t2_65g6k7nr", + "over_18": false, + "report_reasons": null, + "removal_reason": null, + "approved_by": null, + "controversiality": 0, + "body": " !addlicense asf s/762440,a/1601550\n\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", + "edited": false, + "top_awarded_type": null, + "downs": 0, + "author_flair_css_class": null, + "is_submitter": false, + "collapsed": false, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><pre><code>!addlicense asf s/762440,a/1601550\n</code></pre>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", + "gildings": {}, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "link_id": "t3_yl8mdl", + "unrepliable_reason": null, + "author_flair_text_color": "dark", + "score_hidden": false, + "permalink": "/r/FreeGamesForPC/comments/yl8mdl/steam_warhammer_vermintide_2/iux31qr/", + "subreddit_type": "restricted", + "link_permalink": "https://www.reddit.com/r/FreeGamesForPC/comments/yl8mdl/steam_warhammer_vermintide_2/", + "name": "t1_iux31qr", + "author_flair_template_id": null, + "subreddit_name_prefixed": "r/FreeGamesForPC", + "author_flair_text": "BOT and HOT. ", + "treatment_tags": [], + "created": 1667496215.0, + "created_utc": 1667496215.0, + "awarders": [], + "all_awardings": [], + "locked": false, + "author_flair_background_color": "", + "collapsed_because_crowd_control": null, + "mod_reports": [], + "quarantine": false, + "mod_note": null, + "link_url": "https://www.reddit.com/r/FreeGamesForPC/comments/yl8mdl/steam_warhammer_vermintide_2/" + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_301ik", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "link_title": "Warhammer: Vermintide 2", + "mod_reason_by": null, + "banned_by": null, + "ups": 2, + "num_reports": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "FreeGamesOnSteam", + "link_author": "[deleted]", + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "iux0y3h", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "ASFinfo", + "num_comments": 1, + "can_mod_post": false, + "send_replies": true, + "parent_id": "t3_yl8a77", + "score": 2, + "author_fullname": "t2_65g6k7nr", + "over_18": false, + "report_reasons": null, + "removal_reason": null, + "approved_by": null, + "controversiality": 0, + "body": " !addlicense asf s/762440,a/1601550\n\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", + "edited": false, + "top_awarded_type": null, + "downs": 0, + "author_flair_css_class": null, + "is_submitter": false, + "collapsed": false, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><pre><code>!addlicense asf s/762440,a/1601550\n</code></pre>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", + "gildings": {}, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "link_id": "t3_yl8a77", + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": false, + "permalink": "/r/FreeGamesOnSteam/comments/yl8a77/warhammer_vermintide_2/iux0y3h/", + "subreddit_type": "public", + "link_permalink": "https://www.reddit.com/r/FreeGamesOnSteam/comments/yl8a77/warhammer_vermintide_2/", + "name": "t1_iux0y3h", + "author_flair_template_id": null, + "subreddit_name_prefixed": "r/FreeGamesOnSteam", + "author_flair_text": null, + "treatment_tags": [], + "created": 1667495414.0, + "created_utc": 1667495414.0, + "awarders": [], + "all_awardings": [], + "locked": false, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "quarantine": false, + "mod_note": null, + "link_url": "https://store.steampowered.com/app/552500/Warhammer_Vermintide_2/" + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_301ik", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "link_title": "Warhammer: Vermintide 2", + "mod_reason_by": null, + "banned_by": null, + "ups": 8, + "num_reports": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "FreeGamesOnSteam", + "link_author": "RegionalPrices", + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "iux0x7x", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": false, + "author": "ASFinfo", + "num_comments": 32, + "can_mod_post": false, + "send_replies": true, + "parent_id": "t3_yl8a4k", + "score": 8, + "author_fullname": "t2_65g6k7nr", + "over_18": false, + "report_reasons": null, + "removal_reason": null, + "approved_by": null, + "controversiality": 0, + "body": " !addlicense asf s/762440,a/1601550\n\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", + "edited": false, + "top_awarded_type": null, + "downs": 0, + "author_flair_css_class": null, + "is_submitter": false, + "collapsed": false, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><pre><code>!addlicense asf s/762440,a/1601550\n</code></pre>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", + "gildings": {}, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "link_id": "t3_yl8a4k", + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": false, + "permalink": "/r/FreeGamesOnSteam/comments/yl8a4k/warhammer_vermintide_2/iux0x7x/", + "subreddit_type": "public", + "link_permalink": "https://www.reddit.com/r/FreeGamesOnSteam/comments/yl8a4k/warhammer_vermintide_2/", + "name": "t1_iux0x7x", + "author_flair_template_id": null, + "subreddit_name_prefixed": "r/FreeGamesOnSteam", + "author_flair_text": null, + "treatment_tags": [], + "created": 1667495405.0, + "created_utc": 1667495405.0, + "awarders": [], + "all_awardings": [], + "locked": false, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "quarantine": false, + "mod_note": null, + "link_url": "https://store.steampowered.com/app/552500/Warhammer_Vermintide_2/" + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_30mv3", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "link_title": "[Steam] (Game) Warhammer: Vermintide 2", + "mod_reason_by": null, + "banned_by": null, + "ups": 7, + "num_reports": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "FreeGameFindings", + "link_author": "Lobsterknight43", + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "iux0oqj", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": false, + "author": "ASFinfo", + "num_comments": 83, + "can_mod_post": false, + "send_replies": true, + "parent_id": "t3_yl88rz", + "score": 7, + "author_fullname": "t2_65g6k7nr", + "over_18": false, + "report_reasons": null, + "removal_reason": null, + "approved_by": null, + "controversiality": 0, + "body": " !addlicense asf s/762440,a/1601550\n\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", + "edited": false, + "top_awarded_type": null, + "downs": 0, + "author_flair_css_class": null, + "is_submitter": false, + "collapsed": false, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><pre><code>!addlicense asf s/762440,a/1601550\n</code></pre>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", + "gildings": {}, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "link_id": "t3_yl88rz", + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": false, + "permalink": "/r/FreeGameFindings/comments/yl88rz/steam_game_warhammer_vermintide_2/iux0oqj/", + "subreddit_type": "public", + "link_permalink": "https://www.reddit.com/r/FreeGameFindings/comments/yl88rz/steam_game_warhammer_vermintide_2/", + "name": "t1_iux0oqj", + "author_flair_template_id": null, + "subreddit_name_prefixed": "r/FreeGameFindings", + "author_flair_text": null, + "treatment_tags": [], + "created": 1667495314.0, + "created_utc": 1667495314.0, + "awarders": [], + "all_awardings": [], + "locked": false, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "quarantine": false, + "mod_note": null, + "link_url": "https://store.steampowered.com/app/552500/Warhammer_Vermintide_2/" + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_2r32q", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "link_title": "[PSA] Destroy All Humans! \u2013 Clone Carnage is now F2P on most platforms (read comments)", + "mod_reason_by": null, + "banned_by": null, + "ups": 2, + "num_reports": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "freegames", + "link_author": "titomalkavian", + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "iux0gbx", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "ASFinfo", + "num_comments": 2, + "can_mod_post": false, + "send_replies": true, + "parent_id": "t3_yl87ia", + "score": 2, + "author_fullname": "t2_65g6k7nr", + "over_18": false, + "report_reasons": null, + "removal_reason": null, + "approved_by": null, + "controversiality": 0, + "body": " !addlicense asf a/1872550\nThis is most likely permanently free.\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", + "edited": false, + "top_awarded_type": null, + "downs": 0, + "author_flair_css_class": null, + "is_submitter": false, + "collapsed": false, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><pre><code>!addlicense asf a/1872550\n</code></pre>\n\n<p>This is most likely permanently free.</p>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", + "gildings": {}, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "link_id": "t3_yl87ia", + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": false, + "permalink": "/r/freegames/comments/yl87ia/psa_destroy_all_humans_clone_carnage_is_now_f2p/iux0gbx/", + "subreddit_type": "public", + "link_permalink": "https://www.reddit.com/r/freegames/comments/yl87ia/psa_destroy_all_humans_clone_carnage_is_now_f2p/", + "name": "t1_iux0gbx", + "author_flair_template_id": null, + "subreddit_name_prefixed": "r/freegames", + "author_flair_text": null, + "treatment_tags": [], + "created": 1667495224.0, + "created_utc": 1667495224.0, + "awarders": [], + "all_awardings": [], + "locked": false, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "quarantine": false, + "mod_note": null, + "link_url": "https://store.steampowered.com/app/1872550/Destroy_All_Humans__Clone_Carnage/" + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_30mv3", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "link_title": "[PSA] Destroy All Humans! \u2013 Clone Carnage is now F2P on most platforms (read comments)", + "mod_reason_by": null, + "banned_by": null, + "ups": 4, + "num_reports": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "FreeGameFindings", + "link_author": "titomalkavian", + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "iuvivh0", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": false, + "author": "ASFinfo", + "num_comments": 19, + "can_mod_post": false, + "send_replies": true, + "parent_id": "t3_ykxopr", + "score": 4, + "author_fullname": "t2_65g6k7nr", + "over_18": false, + "report_reasons": null, + "removal_reason": null, + "approved_by": null, + "controversiality": 0, + "body": " !addlicense asf a/1872550\nThis is most likely permanently free.\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", + "edited": false, + "top_awarded_type": null, + "downs": 0, + "author_flair_css_class": null, + "is_submitter": false, + "collapsed": false, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><pre><code>!addlicense asf a/1872550\n</code></pre>\n\n<p>This is most likely permanently free.</p>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", + "gildings": {}, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "link_id": "t3_ykxopr", + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": false, + "permalink": "/r/FreeGameFindings/comments/ykxopr/psa_destroy_all_humans_clone_carnage_is_now_f2p/iuvivh0/", + "subreddit_type": "public", + "link_permalink": "https://www.reddit.com/r/FreeGameFindings/comments/ykxopr/psa_destroy_all_humans_clone_carnage_is_now_f2p/", + "name": "t1_iuvivh0", + "author_flair_template_id": null, + "subreddit_name_prefixed": "r/FreeGameFindings", + "author_flair_text": null, + "treatment_tags": [], + "created": 1667469376.0, + "created_utc": 1667469376.0, + "awarders": [], + "all_awardings": [], + "locked": false, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "quarantine": false, + "mod_note": null, + "link_url": "https://store.steampowered.com/app/1872550/Destroy_All_Humans__Clone_Carnage" + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_301ik", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "link_title": "Need for Speed\u2122 Payback: Pontiac Firebird & Aston Martin DB5 Superbuild Bundle", + "mod_reason_by": null, + "banned_by": null, + "ups": 10, + "num_reports": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "FreeGamesOnSteam", + "link_author": "TBone_SK", + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "iund354", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": false, + "author": "ASFinfo", + "num_comments": 6, + "can_mod_post": false, + "send_replies": true, + "parent_id": "t3_yjeotb", + "score": 10, + "author_fullname": "t2_65g6k7nr", + "over_18": false, + "report_reasons": null, + "removal_reason": null, + "approved_by": null, + "controversiality": 0, + "body": " !addlicense asf s/771751\nThere is a chance this is free DLC for a non-free game.\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", + "edited": false, + "top_awarded_type": null, + "downs": 0, + "author_flair_css_class": null, + "is_submitter": false, + "collapsed": false, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><pre><code>!addlicense asf s/771751\n</code></pre>\n\n<p>There is a chance this is free DLC for a non-free game.</p>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", + "gildings": {}, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "link_id": "t3_yjeotb", + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": false, + "permalink": "/r/FreeGamesOnSteam/comments/yjeotb/need_for_speed_payback_pontiac_firebird_aston/iund354/", + "subreddit_type": "public", + "link_permalink": "https://www.reddit.com/r/FreeGamesOnSteam/comments/yjeotb/need_for_speed_payback_pontiac_firebird_aston/", + "name": "t1_iund354", + "author_flair_template_id": null, + "subreddit_name_prefixed": "r/FreeGamesOnSteam", + "author_flair_text": null, + "treatment_tags": [], + "created": 1667323053.0, + "created_utc": 1667323053.0, + "awarders": [], + "all_awardings": [], + "locked": false, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "quarantine": false, + "mod_note": null, + "link_url": "https://store.steampowered.com/app/1328031/Need_for_Speed_Payback_Pontiac_Firebird__Aston_Martin_DB5_Superbuild_Bundle/" + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_30mv3", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "link_title": "[Steam] (DLC) Need for Speed\u2122 Payback: Pontiac Firebird & Aston Martin DB5 Superbuild Bundle", + "mod_reason_by": null, + "banned_by": null, + "ups": 2, + "num_reports": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "FreeGameFindings", + "link_author": "RegionalPrices", + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "iunc8kl", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "ASFinfo", + "num_comments": 10, + "can_mod_post": false, + "send_replies": true, + "parent_id": "t3_yjej36", + "score": 2, + "author_fullname": "t2_65g6k7nr", + "over_18": false, + "report_reasons": null, + "removal_reason": null, + "approved_by": null, + "controversiality": 0, + "body": " !addlicense asf s/771751\nThere is a chance this is free DLC for a non-free game.\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", + "edited": false, + "top_awarded_type": null, + "downs": 0, + "author_flair_css_class": null, + "is_submitter": false, + "collapsed": false, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><pre><code>!addlicense asf s/771751\n</code></pre>\n\n<p>There is a chance this is free DLC for a non-free game.</p>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", + "gildings": {}, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "link_id": "t3_yjej36", + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": false, + "permalink": "/r/FreeGameFindings/comments/yjej36/steam_dlc_need_for_speed_payback_pontiac_firebird/iunc8kl/", + "subreddit_type": "public", + "link_permalink": "https://www.reddit.com/r/FreeGameFindings/comments/yjej36/steam_dlc_need_for_speed_payback_pontiac_firebird/", + "name": "t1_iunc8kl", + "author_flair_template_id": null, + "subreddit_name_prefixed": "r/FreeGameFindings", + "author_flair_text": null, + "treatment_tags": [], + "created": 1667322732.0, + "created_utc": 1667322732.0, + "awarders": [], + "all_awardings": [], + "locked": false, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "quarantine": false, + "mod_note": null, + "link_url": "https://store.steampowered.com/app/1328031/Need_for_Speed_Payback_Pontiac_Firebird__Aston_Martin_DB5_Superbuild_Bundle/" + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_301ik", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "link_title": "The Elder Scrolls II: Daggerfall", + "mod_reason_by": null, + "banned_by": null, + "ups": 1, + "num_reports": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "FreeGamesOnSteam", + "link_author": "[deleted]", + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "iu7jqkm", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "ASFinfo", + "num_comments": 1, + "can_mod_post": false, + "send_replies": true, + "parent_id": "t3_yg97x0", + "score": 1, + "author_fullname": "t2_65g6k7nr", + "over_18": false, + "report_reasons": null, + "removal_reason": null, + "approved_by": null, + "controversiality": 0, + "body": " !addlicense asf a/1812390\nThis is most likely permanently free.\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", + "edited": false, + "top_awarded_type": null, + "downs": 0, + "author_flair_css_class": null, + "is_submitter": false, + "collapsed": false, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><pre><code>!addlicense asf a/1812390\n</code></pre>\n\n<p>This is most likely permanently free.</p>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", + "gildings": {}, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "link_id": "t3_yg97x0", + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": false, + "permalink": "/r/FreeGamesOnSteam/comments/yg97x0/the_elder_scrolls_ii_daggerfall/iu7jqkm/", + "subreddit_type": "public", + "link_permalink": "https://www.reddit.com/r/FreeGamesOnSteam/comments/yg97x0/the_elder_scrolls_ii_daggerfall/", + "name": "t1_iu7jqkm", + "author_flair_template_id": null, + "subreddit_name_prefixed": "r/FreeGamesOnSteam", + "author_flair_text": null, + "treatment_tags": [], + "created": 1667015586.0, + "created_utc": 1667015586.0, + "awarders": [], + "all_awardings": [], + "locked": false, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "quarantine": false, + "mod_note": null, + "link_url": "https://store.steampowered.com/app/1812390/The_Elder_Scrolls_II_Daggerfall/?curator_clanid=33028765&curator_listid=35536" + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_301ik", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "link_title": "Elder Scrolls Arena", + "mod_reason_by": null, + "banned_by": null, + "ups": 1, + "num_reports": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "FreeGamesOnSteam", + "link_author": "[deleted]", + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "iu7jmyw", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "ASFinfo", + "num_comments": 1, + "can_mod_post": false, + "send_replies": true, + "parent_id": "t3_yg975b", + "score": 1, + "author_fullname": "t2_65g6k7nr", + "over_18": false, + "report_reasons": null, + "removal_reason": null, + "approved_by": null, + "controversiality": 0, + "body": " !addlicense asf a/1812290\nThis is most likely permanently free.\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", + "edited": false, + "top_awarded_type": null, + "downs": 0, + "author_flair_css_class": null, + "is_submitter": false, + "collapsed": false, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><pre><code>!addlicense asf a/1812290\n</code></pre>\n\n<p>This is most likely permanently free.</p>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", + "gildings": {}, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "link_id": "t3_yg975b", + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": false, + "permalink": "/r/FreeGamesOnSteam/comments/yg975b/elder_scrolls_arena/iu7jmyw/", + "subreddit_type": "public", + "link_permalink": "https://www.reddit.com/r/FreeGamesOnSteam/comments/yg975b/elder_scrolls_arena/", + "name": "t1_iu7jmyw", + "author_flair_template_id": null, + "subreddit_name_prefixed": "r/FreeGamesOnSteam", + "author_flair_text": null, + "treatment_tags": [], + "created": 1667015526.0, + "created_utc": 1667015526.0, + "awarders": [], + "all_awardings": [], + "locked": false, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "quarantine": false, + "mod_note": null, + "link_url": "https://store.steampowered.com/app/1812290/The_Elder_Scrolls_Arena/?curator_clanid=33028765&curator_listid=35536" + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_30mv3", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "link_title": "[Steam] (Game) Sandwalkers: The Fourteenth Caravan", + "mod_reason_by": null, + "banned_by": null, + "ups": 2, + "num_reports": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "FreeGameFindings", + "link_author": "[deleted]", + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "iu7irzh", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "ASFinfo", + "num_comments": 4, + "can_mod_post": false, + "send_replies": true, + "parent_id": "t3_yg91a3", + "score": 2, + "author_fullname": "t2_65g6k7nr", + "over_18": false, + "report_reasons": null, + "removal_reason": null, + "approved_by": null, + "controversiality": 0, + "body": " !addlicense asf a/1966290\nThis is most likely permanently free.\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", + "edited": false, + "top_awarded_type": null, + "downs": 0, + "author_flair_css_class": null, + "is_submitter": false, + "collapsed": false, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><pre><code>!addlicense asf a/1966290\n</code></pre>\n\n<p>This is most likely permanently free.</p>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", + "gildings": {}, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "link_id": "t3_yg91a3", + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": false, + "permalink": "/r/FreeGameFindings/comments/yg91a3/steam_game_sandwalkers_the_fourteenth_caravan/iu7irzh/", + "subreddit_type": "public", + "link_permalink": "https://www.reddit.com/r/FreeGameFindings/comments/yg91a3/steam_game_sandwalkers_the_fourteenth_caravan/", + "name": "t1_iu7irzh", + "author_flair_template_id": null, + "subreddit_name_prefixed": "r/FreeGameFindings", + "author_flair_text": null, + "treatment_tags": [], + "created": 1667015016.0, + "created_utc": 1667015016.0, + "awarders": [], + "all_awardings": [], + "locked": false, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "quarantine": false, + "mod_note": null, + "link_url": "https://store.steampowered.com/app/1966290/Sandwalkers_The_Fourteenth_Caravan/" + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_301ik", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "link_title": "Arkanoid - Eternal Battle - SPACE SCOUT PACK", + "mod_reason_by": null, + "banned_by": null, + "ups": 4, + "num_reports": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "FreeGamesOnSteam", + "link_author": "0xy1113311", + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "iu3qjyl", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": false, + "author": "ASFinfo", + "num_comments": 10, + "can_mod_post": false, + "send_replies": true, + "parent_id": "t3_yfk1ll", + "score": 4, + "author_fullname": "t2_65g6k7nr", + "over_18": false, + "report_reasons": null, + "removal_reason": null, + "approved_by": null, + "controversiality": 0, + "body": " !addlicense asf s/788288\nThere is a chance this is free DLC for a non-free game.\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", + "edited": false, + "top_awarded_type": null, + "downs": 0, + "author_flair_css_class": null, + "is_submitter": false, + "collapsed": false, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><pre><code>!addlicense asf s/788288\n</code></pre>\n\n<p>There is a chance this is free DLC for a non-free game.</p>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", + "gildings": {}, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "link_id": "t3_yfk1ll", + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": false, + "permalink": "/r/FreeGamesOnSteam/comments/yfk1ll/arkanoid_eternal_battle_space_scout_pack/iu3qjyl/", + "subreddit_type": "public", + "link_permalink": "https://www.reddit.com/r/FreeGamesOnSteam/comments/yfk1ll/arkanoid_eternal_battle_space_scout_pack/", + "name": "t1_iu3qjyl", + "author_flair_template_id": null, + "subreddit_name_prefixed": "r/FreeGamesOnSteam", + "author_flair_text": null, + "treatment_tags": [], + "created": 1666951446.0, + "created_utc": 1666951446.0, + "awarders": [], + "all_awardings": [], + "locked": false, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "quarantine": false, + "mod_note": null, + "link_url": "https://store.steampowered.com/app/1982851/" + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_30mv3", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "link_title": "[Steam] (Game) Destroy All Humans! \u2013 Clone Carnage", + "mod_reason_by": null, + "banned_by": null, + "ups": 1, + "num_reports": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "FreeGameFindings", + "link_author": "th1lt92", + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "iu0g2d4", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "ASFinfo", + "num_comments": 4, + "can_mod_post": false, + "send_replies": true, + "parent_id": "t3_yexjm8", + "score": 1, + "author_fullname": "t2_65g6k7nr", + "over_18": false, + "report_reasons": null, + "removal_reason": null, + "approved_by": null, + "controversiality": 0, + "body": " !addlicense asf a/1872550\nThis is most likely permanently free.\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", + "edited": false, + "top_awarded_type": null, + "downs": 0, + "author_flair_css_class": null, + "is_submitter": false, + "collapsed": false, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><pre><code>!addlicense asf a/1872550\n</code></pre>\n\n<p>This is most likely permanently free.</p>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", + "gildings": {}, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "link_id": "t3_yexjm8", + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": false, + "permalink": "/r/FreeGameFindings/comments/yexjm8/steam_game_destroy_all_humans_clone_carnage/iu0g2d4/", + "subreddit_type": "public", + "link_permalink": "https://www.reddit.com/r/FreeGameFindings/comments/yexjm8/steam_game_destroy_all_humans_clone_carnage/", + "name": "t1_iu0g2d4", + "author_flair_template_id": null, + "subreddit_name_prefixed": "r/FreeGameFindings", + "author_flair_text": null, + "treatment_tags": [], + "created": 1666890705.0, + "created_utc": 1666890705.0, + "awarders": [], + "all_awardings": [], + "locked": false, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "quarantine": false, + "mod_note": null, + "link_url": "https://store.steampowered.com/app/1872550/Destroy_All_Humans__Clone_Carnage/" + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_30mv3", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "link_title": "[Steam] (DLC) Arkanoid - Eternal Battle - Space Scout Pack", + "mod_reason_by": null, + "banned_by": null, + "ups": 1, + "num_reports": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "FreeGameFindings", + "link_author": "mohsreg_", + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "iu069v7", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "ASFinfo", + "num_comments": 5, + "can_mod_post": false, + "send_replies": true, + "parent_id": "t3_yevwxh", + "score": 1, + "author_fullname": "t2_65g6k7nr", + "over_18": false, + "report_reasons": null, + "removal_reason": null, + "approved_by": null, + "controversiality": 0, + "body": " !addlicense asf s/788288\nThere is a chance this is free DLC for a non-free game.\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", + "edited": false, + "top_awarded_type": null, + "downs": 0, + "author_flair_css_class": null, + "is_submitter": false, + "collapsed": false, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><pre><code>!addlicense asf s/788288\n</code></pre>\n\n<p>There is a chance this is free DLC for a non-free game.</p>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", + "gildings": {}, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "link_id": "t3_yevwxh", + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": false, + "permalink": "/r/FreeGameFindings/comments/yevwxh/steam_dlc_arkanoid_eternal_battle_space_scout_pack/iu069v7/", + "subreddit_type": "public", + "link_permalink": "https://www.reddit.com/r/FreeGameFindings/comments/yevwxh/steam_dlc_arkanoid_eternal_battle_space_scout_pack/", + "name": "t1_iu069v7", + "author_flair_template_id": null, + "subreddit_name_prefixed": "r/FreeGameFindings", + "author_flair_text": null, + "treatment_tags": [], + "created": 1666886915.0, + "created_utc": 1666886915.0, + "awarders": [], + "all_awardings": [], + "locked": false, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "quarantine": false, + "mod_note": null, + "link_url": "https://store.steampowered.com/app/1982851/Arkanoid__Eternal_Battle__Space_Scout_Pack/" + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_30mv3", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "link_title": "[PSA] [Steam] (Other) Dota2 The International Swag Bag : TI11 Battle pass (24 battlepass level if you already have it)+ 1 Arcana Set + 30 days of Dota plus subscription", + "mod_reason_by": null, + "banned_by": null, + "ups": 2, + "num_reports": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "FreeGameFindings", + "link_author": "ArmanXZS", + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "itwov1j", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "ASFinfo", + "num_comments": 15, + "can_mod_post": false, + "send_replies": true, + "parent_id": "t3_ye8unk", + "score": 2, + "author_fullname": "t2_65g6k7nr", + "over_18": false, + "report_reasons": null, + "removal_reason": null, + "approved_by": null, + "controversiality": 0, + "body": " !addlicense asf s/197846,a/652720\n\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", + "edited": false, + "top_awarded_type": null, + "downs": 0, + "author_flair_css_class": null, + "is_submitter": false, + "collapsed": false, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><pre><code>!addlicense asf s/197846,a/652720\n</code></pre>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", + "gildings": {}, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "link_id": "t3_ye8unk", + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": false, + "permalink": "/r/FreeGameFindings/comments/ye8unk/psa_steam_other_dota2_the_international_swag_bag/itwov1j/", + "subreddit_type": "public", + "link_permalink": "https://www.reddit.com/r/FreeGameFindings/comments/ye8unk/psa_steam_other_dota2_the_international_swag_bag/", + "name": "t1_itwov1j", + "author_flair_template_id": null, + "subreddit_name_prefixed": "r/FreeGameFindings", + "author_flair_text": null, + "treatment_tags": [], + "created": 1666818907.0, + "created_utc": 1666818907.0, + "awarders": [], + "all_awardings": [], + "locked": false, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "quarantine": false, + "mod_note": null, + "link_url": "https://www.reddit.com/r/FreeGameFindings/comments/ye8unk/psa_steam_other_dota2_the_international_swag_bag/" + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_30mv3", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "link_title": "[Steam] (game) Destroy All Humans: Clone Carnage", + "mod_reason_by": null, + "banned_by": null, + "ups": 2, + "num_reports": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "FreeGameFindings", + "link_author": "[deleted]", + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "itw606g", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "ASFinfo", + "num_comments": 5, + "can_mod_post": false, + "send_replies": true, + "parent_id": "t3_ye6bs3", + "score": 2, + "author_fullname": "t2_65g6k7nr", + "over_18": false, + "report_reasons": null, + "removal_reason": null, + "approved_by": null, + "controversiality": 0, + "body": " !addlicense asf a/1872550\nThis is most likely permanently free.\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", + "edited": false, + "top_awarded_type": null, + "downs": 0, + "author_flair_css_class": null, + "is_submitter": false, + "collapsed": false, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><pre><code>!addlicense asf a/1872550\n</code></pre>\n\n<p>This is most likely permanently free.</p>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", + "gildings": {}, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "link_id": "t3_ye6bs3", + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": false, + "permalink": "/r/FreeGameFindings/comments/ye6bs3/steam_game_destroy_all_humans_clone_carnage/itw606g/", + "subreddit_type": "public", + "link_permalink": "https://www.reddit.com/r/FreeGameFindings/comments/ye6bs3/steam_game_destroy_all_humans_clone_carnage/", + "name": "t1_itw606g", + "author_flair_template_id": null, + "subreddit_name_prefixed": "r/FreeGameFindings", + "author_flair_text": null, + "treatment_tags": [], + "created": 1666811793.0, + "created_utc": 1666811793.0, + "awarders": [], + "all_awardings": [], + "locked": false, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "quarantine": false, + "mod_note": null, + "link_url": "https://store.steampowered.com/app/1872550/Destroy_All_Humans__Clone_Carnage/" + } + }, + { + "kind": "t1", + "data": { + "subreddit_id": "t5_2r32q", + "approved_at_utc": null, + "author_is_blocked": false, + "comment_type": null, + "link_title": "[Steam] [Game] Grimstar: Welcome to the savage planet", + "mod_reason_by": null, + "banned_by": null, + "ups": 1, + "num_reports": null, + "author_flair_type": "text", + "total_awards_received": 0, + "subreddit": "freegames", + "link_author": "JesseKellor", + "likes": null, + "replies": "", + "user_reports": [], + "saved": false, + "id": "itw297a", + "banned_at_utc": null, + "mod_reason_title": null, + "gilded": 0, + "archived": false, + "collapsed_reason_code": null, + "no_follow": true, + "author": "ASFinfo", + "num_comments": 3, + "can_mod_post": false, + "send_replies": true, + "parent_id": "t3_ye5rfx", + "score": 1, + "author_fullname": "t2_65g6k7nr", + "over_18": false, + "report_reasons": null, + "removal_reason": null, + "approved_by": null, + "controversiality": 0, + "body": " !addlicense asf a/1631250\nThis is most likely permanently free.\n\n^I'm&nbsp;a&nbsp;bot&nbsp;|&nbsp;[What&nbsp;is&nbsp;ASF](https://github.com/JustArchiNET/ArchiSteamFarm)&nbsp;|&nbsp;[Info](https://www.reddit.com/user/ASFinfo/comments/jmac24/)", + "edited": false, + "top_awarded_type": null, + "downs": 0, + "author_flair_css_class": null, + "is_submitter": false, + "collapsed": false, + "author_flair_richtext": [], + "author_patreon_flair": false, + "body_html": "<div class=\"md\"><pre><code>!addlicense asf a/1631250\n</code></pre>\n\n<p>This is most likely permanently free.</p>\n\n<p><sup>I&#39;m&nbsp;a&nbsp;bot&nbsp;|&nbsp;<a href=\"https://github.com/JustArchiNET/ArchiSteamFarm\">What&nbsp;is&nbsp;ASF</a>&nbsp;|&nbsp;<a href=\"https://www.reddit.com/user/ASFinfo/comments/jmac24/\">Info</a></sup></p>\n</div>", + "gildings": {}, + "collapsed_reason": null, + "distinguished": null, + "associated_award": null, + "stickied": false, + "author_premium": false, + "can_gild": true, + "link_id": "t3_ye5rfx", + "unrepliable_reason": null, + "author_flair_text_color": null, + "score_hidden": false, + "permalink": "/r/freegames/comments/ye5rfx/steam_game_grimstar_welcome_to_the_savage_planet/itw297a/", + "subreddit_type": "public", + "link_permalink": "https://www.reddit.com/r/freegames/comments/ye5rfx/steam_game_grimstar_welcome_to_the_savage_planet/", + "name": "t1_itw297a", + "author_flair_template_id": null, + "subreddit_name_prefixed": "r/freegames", + "author_flair_text": null, + "treatment_tags": [], + "created": 1666810353.0, + "created_utc": 1666810353.0, + "awarders": [], + "all_awardings": [], + "locked": false, + "author_flair_background_color": null, + "collapsed_because_crowd_control": null, + "mod_reports": [], + "quarantine": false, + "mod_note": null, + "link_url": "https://store.steampowered.com/app/1631250/Grimstar_Welcome_to_the_savage_planet/" + } + } + ], + "before": null + } +} diff --git a/ASFFreeGames/Commands/FreeGamesCommand.cs b/ASFFreeGames/Commands/FreeGamesCommand.cs index ecd94d5..8cdfe77 100644 --- a/ASFFreeGames/Commands/FreeGamesCommand.cs +++ b/ASFFreeGames/Commands/FreeGamesCommand.cs @@ -72,7 +72,8 @@ public void Dispose() { case "SHOWBLACKLIST": if (Options.Blacklist.Count == 0) { return FormatBotResponse(bot, "Blacklist is empty"); - } else { + } + else { string blacklistItems = string.Join(", ", Options.Blacklist); return FormatBotResponse(bot, $"Current blacklist: {blacklistItems}"); } @@ -134,25 +135,32 @@ public void Dispose() { case "REMOVEBLACKLIST": if (args.Length >= 4) { string identifier = args[3]; + if (string.IsNullOrEmpty(identifier)) { + return FormatBotResponse(bot, "Please provide a valid game identifier to remove from blacklist"); + } if (GameIdentifier.TryParse(identifier, out GameIdentifier gid)) { bool removed = Options.RemoveFromBlacklist(in gid); await SaveOptions(cancellationToken).ConfigureAwait(false); if (removed) { return FormatBotResponse(bot, $"{ASFFreeGamesPlugin.StaticName} removed {gid} from blacklist"); - } else { + } + else { return FormatBotResponse(bot, $"{ASFFreeGamesPlugin.StaticName} could not find {gid} in blacklist"); } - } else { + } + else { return FormatBotResponse(bot, $"Invalid game identifier format: {identifier}"); } - } else { + } + else { return FormatBotResponse(bot, "Please provide a game identifier to remove from blacklist"); } case "SHOWBLACKLIST": if (Options.Blacklist.Count == 0) { return FormatBotResponse(bot, "Blacklist is empty"); - } else { + } + else { string blacklistItems = string.Join(", ", Options.Blacklist); return FormatBotResponse(bot, $"Current blacklist: {blacklistItems}"); } @@ -367,8 +375,8 @@ private async Task CollectGames(IEnumerable bots, ECollectGameRequestS bot.ArchiLogger.LogGenericDebug($"Trying to perform command \"{cmd}\"", nameof(CollectGames)); } - int retryAttempts = 0; - int maxRetries = Options.MaxRetryAttempts ?? 1; + int maxRetries = Options?.MaxRetryAttempts ?? 1; + bool isTransientError = false; do { @@ -410,15 +418,20 @@ private async Task CollectGames(IEnumerable bots, ECollectGameRequestS string statusMessage; if (success) { statusMessage = "Success"; - } else if (resp.Contains("Forbidden", StringComparison.InvariantCultureIgnoreCase)) { + } + else if (resp.Contains("Forbidden", StringComparison.InvariantCultureIgnoreCase)) { statusMessage = "AccessDenied/InvalidPackage"; - } else if (resp.Contains("RateLimited", StringComparison.InvariantCultureIgnoreCase)) { + } + else if (resp.Contains("RateLimited", StringComparison.InvariantCultureIgnoreCase)) { statusMessage = "RateLimited"; - } else if (resp.Contains("timeout", StringComparison.InvariantCultureIgnoreCase)) { + } + else if (resp.Contains("timeout", StringComparison.InvariantCultureIgnoreCase)) { statusMessage = "Timeout"; - } else if (resp.Contains("no eligible accounts", StringComparison.InvariantCultureIgnoreCase)) { + } + else if (resp.Contains("no eligible accounts", StringComparison.InvariantCultureIgnoreCase)) { statusMessage = "NoEligibleAccounts"; - } else { + } + else { statusMessage = "Failed"; } @@ -461,12 +474,13 @@ private async Task CollectGames(IEnumerable bots, ECollectGameRequestS _ = Task.Run(async () => { try { await SaveOptions(cancellationToken).ConfigureAwait(false); - } catch (Exception ex) { + } + catch (Exception ex) { if (VerboseLog || requestSource is ECollectGameRequestSource.RequestedByUser) { - bot.ArchiLogger.LogGenericWarning($"Failed to save options after blacklisting: {ex.Message}", nameof(CollectGames)); + ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericError($"Failed to save options after blacklisting: {ex.Message}"); } } - }); + }).ConfigureAwait(false); } if (VerboseLog || requestSource is ECollectGameRequestSource.RequestedByUser) { diff --git a/ASFFreeGames/Configurations/ASFFreeGamesOptions.cs b/ASFFreeGames/Configurations/ASFFreeGamesOptions.cs index 93f4b3d..a79fe1b 100644 --- a/ASFFreeGames/Configurations/ASFFreeGamesOptions.cs +++ b/ASFFreeGames/Configurations/ASFFreeGamesOptions.cs @@ -10,7 +10,8 @@ namespace ASFFreeGames.Configurations; -public class ASFFreeGamesOptions { +public class ASFFreeGamesOptions +{ // Use TimeSpan instead of long for representing time intervals [JsonPropertyName("recheckInterval")] public TimeSpan RecheckInterval { get; set; } = TimeSpan.FromMinutes(30); @@ -44,45 +45,48 @@ public class ASFFreeGamesOptions { [JsonPropertyName("retryDelayMilliseconds")] public int? RetryDelayMilliseconds { get; set; } = 2000; // Default 2 second delay between retries - #region IsBlacklisted - public bool IsBlacklisted(in GameIdentifier gid) { - if (Blacklist.Count <= 0) { + public bool IsBlacklisted(in GameIdentifier gid) + { + if (Blacklist.Count <= 0) + { return false; } - return Blacklist.Contains(gid.ToString()) || Blacklist.Contains(gid.Id.ToString(CultureInfo.InvariantCulture)); + 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}")); + public bool IsBlacklisted(in Bot? bot) => + bot is null || ((Blacklist.Count > 0) && Blacklist.Contains($"bot/{bot.BotName}")); - public void AddToBlacklist(in GameIdentifier gid) { - if (Blacklist is HashSet blacklist) { - blacklist.Add(gid.ToString()); - } else { - Blacklist = new HashSet(Blacklist) { gid.ToString() }; + public void AddToBlacklist(in GameIdentifier gid) + { + if (Blacklist is not HashSet blacklist) + { + Blacklist = new HashSet(Blacklist); + blacklist = (HashSet)Blacklist; } + ((HashSet)Blacklist).Add(gid.ToString()); } - public bool RemoveFromBlacklist(in GameIdentifier gid) { - if (Blacklist is HashSet blacklist) { - return blacklist.Remove(gid.ToString()) || blacklist.Remove(gid.Id.ToString(CultureInfo.InvariantCulture)); - } else { - HashSet newBlacklist = new(Blacklist); - bool removed = newBlacklist.Remove(gid.ToString()) || newBlacklist.Remove(gid.Id.ToString(CultureInfo.InvariantCulture)); - if (removed) { - Blacklist = newBlacklist; - } - return removed; + public bool RemoveFromBlacklist(in GameIdentifier gid) + { + if (Blacklist is not HashSet blacklist) + { + Blacklist = new HashSet(Blacklist); + blacklist = (HashSet)Blacklist; } + return ((HashSet)Blacklist).Remove(gid.ToString()); } - public void ClearBlacklist() { - if (Blacklist is HashSet blacklist) { - blacklist.Clear(); - } else { + public void ClearBlacklist() + { + if (Blacklist is not HashSet blacklist) + { Blacklist = new HashSet(); } + ((HashSet)Blacklist).Clear(); } #endregion @@ -99,6 +103,7 @@ public void ClearBlacklist() { [JsonPropertyName("redlibInstanceUrl")] #pragma warning disable CA1056 - public string? RedlibInstanceUrl { get; set; } = "https://raw.githubusercontent.com/redlib-org/redlib-instances/main/instances.json"; + public string? RedlibInstanceUrl { get; set; } = + "https://raw.githubusercontent.com/redlib-org/redlib-instances/main/instances.json"; #pragma warning restore CA1056 } diff --git a/README.md b/README.md index daaffe8..6168d85 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ ASF-FreeGames is a **[plugin](https://github.com/JustArchiNET/ArchiSteamFarm/wik ## Installation - 🔽 Download latest [Dll](https://github.com/maxisoft/ASFFreeGames/releases) from the release page -- ➡️ Move the **dll** into the `plugins` folder of your *ArchiSteamFarm* installation +- ➡️ Move the **dll** into the `plugins` folder of your _ArchiSteamFarm_ installation - 🔄 (re)start ArchiSteamFarm - 🎉 Have fun @@ -52,10 +52,10 @@ The following options can be set in the `freegames.json.config` file: ```json { - "autoBlacklistForbiddenPackages": true, // Automatically blacklist packages that return Forbidden errors - "delayBetweenRequests": 500, // Delay in milliseconds between license requests (helps avoid rate limits) - "maxRetryAttempts": 1, // Number of retry attempts for transient errors (like timeouts) - "retryDelayMilliseconds": 2000 // Delay in milliseconds before retrying a failed request + "autoBlacklistForbiddenPackages": true, // Automatically blacklist packages that return Forbidden errors + "delayBetweenRequests": 500, // Delay in milliseconds between license requests (helps avoid rate limits) + "maxRetryAttempts": 1, // Number of retry attempts for transient errors (like timeouts) + "retryDelayMilliseconds": 2000 // Delay in milliseconds before retrying a failed request } ``` @@ -99,7 +99,7 @@ The plugin supports checking for updates on GitHub. You can enable automatic upd **Important note:** Enabling automatic updates for plugins can have security implications. It's recommended to thoroughly test updates in a non-production environment before enabling them on your main system. ------- +--- ## Dev notes From f9052a89c774671f239f6c7eccb3433527caa3ae Mon Sep 17 00:00:00 2001 From: peter9811 Date: Fri, 7 Mar 2025 14:21:35 +1000 Subject: [PATCH 4/7] Update subproject commit reference in ArchiSteamFarm --- ArchiSteamFarm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ArchiSteamFarm b/ArchiSteamFarm index e5c9def..a05a7a2 160000 --- a/ArchiSteamFarm +++ b/ArchiSteamFarm @@ -1 +1 @@ -Subproject commit e5c9defac847c173694b1f523ba5ef996447501a +Subproject commit a05a7a23224556360465090273ca2327475019a0 From 5048b7399ec32a6fb28f8fcf82ca4c6b8d1fb476 Mon Sep 17 00:00:00 2001 From: peter9811 Date: Fri, 7 Mar 2025 14:27:39 +1000 Subject: [PATCH 5/7] Update subproject commit reference in ArchiSteamFarm --- ArchiSteamFarm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ArchiSteamFarm b/ArchiSteamFarm index a05a7a2..b8e65ac 160000 --- a/ArchiSteamFarm +++ b/ArchiSteamFarm @@ -1 +1 @@ -Subproject commit a05a7a23224556360465090273ca2327475019a0 +Subproject commit b8e65ac3e357bb982213c5f831dad4c37fe66c8e From 1ca3e5fd9ab9f11d9501de43cbdb27b24a5ee033 Mon Sep 17 00:00:00 2001 From: peter9811 Date: Fri, 7 Mar 2025 14:57:41 +1000 Subject: [PATCH 6/7] Refactor: Improve code formatting and structure across multiple files --- ASFFreeGames.Tests/redlib_asfinfo.html | 1326 ++++++++++------- ASFFreeGames/ASFExtentions/Bot/BotName.cs | 9 +- .../ASFExtentions/Games/GameIdentifier.cs | 16 +- .../Games/GameIdentifierParser.cs | 12 +- ASFFreeGames/ASFFreeGamesPlugin.cs | 94 +- ASFFreeGames/AppLists/CompletedAppList.cs | 77 +- ASFFreeGames/AppLists/RecentGameMapping.cs | 27 +- ASFFreeGames/CollectIntervalManager.cs | 32 +- ASFFreeGames/Commands/CommandDispatcher.cs | 26 +- ASFFreeGames/Commands/FreeGamesCommand.cs | 734 ++++++--- ASFFreeGames/Commands/GetIp/GetIPCommand.cs | 30 +- ASFFreeGames/Commands/IBotCommand.cs | 15 +- .../Configurations/ASFFreeGamesOptions.cs | 37 +- .../ASFFreeGamesOptionsLoader.cs | 45 +- .../ASFFreeGamesOptionsSaver.cs | 58 +- ASFFreeGames/ContextRegistry.cs | 9 +- .../Strategies/EListFreeGamesStrategy.cs | 2 +- .../Strategies/HttpRequestRedlibException.cs | 8 +- .../Strategies/IListFreeGamesStrategy.cs | 9 +- .../Strategies/ListFreeGamesContext.cs | 6 +- .../Strategies/ListFreeGamesMainStrategy.cs | 137 +- .../Strategies/RedditListFreeGamesStrategy.cs | 9 +- .../Strategies/RedlibListFreeGamesStrategy.cs | 72 +- ASFFreeGames/Github/GithubPluginUpdater.cs | 44 +- .../HttpClientSimple/SimpleHttpClient.cs | 124 +- .../SimpleHttpClientFactory.cs | 27 +- .../Maxisoft.Utils/BitSpan.Helpers.cs | 124 +- .../Maxisoft.Utils/OrderedDictionary.cs | 89 +- .../Maxisoft.Utils/SpanDict.Helpers.cs | 6 +- ASFFreeGames/Maxisoft.Utils/SpanDict.cs | 59 +- ASFFreeGames/Maxisoft.Utils/SpanExtensions.cs | 88 +- ASFFreeGames/Maxisoft.Utils/WrappedIndex.cs | 6 +- ASFFreeGames/PluginContext.cs | 15 +- ASFFreeGames/Reddit/RedditHelper.cs | 94 +- ASFFreeGames/Reddit/RedditServerException.cs | 9 +- .../Redlib/GameIdentifiersEqualityComparer.cs | 1 + ASFFreeGames/Redlib/Html/RedditHtmlParser.cs | 200 ++- .../Redlib/Html/RedlibHtmlParserRegex.cs | 25 +- .../Redlib/Instances/RedlibInstanceList.cs | 54 +- ASFFreeGames/Redlib/RedlibGameEntry.cs | 13 +- ASFFreeGames/Utils/LoggerFilter.cs | 248 +-- ASFFreeGames/Utils/Workarounds/AsyncLocal.cs | 1 - .../Utils/Workarounds/BotPackageChecker.cs | 31 +- 43 files changed, 2733 insertions(+), 1315 deletions(-) diff --git a/ASFFreeGames.Tests/redlib_asfinfo.html b/ASFFreeGames.Tests/redlib_asfinfo.html index 12763b3..794a00f 100644 --- a/ASFFreeGames.Tests/redlib_asfinfo.html +++ b/ASFFreeGames.Tests/redlib_asfinfo.html @@ -1,105 +1,126 @@  - - - ASFinfo (u/ASFinfo) - Redlib - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + - - - + + settings + + settings + + + + + + + +
@@ -107,36 +128,36 @@

You are about to leave Redlib

@@ -840,9 +1039,9 @@

ASFinfo [BOT]

- - - +
+ +
@@ -851,17 +1050,18 @@

ASFinfo [BOT]

- + - + + + - diff --git a/ASFFreeGames/ASFExtentions/Bot/BotName.cs b/ASFFreeGames/ASFExtentions/Bot/BotName.cs index 91c08a3..641edda 100644 --- a/ASFFreeGames/ASFExtentions/Bot/BotName.cs +++ b/ASFFreeGames/ASFExtentions/Bot/BotName.cs @@ -19,7 +19,11 @@ public readonly record struct BotName(string Name) : IComparable { /// /// Converts a to a instance implicitly. /// - [SuppressMessage("Usage", "CA2225:Operator overloads have named alternates", Justification = "The constructor serves as an alternative method.")] + [SuppressMessage( + "Usage", + "CA2225:Operator overloads have named alternates", + Justification = "The constructor serves as an alternative method." + )] public static implicit operator BotName(string value) => new BotName(value); /// @@ -38,8 +42,11 @@ public readonly record struct BotName(string Name) : IComparable { // Implement the relational operators using the CompareTo method public static bool operator <(BotName left, BotName right) => left.CompareTo(right) < 0; + public static bool operator <=(BotName left, BotName right) => left.CompareTo(right) <= 0; + public static bool operator >(BotName left, BotName right) => left.CompareTo(right) > 0; + public static bool operator >=(BotName left, BotName right) => left.CompareTo(right) >= 0; } } diff --git a/ASFFreeGames/ASFExtentions/Games/GameIdentifier.cs b/ASFFreeGames/ASFExtentions/Games/GameIdentifier.cs index 204725f..1a1cc90 100644 --- a/ASFFreeGames/ASFExtentions/Games/GameIdentifier.cs +++ b/ASFFreeGames/ASFExtentions/Games/GameIdentifier.cs @@ -12,13 +12,18 @@ namespace ASFFreeGames.ASFExtensions.Games; /// /// Represents a readonly record struct that encapsulates a game identifier with a numeric ID and a type. /// -public readonly record struct GameIdentifier(long Id, GameIdentifierType Type = GameIdentifierType.None) { +public readonly record struct GameIdentifier( + long Id, + GameIdentifierType Type = GameIdentifierType.None +) { /// /// Gets a value indicating whether the game identifier is valid. /// - public bool Valid => (Id > 0) && Type is >= GameIdentifierType.None and <= GameIdentifierType.App; + public bool Valid => + (Id > 0) && Type is >= GameIdentifierType.None and <= GameIdentifierType.App; - public override int GetHashCode() => unchecked(((ulong) Id ^ BinaryPrimitives.ReverseEndianness((ulong) Type)).GetHashCode()); + public override int GetHashCode() => + unchecked(((ulong) Id ^ BinaryPrimitives.ReverseEndianness((ulong) Type)).GetHashCode()); /// /// Returns the string representation of the game identifier. @@ -29,7 +34,7 @@ public override string ToString() => GameIdentifierType.None => Id.ToString(CultureInfo.InvariantCulture), GameIdentifierType.Sub => $"s/{Id}", GameIdentifierType.App => $"a/{Id}", - _ => throw new ArgumentOutOfRangeException(nameof(Type)) + _ => throw new ArgumentOutOfRangeException(nameof(Type)), }; /// @@ -38,5 +43,6 @@ public override string ToString() => /// 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) => GameIdentifierParser.TryParse(query, out result); + public static bool TryParse([NotNull] ReadOnlySpan query, out GameIdentifier result) => + GameIdentifierParser.TryParse(query, out result); } diff --git a/ASFFreeGames/ASFExtentions/Games/GameIdentifierParser.cs b/ASFFreeGames/ASFExtentions/Games/GameIdentifierParser.cs index d24edf3..2240c7c 100644 --- a/ASFFreeGames/ASFExtentions/Games/GameIdentifierParser.cs +++ b/ASFFreeGames/ASFExtentions/Games/GameIdentifierParser.cs @@ -56,7 +56,7 @@ public static bool TryParse(ReadOnlySpan query, out GameIdentifier result) identifierType = c switch { 'A' => GameIdentifierType.App, 'S' => GameIdentifierType.Sub, - _ => identifierType + _ => identifierType, }; } @@ -65,12 +65,18 @@ public static bool TryParse(ReadOnlySpan query, out GameIdentifier result) case 0: break; case 1 when char.ToUpperInvariant(type[0]) == 'A': - case 3 when (char.ToUpperInvariant(type[0]) == 'A') && (char.ToUpperInvariant(type[1]) == 'P') && (char.ToUpperInvariant(type[2]) == 'P'): + case 3 + when (char.ToUpperInvariant(type[0]) == 'A') + && (char.ToUpperInvariant(type[1]) == 'P') + && (char.ToUpperInvariant(type[2]) == 'P'): identifierType = GameIdentifierType.App; break; case 1 when char.ToUpperInvariant(type[0]) == 'S': - case 3 when (char.ToUpperInvariant(type[0]) == 'S') && (char.ToUpperInvariant(type[1]) == 'U') && (char.ToUpperInvariant(type[2]) == 'B'): + case 3 + when (char.ToUpperInvariant(type[0]) == 'S') + && (char.ToUpperInvariant(type[1]) == 'U') + && (char.ToUpperInvariant(type[2]) == 'B'): identifierType = GameIdentifierType.Sub; break; diff --git a/ASFFreeGames/ASFFreeGamesPlugin.cs b/ASFFreeGames/ASFFreeGamesPlugin.cs index 7f6c612..84978b4 100644 --- a/ASFFreeGames/ASFFreeGamesPlugin.cs +++ b/ASFFreeGames/ASFFreeGamesPlugin.cs @@ -31,12 +31,26 @@ internal interface IASFFreeGamesPlugin { #pragma warning disable CA1812 // ASF uses this class during runtime [SuppressMessage("Design", "CA1001:Disposable fields")] -internal sealed class ASFFreeGamesPlugin : IASF, IBot, IBotConnection, IBotCommand2, IUpdateAware, IASFFreeGamesPlugin, IGitHubPluginUpdates { +internal sealed class ASFFreeGamesPlugin + : IASF, + IBot, + IBotConnection, + IBotCommand2, + IUpdateAware, + IASFFreeGamesPlugin, + IGitHubPluginUpdates { internal const string StaticName = nameof(ASFFreeGamesPlugin); private const int CollectGamesTimeout = 3 * 60 * 1000; internal static PluginContext Context { - get => _context.Value ?? new PluginContext(Array.Empty(), new ContextRegistry(), new ASFFreeGamesOptions(), new LoggerFilter()); + get => + _context.Value + ?? new PluginContext( + Array.Empty(), + new ContextRegistry(), + new ASFFreeGamesOptions(), + new LoggerFilter() + ); private set => _context.Value = value; } @@ -47,10 +61,14 @@ internal static PluginContext Context { public string Name => StaticName; public Version Version => GetVersion(); - private static Version GetVersion() => typeof(ASFFreeGamesPlugin).Assembly.GetName().Version ?? throw new InvalidOperationException(nameof(Version)); + private static Version GetVersion() => + typeof(ASFFreeGamesPlugin).Assembly.GetName().Version + ?? throw new InvalidOperationException(nameof(Version)); private readonly ConcurrentHashSet Bots = new(new BotEqualityComparer()); - private readonly Lazy CancellationTokenSourceLazy = new(static () => new CancellationTokenSource()); + private readonly Lazy CancellationTokenSourceLazy = new( + static () => new CancellationTokenSource() + ); private readonly CommandDispatcher CommandDispatcher; private readonly LoggerFilter LoggerFilter = new(); @@ -66,10 +84,20 @@ internal static PluginContext Context { public ASFFreeGamesPlugin() { CommandDispatcher = new CommandDispatcher(Options); CollectIntervalManager = new CollectIntervalManager(this); - _context.Value = new PluginContext(Bots, BotContextRegistry, Options, LoggerFilter) { CancellationTokenLazy = new Lazy(() => CancellationTokenSourceLazy.Value.Token) }; + _context.Value = new PluginContext(Bots, BotContextRegistry, Options, LoggerFilter) { + CancellationTokenLazy = new Lazy( + () => CancellationTokenSourceLazy.Value.Token + ), + }; } - public async Task OnBotCommand(Bot? bot, EAccess access, string message, string[] args, ulong steamID = 0) { + public async Task OnBotCommand( + Bot? bot, + EAccess access, + string message, + string[] args, + ulong steamID = 0 + ) { if (!Context.Valid) { CreateContext(); } @@ -79,7 +107,8 @@ public ASFFreeGamesPlugin() { public async Task OnBotDestroy(Bot bot) => await RemoveBot(bot).ConfigureAwait(false); - public async Task OnBotDisconnected(Bot bot, EResult reason) => await RemoveBot(bot).ConfigureAwait(false); + public async Task OnBotDisconnected(Bot bot, EResult reason) => + await RemoveBot(bot).ConfigureAwait(false); public Task OnBotInit(Bot bot) => Task.CompletedTask; @@ -93,7 +122,9 @@ public Task OnLoaded() { return Task.CompletedTask; } - public async Task OnASFInit(IReadOnlyDictionary? additionalConfigProperties = null) { + public async Task OnASFInit( + IReadOnlyDictionary? additionalConfigProperties = null + ) { ASFFreeGamesOptionsLoader.Bind(ref OptionsField); JsonElement? jsonElement = GlobalDatabase?.LoadFromJsonStorage($"{Name}.Verbose"); @@ -104,9 +135,11 @@ public async Task OnASFInit(IReadOnlyDictionary? additional await SaveOptions(CancellationToken).ConfigureAwait(false); } - public async Task OnUpdateFinished(Version currentVersion, Version newVersion) => await SaveOptions(Context.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; + public Task OnUpdateProceeding(Version currentVersion, Version newVersion) => + Task.CompletedTask; public async void CollectGamesOnClock(object? source) { CollectIntervalManager.RandomlyChangeCollectInterval(source); @@ -115,7 +148,9 @@ public async void CollectGamesOnClock(object? source) { CreateContext(); } - using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken); + using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource( + CancellationToken + ); cts.CancelAfter(TimeSpan.FromMilliseconds(CollectGamesTimeout)); if (cts.IsCancellationRequested || !Context.Valid) { @@ -128,8 +163,10 @@ public async void CollectGamesOnClock(object? source) { IContextRegistry botContexts = Context.BotContexts; lock (botContexts) { - long orderByRunKeySelector(Bot bot) => botContexts.GetBotContext(bot)?.RunElapsedMilli ?? long.MaxValue; - int comparison(Bot x, Bot y) => orderByRunKeySelector(y).CompareTo(orderByRunKeySelector(x)); // sort in descending order + long orderByRunKeySelector(Bot bot) => + botContexts.GetBotContext(bot)?.RunElapsedMilli ?? long.MaxValue; + int comparison(Bot x, Bot y) => + orderByRunKeySelector(y).CompareTo(orderByRunKeySelector(x)); // sort in descending order reorderedBots = Bots.ToArray(); Array.Sort(reorderedBots, comparison); } @@ -141,13 +178,17 @@ public async void CollectGamesOnClock(object? source) { } if (!cts.IsCancellationRequested) { - string cmd = $"FREEGAMES {FreeGamesCommand.CollectInternalCommandString} " + string.Join(' ', reorderedBots.Select(static bot => bot.BotName)); + string cmd = + $"FREEGAMES {FreeGamesCommand.CollectInternalCommandString} " + + string.Join(' ', reorderedBots.Select(static bot => bot.BotName)); try { await OnBotCommand(null, EAccess.None, cmd, cmd.Split()).ConfigureAwait(false); } catch (Exception ex) { - ArchiLogger.LogGenericWarning($"Failed to execute scheduled free games collection: {ex.Message}"); + ArchiLogger.LogGenericWarning( + $"Failed to execute scheduled free games collection: {ex.Message}" + ); } } } @@ -156,14 +197,21 @@ public async void CollectGamesOnClock(object? source) { /// /// Creates a new PluginContext instance and assigns it to the Context property. /// - private void CreateContext() => Context = new PluginContext(Bots, BotContextRegistry, Options, LoggerFilter, true) { CancellationTokenLazy = new Lazy(() => CancellationTokenSourceLazy.Value.Token) }; + private void CreateContext() => + Context = new PluginContext(Bots, BotContextRegistry, Options, LoggerFilter, true) { + CancellationTokenLazy = new Lazy( + () => CancellationTokenSourceLazy.Value.Token + ), + }; private async Task RegisterBot(Bot bot) { Bots.Add(bot); StartTimerIfNeeded(); - await BotContextRegistry.SaveBotContext(bot, new BotContext(bot), CancellationToken).ConfigureAwait(false); + await BotContextRegistry + .SaveBotContext(bot, new BotContext(bot), CancellationToken) + .ConfigureAwait(false); BotContext? ctx = BotContextRegistry.GetBotContext(bot); if (ctx is not null) { @@ -198,7 +246,9 @@ private async Task RemoveBot(Bot bot) { private async Task SaveOptions(CancellationToken cancellationToken) { if (!cancellationToken.IsCancellationRequested) { const string cmd = $"FREEGAMES {FreeGamesCommand.SaveOptionsInternalCommandString}"; - async Task continuation() => await OnBotCommand(Bots.FirstOrDefault()!, EAccess.None, cmd, cmd.Split()).ConfigureAwait(false); + async Task continuation() => + await OnBotCommand(Bots.FirstOrDefault()!, EAccess.None, cmd, cmd.Split()) + .ConfigureAwait(false); string? result; @@ -227,7 +277,13 @@ private async Task RemoveBot(Bot bot) { bool IGitHubPluginUpdates.CanUpdate => Updater.CanUpdate; - Task IGitHubPluginUpdates.GetTargetReleaseURL(Version asfVersion, string asfVariant, bool asfUpdate, bool stable, bool forced) => Updater.GetTargetReleaseURL(asfVersion, asfVariant, asfUpdate, stable, forced); + Task IGitHubPluginUpdates.GetTargetReleaseURL( + Version asfVersion, + string asfVariant, + bool asfUpdate, + bool stable, + bool forced + ) => Updater.GetTargetReleaseURL(asfVersion, asfVariant, asfUpdate, stable, forced); #endregion } diff --git a/ASFFreeGames/AppLists/CompletedAppList.cs b/ASFFreeGames/AppLists/CompletedAppList.cs index 0cc4c51..3b87b1b 100644 --- a/ASFFreeGames/AppLists/CompletedAppList.cs +++ b/ASFFreeGames/AppLists/CompletedAppList.cs @@ -15,10 +15,14 @@ namespace Maxisoft.ASF.AppLists; internal sealed class CompletedAppList : IDisposable { internal long[]? CompletedAppBuffer { get; private set; } internal const int CompletedAppBufferSize = 128; - internal Memory CompletedAppMemory => ((Memory) CompletedAppBuffer!)[..CompletedAppBufferSize]; + internal Memory CompletedAppMemory => + ((Memory) CompletedAppBuffer!)[..CompletedAppBufferSize]; internal RecentGameMapping CompletedApps { get; } internal const int FileCompletedAppBufferSize = CompletedAppBufferSize * sizeof(long) * 2; - private static readonly ArrayPool LongMemoryPool = ArrayPool.Create(CompletedAppBufferSize, 10); + private static readonly ArrayPool LongMemoryPool = ArrayPool.Create( + CompletedAppBufferSize, + 10 + ); private static readonly char Endianness = BitConverter.IsLittleEndian ? 'l' : 'b'; public static readonly string FileExtension = $".fg{Endianness}dict"; @@ -48,24 +52,35 @@ public void Dispose() { } public bool Add(in GameIdentifier gameIdentifier) => CompletedApps.Add(in gameIdentifier); - public bool AddInvalid(in GameIdentifier gameIdentifier) => CompletedApps.AddInvalid(in gameIdentifier); - public bool Contains(in GameIdentifier gameIdentifier) => CompletedApps.Contains(in gameIdentifier); + public bool AddInvalid(in GameIdentifier gameIdentifier) => + CompletedApps.AddInvalid(in gameIdentifier); - public bool ContainsInvalid(in GameIdentifier gameIdentifier) => CompletedApps.ContainsInvalid(in gameIdentifier); + public bool Contains(in GameIdentifier gameIdentifier) => + CompletedApps.Contains(in gameIdentifier); + + public bool ContainsInvalid(in GameIdentifier gameIdentifier) => + CompletedApps.ContainsInvalid(in gameIdentifier); } public static class CompletedAppListSerializer { [SuppressMessage("Code", "CAC001:ConfigureAwaitChecker")] - internal static async Task SaveToFile(this CompletedAppList appList, string filePath, CancellationToken cancellationToken = default) { + internal static async Task SaveToFile( + this CompletedAppList appList, + string filePath, + CancellationToken cancellationToken = default + ) { if (string.IsNullOrWhiteSpace(filePath)) { return; } #pragma warning disable CA2007 await using FileStream sourceStream = new( filePath, - FileMode.Create, FileAccess.Write, FileShare.None, - bufferSize: CompletedAppList.FileCompletedAppBufferSize, useAsync: true + FileMode.Create, + FileAccess.Write, + FileShare.None, + bufferSize: CompletedAppList.FileCompletedAppBufferSize, + useAsync: true ); // ReSharper disable once UseAwaitUsing @@ -81,7 +96,11 @@ internal static async Task SaveToFile(this CompletedAppList appList, string file } [SuppressMessage("Code", "CAC001:ConfigureAwaitChecker")] - internal static async Task LoadFromFile(this CompletedAppList appList, string filePath, CancellationToken cancellationToken = default) { + internal static async Task LoadFromFile( + this CompletedAppList appList, + string filePath, + CancellationToken cancellationToken = default + ) { if (string.IsNullOrWhiteSpace(filePath)) { return false; } @@ -90,8 +109,11 @@ internal static async Task LoadFromFile(this CompletedAppList appList, str #pragma warning disable CA2007 await using FileStream sourceStream = new( filePath, - FileMode.Open, FileAccess.Read, FileShare.Read, - bufferSize: CompletedAppList.FileCompletedAppBufferSize, useAsync: true + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: CompletedAppList.FileCompletedAppBufferSize, + useAsync: true ); // ReSharper disable once UseAwaitUsing @@ -104,26 +126,38 @@ internal static async Task LoadFromFile(this CompletedAppList appList, str await decoder.CopyToAsync(ms, cancellationToken).ConfigureAwait(false); await decoder.FlushAsync(cancellationToken).ConfigureAwait(false); - if (appList.CompletedAppBuffer is { Length: > 0 } && (ms.Length == appList.CompletedAppMemory.Length * sizeof(long))) { + if ( + appList.CompletedAppBuffer is { Length: > 0 } + && (ms.Length == appList.CompletedAppMemory.Length * sizeof(long)) + ) { ms.Seek(0, SeekOrigin.Begin); int size = ms.Read(MemoryMarshal.Cast(appList.CompletedAppMemory.Span)); if (size != appList.CompletedAppMemory.Length * sizeof(long)) { - ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericError("[FreeGames] Unable to load previous completed app dict", nameof(LoadFromFile)); + ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericError( + "[FreeGames] Unable to load previous completed app dict", + nameof(LoadFromFile) + ); } try { appList.CompletedApps.Reload(); } catch (InvalidDataException e) { - ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericWarningException(e, $"[FreeGames] {nameof(appList.CompletedApps)}.{nameof(appList.CompletedApps.Reload)}"); + ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericWarningException( + e, + $"[FreeGames] {nameof(appList.CompletedApps)}.{nameof(appList.CompletedApps.Reload)}" + ); appList.CompletedApps.Reload(true); return false; } } else { - ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericError("[FreeGames] Unable to load previous completed app dict", nameof(LoadFromFile)); + ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericError( + "[FreeGames] Unable to load previous completed app dict", + nameof(LoadFromFile) + ); } return true; @@ -141,13 +175,17 @@ internal static async Task LoadFromFile(this CompletedAppList appList, str /// private static void ChangeBrotliEncoderToFastCompress(BrotliStream encoder, int level = 1) { try { - FieldInfo? field = encoder.GetType().GetField("_encoder", BindingFlags.NonPublic | BindingFlags.Instance); + FieldInfo? field = encoder + .GetType() + .GetField("_encoder", BindingFlags.NonPublic | BindingFlags.Instance); if (field?.GetValue(encoder) is BrotliEncoder previous) { BrotliEncoder brotliEncoder = default(BrotliEncoder); try { - MethodInfo? method = brotliEncoder.GetType().GetMethod("SetQuality", BindingFlags.NonPublic | BindingFlags.Instance); + MethodInfo? method = brotliEncoder + .GetType() + .GetMethod("SetQuality", BindingFlags.NonPublic | BindingFlags.Instance); method?.Invoke(brotliEncoder, new object?[] { level }); field.SetValue(encoder, brotliEncoder); } @@ -161,7 +199,10 @@ private static void ChangeBrotliEncoderToFastCompress(BrotliStream encoder, int } } catch (Exception e) { - ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericDebuggingException(e, nameof(ChangeBrotliEncoderToFastCompress)); + ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericDebuggingException( + e, + nameof(ChangeBrotliEncoderToFastCompress) + ); } } } diff --git a/ASFFreeGames/AppLists/RecentGameMapping.cs b/ASFFreeGames/AppLists/RecentGameMapping.cs index b408317..2686f6c 100644 --- a/ASFFreeGames/AppLists/RecentGameMapping.cs +++ b/ASFFreeGames/AppLists/RecentGameMapping.cs @@ -46,11 +46,14 @@ internal void InitMemories() { } public void Reload(bool fix = false) => LoadMemories(fix); + public void Reset() => InitMemories(); internal void LoadMemories(bool allowFixes) { ReadOnlySpan magicBytes = MagicBytes; - ReadOnlySpan magicSpan = MemoryMarshal.Cast(Buffer.Span)[..magicBytes.Length]; + ReadOnlySpan magicSpan = MemoryMarshal.Cast(Buffer.Span)[ + ..magicBytes.Length + ]; // ReSharper disable once LoopCanBeConvertedToQuery for (int i = 0; i < magicBytes.Length; i++) { @@ -74,7 +77,9 @@ internal void LoadMemories(bool allowFixes) { throw new InvalidDataException(); } - SpanDict dict = SpanDict.CreateFromBuffer(DictData.Span); + SpanDict dict = SpanDict.CreateFromBuffer( + DictData.Span + ); if (dict.Count != CountRef) { if (!allowFixes) { @@ -89,17 +94,23 @@ internal void LoadMemories(bool allowFixes) { internal ref long CountRef => ref SizeMemory.Span[0]; - public SpanDict Dict => SpanDict.CreateFromBuffer(DictData.Span, null, checked((int) Count)); + public SpanDict Dict => + SpanDict.CreateFromBuffer(DictData.Span, null, checked((int) Count)); - public bool Contains(in GameIdentifier item) => TryGetDate(in item, out long date) && (date > 0); + public bool Contains(in GameIdentifier item) => + TryGetDate(in item, out long date) && (date > 0); - public bool ContainsInvalid(in GameIdentifier item) => TryGetDate(in item, out long date) && (date < 0); + public bool ContainsInvalid(in GameIdentifier item) => + TryGetDate(in item, out long date) && (date < 0); - public bool TryGetDate(in GameIdentifier key, out long value) => Dict.TryGetValue(in key, out value); + public bool TryGetDate(in GameIdentifier key, out long value) => + Dict.TryGetValue(in key, out value); - public bool Add(in GameIdentifier item) => Add(in item, DateTimeOffset.UtcNow.ToUnixTimeSeconds()); + public bool Add(in GameIdentifier item) => + Add(in item, DateTimeOffset.UtcNow.ToUnixTimeSeconds()); - public bool AddInvalid(in GameIdentifier item) => Add(in item, -DateTimeOffset.UtcNow.ToUnixTimeSeconds()); + public bool AddInvalid(in GameIdentifier item) => + Add(in item, -DateTimeOffset.UtcNow.ToUnixTimeSeconds()); public bool Add(in GameIdentifier item, long date) { SpanDict dict = Dict; diff --git a/ASFFreeGames/CollectIntervalManager.cs b/ASFFreeGames/CollectIntervalManager.cs index 53ef83d..991d87a 100644 --- a/ASFFreeGames/CollectIntervalManager.cs +++ b/ASFFreeGames/CollectIntervalManager.cs @@ -51,10 +51,18 @@ internal sealed class CollectIntervalManager(IASFFreeGamesPlugin plugin) : IColl public void StartTimerIfNeeded() { if (Timer is null) { // Get a random initial delay - TimeSpan initialDelay = GetRandomizedTimerDelay(30, 6 * RandomizeIntervalSwitch, 1, 5 * 60); + TimeSpan initialDelay = GetRandomizedTimerDelay( + 30, + 6 * RandomizeIntervalSwitch, + 1, + 5 * 60 + ); // Get a random regular delay - TimeSpan regularDelay = GetRandomizedTimerDelay(plugin.Options.RecheckInterval.TotalSeconds, 7 * 60 * RandomizeIntervalSwitch); + TimeSpan regularDelay = GetRandomizedTimerDelay( + plugin.Options.RecheckInterval.TotalSeconds, + 7 * 60 * RandomizeIntervalSwitch + ); // Create a new timer with the collect operation as the callback Timer = new Timer(plugin.CollectGamesOnClock); @@ -69,12 +77,18 @@ public void StartTimerIfNeeded() { /// /// The randomized delay. /// - private TimeSpan GetRandomizedTimerDelay() => GetRandomizedTimerDelay(plugin.Options.RecheckInterval.TotalSeconds, 7 * 60 * RandomizeIntervalSwitch); + private TimeSpan GetRandomizedTimerDelay() => + GetRandomizedTimerDelay( + plugin.Options.RecheckInterval.TotalSeconds, + 7 * 60 * RandomizeIntervalSwitch + ); public TimeSpan RandomlyChangeCollectInterval(object? source) { // Calculate a random delay using GetRandomizedTimerDelay method TimeSpan delay = GetRandomizedTimerDelay(); - ResetTimer(() => new Timer(state => plugin.CollectGamesOnClock(state), source, delay, delay)); + ResetTimer( + () => new Timer(state => plugin.CollectGamesOnClock(state), source, delay, delay) + ); return delay; } @@ -94,8 +108,14 @@ public TimeSpan RandomlyChangeCollectInterval(object? source) { /// This method uses the NextGaussian method from the RandomUtils class to generate normally distributed random numbers. /// See [Random nextGaussian() method in Java with Examples] for more details on how to implement NextGaussian in C#. /// - private static TimeSpan GetRandomizedTimerDelay(double meanSeconds, double stdSeconds, double minSeconds = 11 * 60, double maxSeconds = 60 * 60) { - double randomNumber = stdSeconds != 0 ? Random.NextGaussian(meanSeconds, stdSeconds) : meanSeconds; + private static TimeSpan GetRandomizedTimerDelay( + double meanSeconds, + double stdSeconds, + double minSeconds = 11 * 60, + double maxSeconds = 60 * 60 + ) { + double randomNumber = + stdSeconds != 0 ? Random.NextGaussian(meanSeconds, stdSeconds) : meanSeconds; TimeSpan delay = TimeSpan.FromSeconds(randomNumber); diff --git a/ASFFreeGames/Commands/CommandDispatcher.cs b/ASFFreeGames/Commands/CommandDispatcher.cs index 4544da1..905dce2 100644 --- a/ASFFreeGames/Commands/CommandDispatcher.cs +++ b/ASFFreeGames/Commands/CommandDispatcher.cs @@ -10,21 +10,33 @@ namespace ASFFreeGames.Commands { // Implement the IBotCommand interface internal sealed class CommandDispatcher(ASFFreeGamesOptions options) : IBotCommand, IDisposable { // Declare a private field for the plugin options instance - private readonly ASFFreeGamesOptions Options = options ?? throw new ArgumentNullException(nameof(options)); + private readonly ASFFreeGamesOptions Options = + options ?? throw new ArgumentNullException(nameof(options)); // Declare a private field for the dictionary that maps command names to IBotCommand instances - private readonly Dictionary Commands = new(StringComparer.OrdinalIgnoreCase) { + private readonly Dictionary Commands = new( + StringComparer.OrdinalIgnoreCase + ) + { { "GETIP", new GetIPCommand() }, - { "FREEGAMES", new FreeGamesCommand(options) } + { "FREEGAMES", new FreeGamesCommand(options) }, }; - public async Task Execute(Bot? bot, string message, string[] args, ulong steamID = 0, CancellationToken cancellationToken = default) { + public async Task Execute( + Bot? bot, + string message, + string[] args, + ulong steamID = 0, + CancellationToken cancellationToken = default + ) { try { if (args is { Length: > 0 }) { // Try to get the corresponding IBotCommand instance from the commands dictionary based on the first argument if (Commands.TryGetValue(args[0], out IBotCommand? command)) { // Delegate the command execution to the IBotCommand instance, passing the bot and other parameters - return await command.Execute(bot, message, args, steamID, cancellationToken).ConfigureAwait(false); + return await command + .Execute(bot, message, args, steamID, cancellationToken) + .ConfigureAwait(false); } } } @@ -42,7 +54,9 @@ internal sealed class CommandDispatcher(ASFFreeGamesOptions options) : IBotComma } else { // Log a compact error message - ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericError($"An error occurred: {ex.GetType().Name} {ex.Message}"); + ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericError( + $"An error occurred: {ex.GetType().Name} {ex.Message}" + ); } } diff --git a/ASFFreeGames/Commands/FreeGamesCommand.cs b/ASFFreeGames/Commands/FreeGamesCommand.cs index 8cdfe77..155a7ac 100644 --- a/ASFFreeGames/Commands/FreeGamesCommand.cs +++ b/ASFFreeGames/Commands/FreeGamesCommand.cs @@ -20,13 +20,17 @@ using Maxisoft.ASF.Utils; using SteamKit2; -namespace ASFFreeGames.Commands { +namespace ASFFreeGames.Commands +{ // Implement the IBotCommand interface - internal sealed class FreeGamesCommand(ASFFreeGamesOptions options) : IBotCommand, IDisposable { - public void Dispose() { + internal sealed class FreeGamesCommand(ASFFreeGamesOptions options) : IBotCommand, IDisposable + { + public void Dispose() + { Strategy.Dispose(); - if (HttpFactory.IsValueCreated) { + if (HttpFactory.IsValueCreated) + { HttpFactory.Value.Dispose(); } @@ -39,12 +43,17 @@ public void Dispose() { private static PluginContext Context => ASFFreeGamesPlugin.Context; // Declare a private field for the plugin options instance - private ASFFreeGamesOptions Options = options ?? throw new ArgumentNullException(nameof(options)); + private ASFFreeGamesOptions Options = + options ?? throw new ArgumentNullException(nameof(options)); - private readonly Lazy HttpFactory = new(() => new SimpleHttpClientFactory(options)); + private readonly Lazy HttpFactory = new( + () => new SimpleHttpClientFactory(options) + ); - public IListFreeGamesStrategy Strategy { get; internal set; } = new ListFreeGamesMainStrategy(); - public EListFreeGamesStrategy PreviousSucessfulStrategy { get; private set; } = EListFreeGamesStrategy.Reddit | EListFreeGamesStrategy.Redlib; + public IListFreeGamesStrategy Strategy { get; internal set; } = + new ListFreeGamesMainStrategy(); + public EListFreeGamesStrategy PreviousSucessfulStrategy { get; private set; } = + EListFreeGamesStrategy.Reddit | EListFreeGamesStrategy.Redlib; // Define a constructor that takes an plugin options instance as a parameter @@ -58,22 +67,36 @@ public void Dispose() { /// The SteamID of the user who sent the command. /// /// A string response that indicates the result of the command execution. - public async Task Execute(Bot? bot, string message, string[] args, ulong steamID = 0, CancellationToken cancellationToken = default) { - if (args.Length >= 2) { - switch (args[1].ToUpperInvariant()) { + public async Task Execute( + Bot? bot, + string message, + string[] args, + ulong steamID = 0, + CancellationToken cancellationToken = default + ) + { + if (args.Length >= 2) + { + switch (args[1].ToUpperInvariant()) + { case "SET": - return await HandleSetCommand(bot, args, cancellationToken).ConfigureAwait(false); + return await HandleSetCommand(bot, args, cancellationToken) + .ConfigureAwait(false); case "RELOAD": return await HandleReloadCommand(bot).ConfigureAwait(false); case SaveOptionsInternalCommandString: - return await HandleInternalSaveOptionsCommand(bot, cancellationToken).ConfigureAwait(false); + return await HandleInternalSaveOptionsCommand(bot, cancellationToken) + .ConfigureAwait(false); case CollectInternalCommandString: - return await HandleInternalCollectCommand(bot, args, cancellationToken).ConfigureAwait(false); + return await HandleInternalCollectCommand(bot, args, cancellationToken) + .ConfigureAwait(false); case "SHOWBLACKLIST": - if (Options.Blacklist.Count == 0) { + if (Options.Blacklist.Count == 0) + { return FormatBotResponse(bot, "Blacklist is empty"); } - else { + else + { string blacklistItems = string.Join(", ", Options.Blacklist); return FormatBotResponse(bot, $"Current blacklist: {blacklistItems}"); } @@ -83,14 +106,22 @@ public void Dispose() { return await HandleCollectCommand(bot).ConfigureAwait(false); } - private static string FormatBotResponse(Bot? bot, string resp) => IBotCommand.FormatBotResponse(bot, resp); + private static string FormatBotResponse(Bot? bot, string resp) => + IBotCommand.FormatBotResponse(bot, resp); - private async Task HandleSetCommand(Bot? bot, string[] args, CancellationToken cancellationToken) { + private async Task HandleSetCommand( + Bot? bot, + string[] args, + CancellationToken cancellationToken + ) + { using CancellationTokenSource cts = CreateLinkedTokenSource(cancellationToken); cancellationToken = cts.Token; - if (args.Length >= 3) { - switch (args[2].ToUpperInvariant()) { + if (args.Length >= 3) + { + switch (args[2].ToUpperInvariant()) + { case "VERBOSE": Options.VerboseLog = true; await SaveOptions(cancellationToken).ConfigureAwait(false); @@ -107,60 +138,99 @@ public void Dispose() { Options.SkipFreeToPlay = false; await SaveOptions(cancellationToken).ConfigureAwait(false); - return FormatBotResponse(bot, $"{ASFFreeGamesPlugin.StaticName} is going to collect f2p games"); + return FormatBotResponse( + bot, + $"{ASFFreeGamesPlugin.StaticName} is going to collect f2p games" + ); case "NOF2P": case "NOFREETOPLAY": case "SKIPFREETOPLAY": Options.SkipFreeToPlay = true; await SaveOptions(cancellationToken).ConfigureAwait(false); - return FormatBotResponse(bot, $"{ASFFreeGamesPlugin.StaticName} is now skipping f2p games"); + return FormatBotResponse( + bot, + $"{ASFFreeGamesPlugin.StaticName} is now skipping f2p games" + ); case "DLC": case "NOSKIPDLC": Options.SkipDLC = false; await SaveOptions(cancellationToken).ConfigureAwait(false); - return FormatBotResponse(bot, $"{ASFFreeGamesPlugin.StaticName} is going to collect dlc"); + return FormatBotResponse( + bot, + $"{ASFFreeGamesPlugin.StaticName} is going to collect dlc" + ); case "NODLC": case "SKIPDLC": Options.SkipDLC = true; await SaveOptions(cancellationToken).ConfigureAwait(false); - return FormatBotResponse(bot, $"{ASFFreeGamesPlugin.StaticName} is now skipping dlc"); + return FormatBotResponse( + bot, + $"{ASFFreeGamesPlugin.StaticName} is now skipping dlc" + ); case "CLEARBLACKLIST": Options.ClearBlacklist(); await SaveOptions(cancellationToken).ConfigureAwait(false); - return FormatBotResponse(bot, $"{ASFFreeGamesPlugin.StaticName} blacklist has been cleared"); + return FormatBotResponse( + bot, + $"{ASFFreeGamesPlugin.StaticName} blacklist has been cleared" + ); case "REMOVEBLACKLIST": - if (args.Length >= 4) { + if (args.Length >= 4) + { string identifier = args[3]; - if (string.IsNullOrEmpty(identifier)) { - return FormatBotResponse(bot, "Please provide a valid game identifier to remove from blacklist"); + if (string.IsNullOrEmpty(identifier)) + { + return FormatBotResponse( + bot, + "Please provide a valid game identifier to remove from blacklist" + ); } - if (GameIdentifier.TryParse(identifier, out GameIdentifier gid)) { + if (GameIdentifier.TryParse(identifier, out GameIdentifier gid)) + { bool removed = Options.RemoveFromBlacklist(in gid); await SaveOptions(cancellationToken).ConfigureAwait(false); - if (removed) { - return FormatBotResponse(bot, $"{ASFFreeGamesPlugin.StaticName} removed {gid} from blacklist"); + if (removed) + { + return FormatBotResponse( + bot, + $"{ASFFreeGamesPlugin.StaticName} removed {gid} from blacklist" + ); } - else { - return FormatBotResponse(bot, $"{ASFFreeGamesPlugin.StaticName} could not find {gid} in blacklist"); + else + { + return FormatBotResponse( + bot, + $"{ASFFreeGamesPlugin.StaticName} could not find {gid} in blacklist" + ); } } - else { - return FormatBotResponse(bot, $"Invalid game identifier format: {identifier}"); + else + { + return FormatBotResponse( + bot, + $"Invalid game identifier format: {identifier}" + ); } } - else { - return FormatBotResponse(bot, "Please provide a game identifier to remove from blacklist"); + else + { + return FormatBotResponse( + bot, + "Please provide a game identifier to remove from blacklist" + ); } case "SHOWBLACKLIST": - if (Options.Blacklist.Count == 0) { + if (Options.Blacklist.Count == 0) + { return FormatBotResponse(bot, "Blacklist is empty"); } - else { + else + { string blacklistItems = string.Join(", ", Options.Blacklist); return FormatBotResponse(bot, $"Current blacklist: {blacklistItems}"); } @@ -177,54 +247,102 @@ public void Dispose() { /// /// The cancellation token to link. /// A CancellationTokenSource that is linked to both tokens. - private static CancellationTokenSource CreateLinkedTokenSource(CancellationToken cancellationToken) => Context.Valid ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, Context.CancellationToken) : CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - - private Task HandleReloadCommand(Bot? bot) { + private static CancellationTokenSource CreateLinkedTokenSource( + CancellationToken cancellationToken + ) => + Context.Valid + ? CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + Context.CancellationToken + ) + : CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + private Task HandleReloadCommand(Bot? bot) + { ASFFreeGamesOptionsLoader.Bind(ref Options); - return Task.FromResult(FormatBotResponse(bot, $"Reloaded {ASFFreeGamesPlugin.StaticName} options"))!; + return Task.FromResult( + FormatBotResponse(bot, $"Reloaded {ASFFreeGamesPlugin.StaticName} options") + )!; } - private async Task HandleCollectCommand(Bot? bot) { - int collected = await CollectGames(bot is not null ? [bot] : Context.Bots.ToArray(), ECollectGameRequestSource.RequestedByUser, Context.CancellationToken).ConfigureAwait(false); + private async Task HandleCollectCommand(Bot? bot) + { + int collected = await CollectGames( + bot is not null ? [bot] : Context.Bots.ToArray(), + ECollectGameRequestSource.RequestedByUser, + Context.CancellationToken + ) + .ConfigureAwait(false); return FormatBotResponse(bot, $"Collected a total of {collected} free game(s)"); } - private async ValueTask HandleInternalSaveOptionsCommand(Bot? bot, CancellationToken cancellationToken) { + private async ValueTask HandleInternalSaveOptionsCommand( + Bot? bot, + CancellationToken cancellationToken + ) + { await SaveOptions(cancellationToken).ConfigureAwait(false); return null; } - private async ValueTask HandleInternalCollectCommand(Bot? bot, string[] args, CancellationToken cancellationToken) { - Dictionary botMap = Context.Bots.ToDictionary(static b => b.BotName.Trim(), static b => b, StringComparer.InvariantCultureIgnoreCase); + private async ValueTask HandleInternalCollectCommand( + Bot? bot, + string[] args, + CancellationToken cancellationToken + ) + { + Dictionary botMap = Context.Bots.ToDictionary( + static b => b.BotName.Trim(), + static b => b, + StringComparer.InvariantCultureIgnoreCase + ); List bots = []; - for (int i = 2; i < args.Length; i++) { + for (int i = 2; i < args.Length; i++) + { string botName = args[i].Trim(); // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract - if (botMap.TryGetValue(botName, out Bot? savedBot) && savedBot is not null) { + if (botMap.TryGetValue(botName, out Bot? savedBot) && savedBot is not null) + { bots.Add(savedBot); } } - if (bots.Count == 0) { - if (bot is null) { + if (bots.Count == 0) + { + if (bot is null) + { return null; } bots = [bot]; } - int collected = await CollectGames(bots, ECollectGameRequestSource.Scheduled, cancellationToken).ConfigureAwait(false); - - return FormatBotResponse(bot, $"Collected a total of {collected} free game(s)" + (bots.Count > 1 ? $" on {bots.Count} bots" : $" on {bots.FirstOrDefault()?.BotName}")); + int collected = await CollectGames( + bots, + ECollectGameRequestSource.Scheduled, + cancellationToken + ) + .ConfigureAwait(false); + + return FormatBotResponse( + bot, + $"Collected a total of {collected} free game(s)" + + ( + bots.Count > 1 + ? $" on {bots.Count} bots" + : $" on {bots.FirstOrDefault()?.BotName}" + ) + ); } - private async Task SaveOptions(CancellationToken cancellationToken) { + private async Task SaveOptions(CancellationToken cancellationToken) + { using CancellationTokenSource cts = CreateLinkedTokenSource(cancellationToken); cancellationToken = cts.Token; cts.CancelAfter(10_000); @@ -236,9 +354,17 @@ private async Task SaveOptions(CancellationToken cancellationToken) { private readonly HashSet PreviouslySeenAppIds = new(); private static LoggerFilter LoggerFilter => Context.LoggerFilter; private const int DayInSeconds = 24 * 60 * 60; - private static readonly Lazy InvalidAppPurchaseRegex = new(BuildInvalidAppPurchaseRegex); - - private static readonly EPurchaseResultDetail[] InvalidAppPurchaseCodes = { EPurchaseResultDetail.AlreadyPurchased, EPurchaseResultDetail.RegionNotSupported, EPurchaseResultDetail.InvalidPackage, EPurchaseResultDetail.DoesNotOwnRequiredApp }; + private static readonly Lazy InvalidAppPurchaseRegex = new( + BuildInvalidAppPurchaseRegex + ); + + private static readonly EPurchaseResultDetail[] InvalidAppPurchaseCodes = + { + EPurchaseResultDetail.AlreadyPurchased, + EPurchaseResultDetail.RegionNotSupported, + EPurchaseResultDetail.InvalidPackage, + EPurchaseResultDetail.DoesNotOwnRequiredApp, + }; // ReSharper disable once RedundantDefaultMemberInitializer #pragma warning disable CA1805 @@ -246,123 +372,181 @@ private async Task SaveOptions(CancellationToken cancellationToken) { #if DEBUG Options.VerboseLog ?? true #else - Options.VerboseLog ?? false + Options.VerboseLog ?? false #endif ; #pragma warning restore CA1805 - private async Task CollectGames(IEnumerable bots, ECollectGameRequestSource requestSource, CancellationToken cancellationToken = default) { + private async Task CollectGames( + IEnumerable bots, + ECollectGameRequestSource requestSource, + CancellationToken cancellationToken = default + ) + { using CancellationTokenSource cts = CreateLinkedTokenSource(cancellationToken); cancellationToken = cts.Token; - if (cancellationToken.IsCancellationRequested) { + if (cancellationToken.IsCancellationRequested) + { return 0; } SemaphoreSlim? semaphore = SemaphoreSlim; - if (semaphore is null) { - lock (LockObject) { + if (semaphore is null) + { + lock (LockObject) + { SemaphoreSlim ??= new SemaphoreSlim(1, 1); semaphore = SemaphoreSlim; } } - if (!await semaphore.WaitAsync(100, cancellationToken).ConfigureAwait(false)) { + if (!await semaphore.WaitAsync(100, cancellationToken).ConfigureAwait(false)) + { return 0; } int res = 0; - try { + try + { IReadOnlyCollection games; - ListFreeGamesContext strategyContext = new(Options, new Lazy(() => HttpFactory.Value.CreateGeneric())) { + ListFreeGamesContext strategyContext = new( + Options, + new Lazy(() => HttpFactory.Value.CreateGeneric()) + ) + { Strategy = Strategy, HttpClientFactory = HttpFactory.Value, - PreviousSucessfulStrategy = PreviousSucessfulStrategy + PreviousSucessfulStrategy = PreviousSucessfulStrategy, }; // Cache of known invalid packages to avoid repeated failed attempts within the same collection run HashSet knownInvalidPackages = new(); - try { + try + { #pragma warning disable CA2000 - games = await Strategy.GetGames(strategyContext, cancellationToken).ConfigureAwait(false); + games = await Strategy + .GetGames(strategyContext, cancellationToken) + .ConfigureAwait(false); #pragma warning restore CA2000 } - catch (Exception e) when (e is InvalidOperationException or JsonException or IOException or RedditServerException) { - if (Options.VerboseLog ?? false) { + catch (Exception e) + when (e + is InvalidOperationException + or JsonException + or IOException + or RedditServerException + ) + { + if (Options.VerboseLog ?? false) + { ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericException(e); } - else { - ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericError($"Unable to get and load json {e.GetType().Name}: {e.Message}"); + else + { + ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericError( + $"Unable to get and load json {e.GetType().Name}: {e.Message}" + ); } return 0; } - finally { + finally + { PreviousSucessfulStrategy = strategyContext.PreviousSucessfulStrategy; - if (Options.VerboseLog ?? false) { - ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericInfo($"PreviousSucessfulStrategy = {PreviousSucessfulStrategy}"); + if (Options.VerboseLog ?? false) + { + ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericInfo( + $"PreviousSucessfulStrategy = {PreviousSucessfulStrategy}" + ); } } #pragma warning disable CA1308 - string remote = strategyContext.PreviousSucessfulStrategy.ToString().ToLowerInvariant(); + string remote = strategyContext + .PreviousSucessfulStrategy.ToString() + .ToLowerInvariant(); #pragma warning restore CA1308 - LogNewGameCount(games, remote, VerboseLog || requestSource is ECollectGameRequestSource.RequestedByUser); - - foreach (Bot bot in bots) { - if (cancellationToken.IsCancellationRequested) { + LogNewGameCount( + games, + remote, + VerboseLog || requestSource is ECollectGameRequestSource.RequestedByUser + ); + + foreach (Bot bot in bots) + { + if (cancellationToken.IsCancellationRequested) + { break; } - if (!bot.IsConnectedAndLoggedOn) { + if (!bot.IsConnectedAndLoggedOn) + { continue; } - if (bot.GamesToRedeemInBackgroundCount > 0) { + if (bot.GamesToRedeemInBackgroundCount > 0) + { continue; } - if (Options.IsBlacklisted(bot)) { + if (Options.IsBlacklisted(bot)) + { continue; } bool save = false; BotContext? context = Context.BotContexts.GetBotContext(bot); - if (context is null) { + if (context is null) + { continue; } - foreach ((string identifier, long time, bool freeToPlay, bool dlc) in games) { - if (freeToPlay && Options.SkipFreeToPlay is true) { + foreach ((string identifier, long time, bool freeToPlay, bool dlc) in games) + { + if (freeToPlay && Options.SkipFreeToPlay is true) + { continue; } - if (dlc && Options.SkipDLC is true) { + if (dlc && Options.SkipDLC is true) + { continue; } - if (string.IsNullOrWhiteSpace(identifier) || !GameIdentifier.TryParse(identifier, out GameIdentifier gid)) { + if ( + string.IsNullOrWhiteSpace(identifier) + || !GameIdentifier.TryParse(identifier, out GameIdentifier gid) + ) + { continue; } - if (context.HasApp(in gid)) { + if (context.HasApp(in gid)) + { continue; } - if (Options.IsBlacklisted(in gid)) { + if (Options.IsBlacklisted(in gid)) + { continue; } // Skip packages that have already failed in this collection run - if (knownInvalidPackages.Contains(gid.ToString())) { - if (VerboseLog) { - bot.ArchiLogger.LogGenericDebug($"Skipping previously failed package in this run: {gid}", nameof(CollectGames)); + if (knownInvalidPackages.Contains(gid.ToString())) + { + if (VerboseLog) + { + bot.ArchiLogger.LogGenericDebug( + $"Skipping previously failed package in this run: {gid}", + nameof(CollectGames) + ); } continue; } @@ -371,89 +555,199 @@ private async Task CollectGames(IEnumerable bots, ECollectGameRequestS string cmd = $"ADDLICENSE {bot.BotName} {gid}"; - if (VerboseLog) { - bot.ArchiLogger.LogGenericDebug($"Trying to perform command \"{cmd}\"", nameof(CollectGames)); + if (VerboseLog) + { + bot.ArchiLogger.LogGenericDebug( + $"Trying to perform command \"{cmd}\"", + nameof(CollectGames) + ); } int maxRetries = Options?.MaxRetryAttempts ?? 1; bool isTransientError = false; - do { - if (retryAttempts > 0) { + do + { + if (retryAttempts > 0) + { // Add delay before retry int retryDelay = Options.RetryDelayMilliseconds ?? 2000; - await Task.Delay(retryDelay, cancellationToken).ConfigureAwait(false); - - if (VerboseLog || requestSource is ECollectGameRequestSource.RequestedByUser) { - bot.ArchiLogger.LogGenericInfo($"[FreeGames] Retry attempt {retryAttempts} for {gid}", nameof(CollectGames)); + await Task.Delay(retryDelay, cancellationToken) + .ConfigureAwait(false); + + if ( + VerboseLog + || requestSource is ECollectGameRequestSource.RequestedByUser + ) + { + bot.ArchiLogger.LogGenericInfo( + $"[FreeGames] Retry attempt {retryAttempts} for {gid}", + nameof(CollectGames) + ); } } - using (LoggerFilter.DisableLoggingForAddLicenseCommonErrors(_ => !VerboseLog && (requestSource is not ECollectGameRequestSource.RequestedByUser) && context.ShouldHideErrorLogForApp(in gid), bot)) { - resp = await bot.Commands.Response(EAccess.Operator, cmd).ConfigureAwait(false); + using ( + LoggerFilter.DisableLoggingForAddLicenseCommonErrors( + _ => + !VerboseLog + && ( + requestSource + is not ECollectGameRequestSource.RequestedByUser + ) + && context.ShouldHideErrorLogForApp(in gid), + bot + ) + ) + { + resp = await bot + .Commands.Response(EAccess.Operator, cmd) + .ConfigureAwait(false); } bool success = false; - if (!string.IsNullOrWhiteSpace(resp)) { - success = resp!.Contains("collected game", StringComparison.InvariantCultureIgnoreCase); - success |= resp!.Contains("OK", StringComparison.InvariantCultureIgnoreCase); + if (!string.IsNullOrWhiteSpace(resp)) + { + success = resp!.Contains( + "collected game", + StringComparison.InvariantCultureIgnoreCase + ); + success |= resp!.Contains( + "OK", + StringComparison.InvariantCultureIgnoreCase + ); // Check if this is a transient error that should be retried - isTransientError = !success && - (resp.Contains("timeout", StringComparison.InvariantCultureIgnoreCase) || - resp.Contains("connection error", StringComparison.InvariantCultureIgnoreCase) || - resp.Contains("service unavailable", StringComparison.InvariantCultureIgnoreCase)); + isTransientError = + !success + && ( + resp.Contains( + "timeout", + StringComparison.InvariantCultureIgnoreCase + ) + || resp.Contains( + "connection error", + StringComparison.InvariantCultureIgnoreCase + ) + || resp.Contains( + "service unavailable", + StringComparison.InvariantCultureIgnoreCase + ) + ); // Don't retry if we got a clear "Forbidden" or other definitive error - if (resp.Contains("Forbidden", StringComparison.InvariantCultureIgnoreCase) || - resp.Contains("RateLimited", StringComparison.InvariantCultureIgnoreCase) || - resp.Contains("no eligible accounts", StringComparison.InvariantCultureIgnoreCase)) { + if ( + resp.Contains( + "Forbidden", + StringComparison.InvariantCultureIgnoreCase + ) + || resp.Contains( + "RateLimited", + StringComparison.InvariantCultureIgnoreCase + ) + || resp.Contains( + "no eligible accounts", + StringComparison.InvariantCultureIgnoreCase + ) + ) + { isTransientError = false; } // Log the result regardless of success if it's verbose or user-requested - if (success || (!isTransientError && (VerboseLog || requestSource is ECollectGameRequestSource.RequestedByUser || !context.ShouldHideErrorLogForApp(in gid)))) { + if ( + success + || ( + !isTransientError + && ( + VerboseLog + || requestSource + is ECollectGameRequestSource.RequestedByUser + || !context.ShouldHideErrorLogForApp(in gid) + ) + ) + ) + { string statusMessage; - if (success) { + if (success) + { statusMessage = "Success"; } - else if (resp.Contains("Forbidden", StringComparison.InvariantCultureIgnoreCase)) { + else if ( + resp.Contains( + "Forbidden", + StringComparison.InvariantCultureIgnoreCase + ) + ) + { statusMessage = "AccessDenied/InvalidPackage"; } - else if (resp.Contains("RateLimited", StringComparison.InvariantCultureIgnoreCase)) { + else if ( + resp.Contains( + "RateLimited", + StringComparison.InvariantCultureIgnoreCase + ) + ) + { statusMessage = "RateLimited"; } - else if (resp.Contains("timeout", StringComparison.InvariantCultureIgnoreCase)) { + else if ( + resp.Contains( + "timeout", + StringComparison.InvariantCultureIgnoreCase + ) + ) + { statusMessage = "Timeout"; } - else if (resp.Contains("no eligible accounts", StringComparison.InvariantCultureIgnoreCase)) { + else if ( + resp.Contains( + "no eligible accounts", + StringComparison.InvariantCultureIgnoreCase + ) + ) + { statusMessage = "NoEligibleAccounts"; } - else { + else + { statusMessage = "Failed"; } - bot.ArchiLogger.LogGenericInfo($"[FreeGames] <{bot.BotName}> ID: {gid} | Status: {statusMessage}{(isTransientError && retryAttempts < maxRetries ? " (Will retry)" : "")}", nameof(CollectGames)); + bot.ArchiLogger.LogGenericInfo( + $"[FreeGames] <{bot.BotName}> ID: {gid} | Status: {statusMessage}{(isTransientError && retryAttempts < maxRetries ? " (Will retry)" : "")}", + nameof(CollectGames) + ); } } // If request was successful or this is not a transient error, break the loop - if (success || !isTransientError) { - - if (success) { - lock (context) { + if (success || !isTransientError) + { + if (success) + { + lock (context) + { context.RegisterApp(in gid); } save = true; res++; } - else { + else + { // Add the game to the processed list even if it failed with Forbidden to avoid retrying - if (resp?.Contains("Forbidden", StringComparison.InvariantCultureIgnoreCase) ?? false) { - lock (context) { + if ( + resp?.Contains( + "Forbidden", + StringComparison.InvariantCultureIgnoreCase + ) ?? false + ) + { + lock (context) + { // Register the app as attempted but failed due to access restrictions context.RegisterApp(in gid); } @@ -463,34 +757,75 @@ private async Task CollectGames(IEnumerable bots, ECollectGameRequestS knownInvalidPackages.Add(gid.ToString()); // Optionally blacklist this game ID if auto-blacklisting is enabled - if (Options.AutoBlacklistForbiddenPackages ?? true) { + if (Options.AutoBlacklistForbiddenPackages ?? true) + { Options.AddToBlacklist(in gid); - - if (VerboseLog || requestSource is ECollectGameRequestSource.RequestedByUser) { - bot.ArchiLogger.LogGenericInfo($"[FreeGames] Adding {gid} to blacklist due to Forbidden response", nameof(CollectGames)); + if ( + VerboseLog + || requestSource + is ECollectGameRequestSource.RequestedByUser + ) + { + bot.ArchiLogger.LogGenericInfo( + $"[FreeGames] Adding {gid} to blacklist due to Forbidden response", + nameof(CollectGames) + ); } // Save the updated options to persist the blacklist - _ = Task.Run(async () => { - try { - await SaveOptions(cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) { - if (VerboseLog || requestSource is ECollectGameRequestSource.RequestedByUser) { - ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericError($"Failed to save options after blacklisting: {ex.Message}"); + _ = Task.Run(async () => + { + try + { + await SaveOptions(cancellationToken) + .ConfigureAwait(false); } - } - }).ConfigureAwait(false); + catch (Exception ex) + { + if ( + VerboseLog + || requestSource + is ECollectGameRequestSource.RequestedByUser + ) + { + ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericError( + $"Failed to save options after blacklisting: {ex.Message}" + ); + } + } + }) + .ConfigureAwait(false); } - if (VerboseLog || requestSource is ECollectGameRequestSource.RequestedByUser) { - bot.ArchiLogger.LogGenericWarning($"[FreeGames] Access denied for {gid}. The package may no longer be available or there are restrictions.", nameof(CollectGames)); + if ( + VerboseLog + || requestSource + is ECollectGameRequestSource.RequestedByUser + ) + { + bot.ArchiLogger.LogGenericWarning( + $"[FreeGames] Access denied for {gid}. The package may no longer be available or there are restrictions.", + nameof(CollectGames) + ); } } - if ((requestSource != ECollectGameRequestSource.RequestedByUser) && (resp?.Contains("RateLimited", StringComparison.InvariantCultureIgnoreCase) ?? false)) { - if (VerboseLog) { - bot.ArchiLogger.LogGenericWarning("[FreeGames] Rate limit reached ! Skipping remaining games...", nameof(CollectGames)); + if ( + (requestSource != ECollectGameRequestSource.RequestedByUser) + && ( + resp?.Contains( + "RateLimited", + StringComparison.InvariantCultureIgnoreCase + ) ?? false + ) + ) + { + if (VerboseLog) + { + bot.ArchiLogger.LogGenericWarning( + "[FreeGames] Rate limit reached ! Skipping remaining games...", + nameof(CollectGames) + ); } break; @@ -498,14 +833,21 @@ private async Task CollectGames(IEnumerable bots, ECollectGameRequestS } // Check if we need to update app tick counts or register invalid apps - if ((!success || isTransientError) && resp != null) { - if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() - time > DayInSeconds) { - lock (context) { + if ((!success || isTransientError) && resp != null) + { + if ( + DateTimeOffset.UtcNow.ToUnixTimeSeconds() - time + > DayInSeconds + ) + { + lock (context) + { context.AppTickCount(in gid, increment: true); } } - if (InvalidAppPurchaseRegex.Value.IsMatch(resp)) { + if (InvalidAppPurchaseRegex.Value.IsMatch(resp)) + { save |= context.RegisterInvalidApp(in gid); } } @@ -518,12 +860,14 @@ private async Task CollectGames(IEnumerable bots, ECollectGameRequestS // Add a delay between requests to avoid hitting rate limits int delay = Options.DelayBetweenRequests ?? 500; - if (delay > 0) { + if (delay > 0) + { await Task.Delay(delay, cancellationToken).ConfigureAwait(false); } } - if (save) { + if (save) + { await context.SaveToFileSystem(cancellationToken).ConfigureAwait(false); } @@ -531,53 +875,87 @@ private async Task CollectGames(IEnumerable bots, ECollectGameRequestS } } catch (TaskCanceledException) { } - finally { + finally + { semaphore.Release(); } return res; } - private void LogNewGameCount(IReadOnlyCollection games, string remote, bool logZero = false) { + private void LogNewGameCount( + IReadOnlyCollection games, + string remote, + bool logZero = false + ) + { int totalAppIdCounter = PreviouslySeenAppIds.Count; int newGameCounter = 0; - foreach (RedditGameEntry entry in games) { - if (GameIdentifier.TryParse(entry.Identifier, out GameIdentifier identifier) && PreviouslySeenAppIds.Add(identifier)) { + foreach (RedditGameEntry entry in games) + { + if ( + GameIdentifier.TryParse(entry.Identifier, out GameIdentifier identifier) + && PreviouslySeenAppIds.Add(identifier) + ) + { newGameCounter++; } } - if ((totalAppIdCounter == 0) && (games.Count > 0)) { - ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericInfo($"[FreeGames] found potentially {games.Count} free games on {remote}", nameof(CollectGames)); + if ((totalAppIdCounter == 0) && (games.Count > 0)) + { + ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericInfo( + $"[FreeGames] found potentially {games.Count} free games on {remote}", + nameof(CollectGames) + ); } - else if (newGameCounter > 0) { - ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericInfo($"[FreeGames] found {newGameCounter} fresh free game(s) on {remote}", nameof(CollectGames)); + else if (newGameCounter > 0) + { + ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericInfo( + $"[FreeGames] found {newGameCounter} fresh free game(s) on {remote}", + nameof(CollectGames) + ); } - else if ((newGameCounter == 0) && logZero) { - ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericInfo($"[FreeGames] found 0 new game out of {games.Count} free games on {remote}", nameof(CollectGames)); + else if ((newGameCounter == 0) && logZero) + { + ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericInfo( + $"[FreeGames] found 0 new game out of {games.Count} free games on {remote}", + nameof(CollectGames) + ); } } - private static Regex BuildInvalidAppPurchaseRegex() { + private static Regex BuildInvalidAppPurchaseRegex() + { StringBuilder stringBuilder = new("^.*?(?:"); - foreach (EPurchaseResultDetail code in InvalidAppPurchaseCodes) { + foreach (EPurchaseResultDetail code in InvalidAppPurchaseCodes) + { stringBuilder.Append("(?:"); - ReadOnlySpan codeString = code.ToString().Replace(nameof(EPurchaseResultDetail), @"\w*?", StringComparison.InvariantCultureIgnoreCase); - - while ((codeString.Length > 0) && (codeString[0] == '.')) { + ReadOnlySpan codeString = code.ToString() + .Replace( + nameof(EPurchaseResultDetail), + @"\w*?", + StringComparison.InvariantCultureIgnoreCase + ); + + while ((codeString.Length > 0) && (codeString[0] == '.')) + { codeString = codeString[1..]; } - if (codeString.Length <= 1) { + if (codeString.Length <= 1) + { continue; } stringBuilder.Append(codeString[0]); - foreach (char c in codeString[1..]) { - if (char.IsUpper(c)) { + foreach (char c in codeString[1..]) + { + if (char.IsUpper(c)) + { stringBuilder.Append(@"(?>\s*)"); } @@ -587,13 +965,21 @@ private static Regex BuildInvalidAppPurchaseRegex() { stringBuilder.Append(")|"); } - while ((stringBuilder.Length > 0) && (stringBuilder[^1] == '|')) { + while ((stringBuilder.Length > 0) && (stringBuilder[^1] == '|')) + { stringBuilder.Length -= 1; } stringBuilder.Append(").*?$"); - return new Regex(stringBuilder.ToString(), RegexOptions.Compiled | RegexOptions.Multiline | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace); + return new Regex( + stringBuilder.ToString(), + RegexOptions.Compiled + | RegexOptions.Multiline + | RegexOptions.CultureInvariant + | RegexOptions.IgnoreCase + | RegexOptions.IgnorePatternWhitespace + ); } } } diff --git a/ASFFreeGames/Commands/GetIp/GetIPCommand.cs b/ASFFreeGames/Commands/GetIp/GetIPCommand.cs index d3725d7..5a8fbfb 100644 --- a/ASFFreeGames/Commands/GetIp/GetIPCommand.cs +++ b/ASFFreeGames/Commands/GetIp/GetIPCommand.cs @@ -16,7 +16,13 @@ namespace ASFFreeGames.Commands.GetIp; internal sealed class GetIPCommand : IBotCommand { private const string GetIPAddressUrl = "https://httpbin.org/ip"; - public async Task Execute(Bot? bot, string message, string[] args, ulong steamID = 0, CancellationToken cancellationToken = default) { + public async Task Execute( + Bot? bot, + string message, + string[] args, + ulong steamID = 0, + CancellationToken cancellationToken = default + ) { WebBrowser? web = IBotCommand.GetWebBrowser(bot); if (web is null) { @@ -30,13 +36,24 @@ 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); + 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; } + if (result?.Content is null) { + return null; + } - GetIpReponse? reponse = await JsonSerializer.DeserializeAsync(result.Content, cancellationToken: cancellationToken).ConfigureAwait(false); + GetIpReponse? reponse = await JsonSerializer + .DeserializeAsync( + result.Content, + cancellationToken: cancellationToken + ) + .ConfigureAwait(false); string? origin = reponse?.Origin; if (!string.IsNullOrWhiteSpace(origin)) { @@ -45,7 +62,10 @@ internal sealed class GetIPCommand : IBotCommand { } catch (Exception e) when (e is JsonException or IOException) { #pragma warning disable CA1863 - return IBotCommand.FormatBotResponse(bot, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, e.Message)); + return IBotCommand.FormatBotResponse( + bot, + string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, e.Message) + ); #pragma warning restore CA1863 } diff --git a/ASFFreeGames/Commands/IBotCommand.cs b/ASFFreeGames/Commands/IBotCommand.cs index 79a8e61..09845be 100644 --- a/ASFFreeGames/Commands/IBotCommand.cs +++ b/ASFFreeGames/Commands/IBotCommand.cs @@ -8,8 +8,17 @@ namespace ASFFreeGames.Commands; // Define an interface named IBotCommand internal interface IBotCommand { // Define a method named Execute that takes the bot, message, args, steamID, and cancellationToken parameters and returns a string response - Task Execute(Bot? bot, string message, string[] args, ulong steamID = 0, CancellationToken cancellationToken = default); + Task Execute( + Bot? bot, + string message, + string[] args, + ulong steamID = 0, + CancellationToken cancellationToken = default + ); - protected static string FormatBotResponse(Bot? bot, string resp) => bot?.Commands?.FormatBotResponse(resp) ?? ArchiSteamFarm.Steam.Interaction.Commands.FormatStaticResponse(resp); - protected static WebBrowser? GetWebBrowser(Bot? bot) => bot?.ArchiWebHandler?.WebBrowser ?? ArchiSteamFarm.Core.ASF.WebBrowser; + protected static string FormatBotResponse(Bot? bot, string resp) => + bot?.Commands?.FormatBotResponse(resp) + ?? ArchiSteamFarm.Steam.Interaction.Commands.FormatStaticResponse(resp); + protected static WebBrowser? GetWebBrowser(Bot? bot) => + bot?.ArchiWebHandler?.WebBrowser ?? ArchiSteamFarm.Core.ASF.WebBrowser; } diff --git a/ASFFreeGames/Configurations/ASFFreeGamesOptions.cs b/ASFFreeGames/Configurations/ASFFreeGamesOptions.cs index a79fe1b..44b1dcb 100644 --- a/ASFFreeGames/Configurations/ASFFreeGamesOptions.cs +++ b/ASFFreeGames/Configurations/ASFFreeGamesOptions.cs @@ -10,8 +10,7 @@ namespace ASFFreeGames.Configurations; -public class ASFFreeGamesOptions -{ +public class ASFFreeGamesOptions { // Use TimeSpan instead of long for representing time intervals [JsonPropertyName("recheckInterval")] public TimeSpan RecheckInterval { get; set; } = TimeSpan.FromMinutes(30); @@ -46,10 +45,8 @@ public class ASFFreeGamesOptions [JsonPropertyName("retryDelayMilliseconds")] public int? RetryDelayMilliseconds { get; set; } = 2000; // Default 2 second delay between retries #region IsBlacklisted - public bool IsBlacklisted(in GameIdentifier gid) - { - if (Blacklist.Count <= 0) - { + public bool IsBlacklisted(in GameIdentifier gid) { + if (Blacklist.Count <= 0) { return false; } @@ -60,33 +57,27 @@ public bool IsBlacklisted(in GameIdentifier gid) public bool IsBlacklisted(in Bot? bot) => bot is null || ((Blacklist.Count > 0) && Blacklist.Contains($"bot/{bot.BotName}")); - public void AddToBlacklist(in GameIdentifier gid) - { - if (Blacklist is not HashSet blacklist) - { + public void AddToBlacklist(in GameIdentifier gid) { + if (Blacklist is not HashSet blacklist) { Blacklist = new HashSet(Blacklist); - blacklist = (HashSet)Blacklist; + blacklist = (HashSet) Blacklist; } - ((HashSet)Blacklist).Add(gid.ToString()); + ((HashSet) Blacklist).Add(gid.ToString()); } - public bool RemoveFromBlacklist(in GameIdentifier gid) - { - if (Blacklist is not HashSet blacklist) - { + public bool RemoveFromBlacklist(in GameIdentifier gid) { + if (Blacklist is not HashSet blacklist) { Blacklist = new HashSet(Blacklist); - blacklist = (HashSet)Blacklist; + blacklist = (HashSet) Blacklist; } - return ((HashSet)Blacklist).Remove(gid.ToString()); + return ((HashSet) Blacklist).Remove(gid.ToString()); } - public void ClearBlacklist() - { - if (Blacklist is not HashSet blacklist) - { + public void ClearBlacklist() { + if (Blacklist is not HashSet blacklist) { Blacklist = new HashSet(); } - ((HashSet)Blacklist).Clear(); + ((HashSet) Blacklist).Clear(); } #endregion diff --git a/ASFFreeGames/Configurations/ASFFreeGamesOptionsLoader.cs b/ASFFreeGames/Configurations/ASFFreeGamesOptionsLoader.cs index 805bebc..23bae25 100644 --- a/ASFFreeGames/Configurations/ASFFreeGamesOptionsLoader.cs +++ b/ASFFreeGames/Configurations/ASFFreeGamesOptionsLoader.cs @@ -21,18 +21,36 @@ public static void Bind(ref ASFFreeGamesOptions options) { try { IConfigurationRoot configurationRoot = CreateConfigurationRoot(); - IEnumerable blacklist = configurationRoot.GetValue("Blacklist", options.Blacklist) ?? options.Blacklist; - options.Blacklist = new HashSet(blacklist, StringComparer.InvariantCultureIgnoreCase); + IEnumerable blacklist = + configurationRoot.GetValue("Blacklist", options.Blacklist) ?? options.Blacklist; + options.Blacklist = new HashSet( + blacklist, + StringComparer.InvariantCultureIgnoreCase + ); options.VerboseLog = configurationRoot.GetValue("VerboseLog", options.VerboseLog); - options.RecheckInterval = TimeSpan.FromMilliseconds(configurationRoot.GetValue("RecheckIntervalMs", options.RecheckInterval.TotalMilliseconds)); - options.SkipFreeToPlay = configurationRoot.GetValue("SkipFreeToPlay", options.SkipFreeToPlay); + options.RecheckInterval = TimeSpan.FromMilliseconds( + configurationRoot.GetValue( + "RecheckIntervalMs", + options.RecheckInterval.TotalMilliseconds + ) + ); + options.SkipFreeToPlay = configurationRoot.GetValue( + "SkipFreeToPlay", + options.SkipFreeToPlay + ); options.SkipDLC = configurationRoot.GetValue("SkipDLC", options.SkipDLC); - options.RandomizeRecheckInterval = configurationRoot.GetValue("RandomizeRecheckInterval", options.RandomizeRecheckInterval); + options.RandomizeRecheckInterval = configurationRoot.GetValue( + "RandomizeRecheckInterval", + options.RandomizeRecheckInterval + ); options.Proxy = configurationRoot.GetValue("Proxy", options.Proxy); options.RedditProxy = configurationRoot.GetValue("RedditProxy", options.RedditProxy); options.RedlibProxy = configurationRoot.GetValue("RedlibProxy", options.RedlibProxy); - options.RedlibInstanceUrl = configurationRoot.GetValue("RedlibInstanceUrl", options.RedlibInstanceUrl); + options.RedlibInstanceUrl = configurationRoot.GetValue( + "RedlibInstanceUrl", + options.RedlibInstanceUrl + ); } finally { Semaphore.Release(); @@ -59,7 +77,12 @@ public static async Task Save(ASFFreeGamesOptions options, CancellationToken can try { #pragma warning disable CAC001 #pragma warning disable CA2007 - await using FileStream fs = new(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.ReadWrite); + await using FileStream fs = new( + path, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.ReadWrite + ); #pragma warning restore CA2007 #pragma warning restore CAC001 byte[] buffer = new byte[fs.Length > 0 ? (int) fs.Length + 1 : 1 << 15]; @@ -69,14 +92,16 @@ public static async Task Save(ASFFreeGamesOptions options, CancellationToken can try { fs.Position = 0; fs.SetLength(0); - int written = await ASFFreeGamesOptionsSaver.SaveOptions(fs, options, true, cancellationToken).ConfigureAwait(false); + int written = await ASFFreeGamesOptionsSaver + .SaveOptions(fs, options, true, cancellationToken) + .ConfigureAwait(false); fs.SetLength(written); } - catch (Exception) { fs.Position = 0; - await fs.WriteAsync(((ReadOnlyMemory) buffer)[..read], cancellationToken).ConfigureAwait(false); + await fs.WriteAsync(((ReadOnlyMemory) buffer)[..read], cancellationToken) + .ConfigureAwait(false); fs.SetLength(read); throw; diff --git a/ASFFreeGames/Configurations/ASFFreeGamesOptionsSaver.cs b/ASFFreeGames/Configurations/ASFFreeGamesOptionsSaver.cs index 3cce75b..7d1719b 100644 --- a/ASFFreeGames/Configurations/ASFFreeGamesOptionsSaver.cs +++ b/ASFFreeGames/Configurations/ASFFreeGamesOptionsSaver.cs @@ -15,7 +15,12 @@ namespace ASFFreeGames.Configurations; public static class ASFFreeGamesOptionsSaver { - public static async Task SaveOptions([NotNull] Stream stream, [NotNull] ASFFreeGamesOptions options, bool checkValid = true, CancellationToken cancellationToken = default) { + public static async Task SaveOptions( + [NotNull] Stream stream, + [NotNull] ASFFreeGamesOptions options, + bool checkValid = true, + CancellationToken cancellationToken = default + ) { using IMemoryOwner memory = MemoryPool.Shared.Rent(1 << 15); int written = CreateOptionsBuffer(options, memory); @@ -41,16 +46,36 @@ internal static int CreateOptionsBuffer(ASFFreeGamesOptions options, IMemoryOwne int written = 0; written += WriteJsonString("{\n"u8, buffer, written); - written += WriteNameAndProperty("recheckInterval"u8, options.RecheckInterval, buffer, written); - written += WriteNameAndProperty("randomizeRecheckInterval"u8, options.RandomizeRecheckInterval, buffer, written); - written += WriteNameAndProperty("skipFreeToPlay"u8, options.SkipFreeToPlay, buffer, written); + written += WriteNameAndProperty( + "recheckInterval"u8, + options.RecheckInterval, + buffer, + written + ); + written += WriteNameAndProperty( + "randomizeRecheckInterval"u8, + options.RandomizeRecheckInterval, + buffer, + written + ); + written += WriteNameAndProperty( + "skipFreeToPlay"u8, + options.SkipFreeToPlay, + buffer, + written + ); written += WriteNameAndProperty("skipDLC"u8, options.SkipDLC, buffer, written); written += WriteNameAndProperty("blacklist"u8, options.Blacklist, buffer, written); written += WriteNameAndProperty("verboseLog"u8, options.VerboseLog, buffer, written); written += WriteNameAndProperty("proxy"u8, options.Proxy, buffer, written); written += WriteNameAndProperty("redditProxy"u8, options.RedditProxy, buffer, written); written += WriteNameAndProperty("redlibProxy"u8, options.RedlibProxy, buffer, written); - written += WriteNameAndProperty("redlibInstanceUrl"u8, options.RedlibInstanceUrl, buffer, written); + written += WriteNameAndProperty( + "redlibInstanceUrl"u8, + options.RedlibInstanceUrl, + buffer, + written + ); RemoveTrailingCommaAndLineReturn(buffer, ref written); written += WriteJsonString("\n}"u8, buffer, written); @@ -159,7 +184,12 @@ private static int WriteEscapedJsonString(string str, Span buffer, int wri } [MethodImpl(MethodImplOptions.AggressiveOptimization)] - private static int WriteNameAndProperty(ReadOnlySpan name, T value, Span buffer, int written) { + private static int WriteNameAndProperty( + ReadOnlySpan name, + T value, + Span buffer, + int written + ) { int startIndex = written; written += WriteJsonString("\""u8, buffer, written); written += WriteJsonString(name, buffer, written); @@ -174,9 +204,15 @@ private static int WriteNameAndProperty(ReadOnlySpan name, T value, Spa #pragma warning disable CA1308 bool b => WriteJsonString(b ? "true"u8 : "false"u8, buffer, written), #pragma warning restore CA1308 - IReadOnlyCollection collection => WriteJsonArray(collection, buffer, written), + IReadOnlyCollection collection => WriteJsonArray( + collection, + buffer, + written + ), TimeSpan timeSpan => WriteEscapedJsonString(timeSpan.ToString(), buffer, written), - _ => throw new ArgumentException($"Unsupported type for property {Encoding.UTF8.GetString(name)}: {value.GetType()}") + _ => throw new ArgumentException( + $"Unsupported type for property {Encoding.UTF8.GetString(name)}: {value.GetType()}" + ), }; } @@ -186,7 +222,11 @@ private static int WriteNameAndProperty(ReadOnlySpan name, T value, Spa return written - startIndex; } - private static int WriteJsonArray(IEnumerable collection, Span buffer, int written) { + private static int WriteJsonArray( + IEnumerable collection, + Span buffer, + int written + ) { int startIndex = written; written += WriteJsonString("["u8, buffer, written); bool first = true; diff --git a/ASFFreeGames/ContextRegistry.cs b/ASFFreeGames/ContextRegistry.cs index 9c6f898..13e9443 100644 --- a/ASFFreeGames/ContextRegistry.cs +++ b/ASFFreeGames/ContextRegistry.cs @@ -50,9 +50,14 @@ internal sealed class ContextRegistry : IContextRegistry { public BotContext? GetBotContext(Bot bot) => BotContexts.GetValueOrDefault(bot.BotName); /// - public ValueTask RemoveBotContext(Bot bot) => ValueTask.FromResult(BotContexts.TryRemove(bot.BotName, out _)); + public ValueTask RemoveBotContext(Bot bot) => + ValueTask.FromResult(BotContexts.TryRemove(bot.BotName, out _)); /// - public Task SaveBotContext(Bot bot, BotContext context, CancellationToken cancellationToken) => Task.FromResult(BotContexts[bot.BotName] = context); + public Task SaveBotContext( + Bot bot, + BotContext context, + CancellationToken cancellationToken + ) => Task.FromResult(BotContexts[bot.BotName] = context); } } diff --git a/ASFFreeGames/FreeGames/Strategies/EListFreeGamesStrategy.cs b/ASFFreeGames/FreeGames/Strategies/EListFreeGamesStrategy.cs index f088d49..4a0eadd 100644 --- a/ASFFreeGames/FreeGames/Strategies/EListFreeGamesStrategy.cs +++ b/ASFFreeGames/FreeGames/Strategies/EListFreeGamesStrategy.cs @@ -7,5 +7,5 @@ namespace Maxisoft.ASF.FreeGames.Strategies; public enum EListFreeGamesStrategy { None = 0, Reddit = 1 << 0, - Redlib = 1 << 1 + Redlib = 1 << 1, } diff --git a/ASFFreeGames/FreeGames/Strategies/HttpRequestRedlibException.cs b/ASFFreeGames/FreeGames/Strategies/HttpRequestRedlibException.cs index 665f9c9..67ef3ca 100644 --- a/ASFFreeGames/FreeGames/Strategies/HttpRequestRedlibException.cs +++ b/ASFFreeGames/FreeGames/Strategies/HttpRequestRedlibException.cs @@ -10,6 +10,10 @@ public class HttpRequestRedlibException : RedlibException { public required Uri? Uri { get; init; } public HttpRequestRedlibException() { } - public HttpRequestRedlibException(string message) : base(message) { } - public HttpRequestRedlibException(string message, Exception inner) : base(message, inner) { } + + public HttpRequestRedlibException(string message) + : base(message) { } + + public HttpRequestRedlibException(string message, Exception inner) + : base(message, inner) { } } diff --git a/ASFFreeGames/FreeGames/Strategies/IListFreeGamesStrategy.cs b/ASFFreeGames/FreeGames/Strategies/IListFreeGamesStrategy.cs index 12222a5..4b4f2e3 100644 --- a/ASFFreeGames/FreeGames/Strategies/IListFreeGamesStrategy.cs +++ b/ASFFreeGames/FreeGames/Strategies/IListFreeGamesStrategy.cs @@ -10,11 +10,16 @@ namespace Maxisoft.ASF.FreeGames.Strategies; [SuppressMessage("ReSharper", "RedundantNullableFlowAttribute")] public interface IListFreeGamesStrategy : IDisposable { - Task> GetGames([NotNull] ListFreeGamesContext context, CancellationToken cancellationToken); + Task> GetGames( + [NotNull] ListFreeGamesContext context, + CancellationToken cancellationToken + ); public static Exception ExceptionFromTask([NotNull] Task task) { if (task is { IsFaulted: true, Exception: not null }) { - return task.Exception.InnerExceptions.Count == 1 ? task.Exception.InnerExceptions[0] : task.Exception; + return task.Exception.InnerExceptions.Count == 1 + ? task.Exception.InnerExceptions[0] + : task.Exception; } if (task.IsCanceled) { diff --git a/ASFFreeGames/FreeGames/Strategies/ListFreeGamesContext.cs b/ASFFreeGames/FreeGames/Strategies/ListFreeGamesContext.cs index 42b66cb..5f7e877 100644 --- a/ASFFreeGames/FreeGames/Strategies/ListFreeGamesContext.cs +++ b/ASFFreeGames/FreeGames/Strategies/ListFreeGamesContext.cs @@ -5,7 +5,11 @@ // ReSharper disable once CheckNamespace namespace Maxisoft.ASF.FreeGames.Strategies; -public sealed record ListFreeGamesContext(ASFFreeGamesOptions Options, Lazy HttpClient, uint Retry = 5) { +public sealed record ListFreeGamesContext( + ASFFreeGamesOptions Options, + Lazy HttpClient, + uint Retry = 5 +) { public required SimpleHttpClientFactory HttpClientFactory { get; init; } public EListFreeGamesStrategy PreviousSucessfulStrategy { get; set; } diff --git a/ASFFreeGames/FreeGames/Strategies/ListFreeGamesMainStrategy.cs b/ASFFreeGames/FreeGames/Strategies/ListFreeGamesMainStrategy.cs index 5455649..787cb59 100644 --- a/ASFFreeGames/FreeGames/Strategies/ListFreeGamesMainStrategy.cs +++ b/ASFFreeGames/FreeGames/Strategies/ListFreeGamesMainStrategy.cs @@ -22,7 +22,10 @@ public void Dispose() { GC.SuppressFinalize(this); } - public async Task> GetGames([NotNull] ListFreeGamesContext context, CancellationToken cancellationToken) { + public async Task> GetGames( + [NotNull] ListFreeGamesContext context, + CancellationToken cancellationToken + ) { await StrategySemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); try { @@ -41,23 +44,35 @@ protected virtual void Dispose(bool disposing) { } } - private async Task> DoGetGames([NotNull] ListFreeGamesContext context, CancellationToken cancellationToken) { - using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + private async Task> DoGetGames( + [NotNull] ListFreeGamesContext context, + CancellationToken cancellationToken + ) { + using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken + ); List disposables = []; try { - Task> redditTask1 = FirstTryRedditStrategy(context, disposables, cts.Token); + Task> redditTask1 = FirstTryRedditStrategy( + context, + disposables, + cts.Token + ); disposables.Add(redditTask1); try { - await WaitForFirstTryRedditStrategy(context, redditTask1, cts.Token).ConfigureAwait(false); + await WaitForFirstTryRedditStrategy(context, redditTask1, cts.Token) + .ConfigureAwait(false); } catch (Exception) { // ignored and handled below } if (redditTask1.IsCompletedSuccessfully) { - IReadOnlyCollection result = await redditTask1.ConfigureAwait(false); + IReadOnlyCollection result = await redditTask1.ConfigureAwait( + false + ); if (result.Count > 0) { context.PreviousSucessfulStrategy |= EListFreeGamesStrategy.Reddit; @@ -66,22 +81,44 @@ private async Task> DoGetGames([NotNull] Li } } - CancellationTokenSource cts2 = CancellationTokenSource.CreateLinkedTokenSource(cts.Token); + CancellationTokenSource cts2 = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token + ); disposables.Add(cts2); cts2.CancelAfter(TimeSpan.FromSeconds(45)); - Task> redlibTask = RedlibStrategy.GetGames(context with { HttpClient = new Lazy(() => context.HttpClientFactory.CreateForRedlib()) }, cts2.Token); + Task> redlibTask = RedlibStrategy.GetGames( + context with { + HttpClient = new Lazy( + () => context.HttpClientFactory.CreateForRedlib() + ), + }, + cts2.Token + ); disposables.Add(redlibTask); - Task> redditTask2 = LastTryRedditStrategy(context, redditTask1, cts2.Token); + Task> redditTask2 = LastTryRedditStrategy( + context, + redditTask1, + cts2.Token + ); disposables.Add(redditTask2); context.PreviousSucessfulStrategy = EListFreeGamesStrategy.None; - Task>[] strategiesTasks = [redditTask1, redditTask2, redlibTask]; // note that order matters + Task>[] strategiesTasks = + [ + redditTask1, + redditTask2, + redlibTask, + ]; // note that order matters try { - IReadOnlyCollection? res = await WaitForStrategiesTasks(cts.Token, strategiesTasks).ConfigureAwait(false); + IReadOnlyCollection? res = await WaitForStrategiesTasks( + cts.Token, + strategiesTasks + ) + .ConfigureAwait(false); if (res is { Count: > 0 }) { return res; @@ -110,7 +147,11 @@ private async Task> DoGetGames([NotNull] Li } List exceptions = new(strategiesTasks.Length); - exceptions.AddRange(from task in strategiesTasks where task.IsFaulted || task.IsCanceled select IListFreeGamesStrategy.ExceptionFromTask(task)); + exceptions.AddRange( + from task in strategiesTasks + where task.IsFaulted || task.IsCanceled + select IListFreeGamesStrategy.ExceptionFromTask(task) + ); switch (exceptions.Count) { case 1: @@ -131,8 +172,14 @@ private async Task> DoGetGames([NotNull] Li } // ReSharper disable once SuggestBaseTypeForParameter - private async Task> FirstTryRedditStrategy(ListFreeGamesContext context, List disposables, CancellationToken cancellationToken) { - CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + private async Task> FirstTryRedditStrategy( + ListFreeGamesContext context, + List disposables, + CancellationToken cancellationToken + ) { + CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken + ); disposables.Add(cts); cts.CancelAfter(TimeSpan.FromSeconds(10)); @@ -140,15 +187,24 @@ private async Task> FirstTryRedditStrategy( await Task.Delay(1000, cancellationToken).ConfigureAwait(false); } - return await RedditStrategy.GetGames( - context with { - Retry = 1, - HttpClient = new Lazy(() => context.HttpClientFactory.CreateForReddit()) - }, cts.Token - ).ConfigureAwait(false); + return await RedditStrategy + .GetGames( + context with { + Retry = 1, + HttpClient = new Lazy( + () => context.HttpClientFactory.CreateForReddit() + ), + }, + cts.Token + ) + .ConfigureAwait(false); } - private async Task> LastTryRedditStrategy(ListFreeGamesContext context, Task firstTryTask, CancellationToken cancellationToken) { + private async Task> LastTryRedditStrategy( + ListFreeGamesContext context, + Task firstTryTask, + CancellationToken cancellationToken + ) { if (!firstTryTask.IsCompleted) { try { await firstTryTask.WaitAsync(cancellationToken).ConfigureAwait(false); @@ -160,28 +216,44 @@ private async Task> LastTryRedditStrategy(L cancellationToken.ThrowIfCancellationRequested(); - return await RedditStrategy.GetGames( - context with { - Retry = checked(context.Retry - 1), - HttpClient = new Lazy(() => context.HttpClientFactory.CreateForReddit()) - }, cancellationToken - ).ConfigureAwait(false); + return await RedditStrategy + .GetGames( + context with { + Retry = checked(context.Retry - 1), + HttpClient = new Lazy( + () => context.HttpClientFactory.CreateForReddit() + ), + }, + cancellationToken + ) + .ConfigureAwait(false); } - private static async Task WaitForFirstTryRedditStrategy(ListFreeGamesContext context, Task redditTask, CancellationToken cancellationToken) { + private static async Task WaitForFirstTryRedditStrategy( + ListFreeGamesContext context, + Task redditTask, + CancellationToken cancellationToken + ) { if (context.PreviousSucessfulStrategy.HasFlag(EListFreeGamesStrategy.Reddit)) { try { - await Task.WhenAny(redditTask, Task.Delay(2500, cancellationToken)).ConfigureAwait(false); + await Task.WhenAny(redditTask, Task.Delay(2500, cancellationToken)) + .ConfigureAwait(false); } catch (Exception e) { - if (e is OperationCanceledException or TimeoutException && cancellationToken.IsCancellationRequested) { + if ( + e is OperationCanceledException or TimeoutException + && cancellationToken.IsCancellationRequested + ) { throw; } } } } - private static async Task?> WaitForStrategiesTasks(CancellationToken cancellationToken, params Task>[] p) { + private static async Task?> WaitForStrategiesTasks( + CancellationToken cancellationToken, + params Task>[] p + ) { LinkedList>> tasks = []; foreach (Task> task in p) { @@ -200,7 +272,8 @@ private static async Task WaitForFirstTryRedditStrategy(ListFreeGamesContext con while (taskNode is not null) { if (taskNode.Value.IsCompletedSuccessfully) { - IReadOnlyCollection result = await taskNode.Value.ConfigureAwait(false); + IReadOnlyCollection result = + await taskNode.Value.ConfigureAwait(false); if (result.Count > 0) { return result; diff --git a/ASFFreeGames/FreeGames/Strategies/RedditListFreeGamesStrategy.cs b/ASFFreeGames/FreeGames/Strategies/RedditListFreeGamesStrategy.cs index 799546d..51a2a27 100644 --- a/ASFFreeGames/FreeGames/Strategies/RedditListFreeGamesStrategy.cs +++ b/ASFFreeGames/FreeGames/Strategies/RedditListFreeGamesStrategy.cs @@ -11,9 +11,14 @@ namespace Maxisoft.ASF.FreeGames.Strategies; public sealed class RedditListFreeGamesStrategy : IListFreeGamesStrategy { public void Dispose() { } - public async Task> GetGames([NotNull] ListFreeGamesContext context, CancellationToken cancellationToken) { + public async Task> GetGames( + [NotNull] ListFreeGamesContext context, + CancellationToken cancellationToken + ) { cancellationToken.ThrowIfCancellationRequested(); - return await RedditHelper.GetGames(context.HttpClient.Value, context.Retry, cancellationToken).ConfigureAwait(false); + return await RedditHelper + .GetGames(context.HttpClient.Value, context.Retry, cancellationToken) + .ConfigureAwait(false); } } diff --git a/ASFFreeGames/FreeGames/Strategies/RedlibListFreeGamesStrategy.cs b/ASFFreeGames/FreeGames/Strategies/RedlibListFreeGamesStrategy.cs index 9538c5c..7711f51 100644 --- a/ASFFreeGames/FreeGames/Strategies/RedlibListFreeGamesStrategy.cs +++ b/ASFFreeGames/FreeGames/Strategies/RedlibListFreeGamesStrategy.cs @@ -19,18 +19,28 @@ namespace Maxisoft.ASF.FreeGames.Strategies; [SuppressMessage("ReSharper", "RedundantNullableFlowAttribute")] public sealed class RedlibListFreeGamesStrategy : IListFreeGamesStrategy { private readonly SemaphoreSlim DownloadSemaphore = new(4, 4); - private readonly CachedRedlibInstanceListStorage InstanceListCache = new(Array.Empty(), DateTimeOffset.MinValue); + private readonly CachedRedlibInstanceListStorage InstanceListCache = new( + Array.Empty(), + DateTimeOffset.MinValue + ); public void Dispose() => DownloadSemaphore.Dispose(); - public async Task> GetGames([NotNull] ListFreeGamesContext context, CancellationToken cancellationToken) { + public async Task> GetGames( + [NotNull] ListFreeGamesContext context, + CancellationToken cancellationToken + ) { cancellationToken.ThrowIfCancellationRequested(); CachedRedlibInstanceList instanceList = new(context.Options, InstanceListCache); - List instances = await instanceList.ListInstances(context.HttpClientFactory.CreateForGithub(), cancellationToken).ConfigureAwait(false); + List instances = await instanceList + .ListInstances(context.HttpClientFactory.CreateForGithub(), cancellationToken) + .ConfigureAwait(false); instances = Shuffle(instances); - using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken + ); cts.CancelAfter(60_000); LinkedList>> tasks = []; @@ -38,11 +48,14 @@ public async Task> GetGames([NotNull] ListF try { foreach (Uri uri in instances) { - tasks.AddLast(DownloadUsingInstance(context.HttpClient.Value, uri, context.Retry, cts.Token)); + tasks.AddLast( + DownloadUsingInstance(context.HttpClient.Value, uri, context.Retry, cts.Token) + ); } allTasks = tasks.ToArray(); - IReadOnlyCollection result = await MonitorDownloads(tasks, cts.Token).ConfigureAwait(false); + IReadOnlyCollection result = await MonitorDownloads(tasks, cts.Token) + .ConfigureAwait(false); if (result.Count > 0) { return result; @@ -64,7 +77,11 @@ public async Task> GetGames([NotNull] ListF } List exceptions = new(allTasks.Length); - exceptions.AddRange(from task in allTasks where task.IsCanceled || task.IsFaulted select IListFreeGamesStrategy.ExceptionFromTask(task)); + exceptions.AddRange( + from task in allTasks + where task.IsCanceled || task.IsFaulted + select IListFreeGamesStrategy.ExceptionFromTask(task) + ); switch (exceptions.Count) { case 1: @@ -112,7 +129,11 @@ public async Task> GetGames([NotNull] ListF return null; } - private async Task> DoDownloadUsingInstance(SimpleHttpClient client, Uri uri, CancellationToken cancellationToken) { + private async Task> DoDownloadUsingInstance( + SimpleHttpClient client, + Uri uri, + CancellationToken cancellationToken + ) { await DownloadSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); string content; DateTimeOffset date = default; @@ -120,20 +141,24 @@ private async Task> DoDownloadUsingInstance try { #pragma warning disable CAC001 #pragma warning disable CA2007 - await using HttpStreamResponse resp = await client.GetStreamAsync(uri, cancellationToken: cancellationToken).ConfigureAwait(false); + await using HttpStreamResponse resp = await client + .GetStreamAsync(uri, cancellationToken: cancellationToken) + .ConfigureAwait(false); #pragma warning restore CA2007 #pragma warning restore CAC001 if (!resp.HasValidStream) { throw new HttpRequestRedlibException("invalid stream for " + uri) { Uri = uri, - StatusCode = resp.StatusCode + StatusCode = resp.StatusCode, }; } else if (!resp.StatusCode.IsSuccessCode()) { - throw new HttpRequestRedlibException($"invalid status code {resp.StatusCode} for {uri}") { + throw new HttpRequestRedlibException( + $"invalid status code {resp.StatusCode} for {uri}" + ) { Uri = uri, - StatusCode = resp.StatusCode + StatusCode = resp.StatusCode, }; } else { @@ -165,12 +190,21 @@ private async Task> DoDownloadUsingInstance return redditGameEntries; } - private async Task> DownloadUsingInstance(SimpleHttpClient client, Uri uri, uint retry, CancellationToken cancellationToken) { - Uri fullUrl = new($"{uri.ToString().TrimEnd('/')}/user/{RedditHelper.User}?sort=new", UriKind.Absolute); + private async Task> DownloadUsingInstance( + SimpleHttpClient client, + Uri uri, + uint retry, + CancellationToken cancellationToken + ) { + Uri fullUrl = new( + $"{uri.ToString().TrimEnd('/')}/user/{RedditHelper.User}?sort=new", + UriKind.Absolute + ); for (int t = 0; t < retry; t++) { try { - return await DoDownloadUsingInstance(client, fullUrl, cancellationToken).ConfigureAwait(false); + return await DoDownloadUsingInstance(client, fullUrl, cancellationToken) + .ConfigureAwait(false); } catch (Exception) { if ((t == retry - 1) || cancellationToken.IsCancellationRequested) { @@ -186,7 +220,10 @@ private async Task> DownloadUsingInstance(S throw new InvalidOperationException("This should never happen"); } - private static async Task> MonitorDownloads(LinkedList>> tasks, CancellationToken cancellationToken) { + private static async Task> MonitorDownloads( + LinkedList>> tasks, + CancellationToken cancellationToken + ) { while (tasks.Count > 0) { cancellationToken.ThrowIfCancellationRequested(); @@ -231,7 +268,8 @@ private static async Task> MonitorDownloads /// /// The list of URIs to shuffle. /// A shuffled list of URIs. - private static List Shuffle(TCollection list) where TCollection : ICollection { + private static List Shuffle(TCollection list) + where TCollection : ICollection { List<(Guid, Uri)> randomized = new(list.Count); randomized.AddRange(list.Select(static uri => (Guid.NewGuid(), uri))); diff --git a/ASFFreeGames/Github/GithubPluginUpdater.cs b/ASFFreeGames/Github/GithubPluginUpdater.cs index 6697919..a5e5184 100644 --- a/ASFFreeGames/Github/GithubPluginUpdater.cs +++ b/ASFFreeGames/Github/GithubPluginUpdater.cs @@ -19,7 +19,9 @@ private static void LogGenericError(string message) { return; } - ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericError($"{nameof(GithubPluginUpdater)}: {message}"); + ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericError( + $"{nameof(GithubPluginUpdater)}: {message}" + ); } private static void LogGenericDebug(string message) { @@ -27,10 +29,18 @@ private static void LogGenericDebug(string message) { return; } - ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericDebug($"{nameof(GithubPluginUpdater)}: {message}"); + ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericDebug( + $"{nameof(GithubPluginUpdater)}: {message}" + ); } - public async Task GetTargetReleaseURL(Version asfVersion, string asfVariant, bool asfUpdate, bool stable, bool forced) { + public async Task GetTargetReleaseURL( + Version asfVersion, + string asfVariant, + bool asfUpdate, + bool stable, + bool forced + ) { ArgumentNullException.ThrowIfNull(asfVersion); ArgumentException.ThrowIfNullOrEmpty(asfVariant); @@ -46,7 +56,9 @@ private static void LogGenericDebug(string message) { return null; } - ReleaseResponse? releaseResponse = await GitHubService.GetLatestRelease(RepositoryName).ConfigureAwait(false); + ReleaseResponse? releaseResponse = await GitHubService + .GetLatestRelease(RepositoryName) + .ConfigureAwait(false); if (releaseResponse == null) { LogGenericError("GetLatestRelease returned null"); @@ -60,7 +72,10 @@ private static void LogGenericDebug(string message) { return null; } - if (stable && ((releaseResponse.PublishedAt - DateTime.UtcNow).Duration() < TimeSpan.FromHours(3))) { + if ( + stable + && ((releaseResponse.PublishedAt - DateTime.UtcNow).Duration() < TimeSpan.FromHours(3)) + ) { LogGenericDebug("GetLatestRelease returned too recent"); return null; @@ -70,7 +85,15 @@ private static void LogGenericDebug(string message) { if (!forced && (CurrentVersion >= newVersion)) { // Allow same version to be re-updated when we're updating ASF release and more than one asset is found - potential compatibility difference - if ((CurrentVersion > newVersion) || !asfUpdate || (releaseResponse.Assets.Count(static asset => asset.Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) < 2)) { + if ( + (CurrentVersion > newVersion) + || !asfUpdate + || ( + releaseResponse.Assets.Count(static asset => + asset.Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) + ) < 2 + ) + ) { return null; } } @@ -81,7 +104,10 @@ private static void LogGenericDebug(string message) { return null; } - ReleaseAsset? asset = releaseResponse.Assets.FirstOrDefault(static asset => asset.Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) && (asset.Size > (1 << 18))); + ReleaseAsset? asset = releaseResponse.Assets.FirstOrDefault(static asset => + asset.Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) + && (asset.Size > (1 << 18)) + ); if ((asset == null) || !releaseResponse.Assets.Contains(asset)) { LogGenericError($"GetLatestRelease for version {newVersion} returned no valid assets"); @@ -89,7 +115,9 @@ private static void LogGenericDebug(string message) { return null; } - LogGenericDebug($"GetLatestRelease for version {newVersion} returned asset {asset.Name} with url {asset.DownloadURL}"); + LogGenericDebug( + $"GetLatestRelease for version {newVersion} returned asset {asset.Name} with url {asset.DownloadURL}" + ); return asset.DownloadURL; } diff --git a/ASFFreeGames/HttpClientSimple/SimpleHttpClient.cs b/ASFFreeGames/HttpClientSimple/SimpleHttpClient.cs index b1a0436..d18822c 100644 --- a/ASFFreeGames/HttpClientSimple/SimpleHttpClient.cs +++ b/ASFFreeGames/HttpClientSimple/SimpleHttpClient.cs @@ -20,9 +20,22 @@ public sealed class SimpleHttpClient : IDisposable { public SimpleHttpClient(IWebProxy? proxy = null, long timeout = 25_000) { SocketsHttpHandler handler = new(); - SetPropertyWithLogging(handler, nameof(SocketsHttpHandler.AutomaticDecompression), DecompressionMethods.All); - SetPropertyWithLogging(handler, nameof(SocketsHttpHandler.MaxConnectionsPerServer), 5, debugLogLevel: true); - SetPropertyWithLogging(handler, nameof(SocketsHttpHandler.EnableMultipleHttp2Connections), true); + SetPropertyWithLogging( + handler, + nameof(SocketsHttpHandler.AutomaticDecompression), + DecompressionMethods.All + ); + SetPropertyWithLogging( + handler, + nameof(SocketsHttpHandler.MaxConnectionsPerServer), + 5, + debugLogLevel: true + ); + SetPropertyWithLogging( + handler, + nameof(SocketsHttpHandler.EnableMultipleHttp2Connections), + true + ); if (proxy is not null) { SetPropertyWithLogging(handler, nameof(SocketsHttpHandler.Proxy), proxy); @@ -37,20 +50,39 @@ public SimpleHttpClient(IWebProxy? proxy = null, long timeout = 25_000) { #pragma warning disable CA5399 HttpClient = new HttpClient(handler, false); #pragma warning restore CA5399 - SetPropertyWithLogging(HttpClient, nameof(HttpClient.DefaultRequestVersion), HttpVersion.Version30); - SetPropertyWithLogging(HttpClient, nameof(HttpClient.Timeout), TimeSpan.FromMilliseconds(timeout)); + SetPropertyWithLogging( + HttpClient, + nameof(HttpClient.DefaultRequestVersion), + HttpVersion.Version30 + ); + SetPropertyWithLogging( + HttpClient, + nameof(HttpClient.Timeout), + TimeSpan.FromMilliseconds(timeout) + ); SetExpectContinueProperty(HttpClient, false); - HttpClient.DefaultRequestHeaders.Add("User-Agent", "Lynx/2.8.8dev.9 libwww-FM/2.14 SSL-MM/1.4.1 GNUTLS/2.12.14"); + HttpClient.DefaultRequestHeaders.Add( + "User-Agent", + "Lynx/2.8.8dev.9 libwww-FM/2.14 SSL-MM/1.4.1 GNUTLS/2.12.14" + ); HttpClient.DefaultRequestHeaders.Add("DNT", "1"); HttpClient.DefaultRequestHeaders.Add("Sec-GPC", "1"); - HttpClient.DefaultRequestHeaders.AcceptLanguage.Add(new StringWithQualityHeaderValue("en-US")); - HttpClient.DefaultRequestHeaders.AcceptLanguage.Add(new StringWithQualityHeaderValue("en", 0.8)); + HttpClient.DefaultRequestHeaders.AcceptLanguage.Add( + new StringWithQualityHeaderValue("en-US") + ); + HttpClient.DefaultRequestHeaders.AcceptLanguage.Add( + new StringWithQualityHeaderValue("en", 0.8) + ); } - public async Task GetStreamAsync(Uri uri, IEnumerable>? additionalHeaders = null, CancellationToken cancellationToken = default) { + public async Task GetStreamAsync( + Uri uri, + IEnumerable>? additionalHeaders = null, + CancellationToken cancellationToken = default + ) { using HttpRequestMessage request = new(HttpMethod.Get, uri); request.Version = HttpClient.DefaultRequestVersion; @@ -61,11 +93,15 @@ public async Task GetStreamAsync(Uri uri, IEnumerable(T targetObject, string propertyName, object value) where T : class { + private static bool TrySetPropertyValue(T targetObject, string propertyName, object value) + where T : class { try { // Get the type of the target object Type targetType = targetObject.GetType(); // Get the property information - PropertyInfo? propertyInfo = targetType.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance); + PropertyInfo? propertyInfo = targetType.GetProperty( + propertyName, + BindingFlags.Public | BindingFlags.Instance + ); if ((propertyInfo is not null) && propertyInfo.CanWrite) { // Set the property value @@ -135,7 +201,13 @@ private static bool TrySetPropertyValue(T targetObject, string propertyName, return false; } - private static void SetPropertyWithLogging(T targetObject, string propertyName, object value, bool debugLogLevel = false) where T : class { + private static void SetPropertyWithLogging( + T targetObject, + string propertyName, + object value, + bool debugLogLevel = false + ) + where T : class { try { if (TrySetPropertyValue(targetObject, propertyName, value)) { return; @@ -145,7 +217,8 @@ private static void SetPropertyWithLogging(T targetObject, string propertyNam // ignored } - string logMessage = $"Failed to set {targetObject.GetType().Name} property {propertyName} to {value}. Please report this issue to github."; + string logMessage = + $"Failed to set {targetObject.GetType().Name} property {propertyName} to {value}. Please report this issue to github."; if (debugLogLevel) { ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericDebug(logMessage); @@ -157,11 +230,14 @@ private static void SetPropertyWithLogging(T targetObject, string propertyNam #endregion } -public sealed class HttpStreamResponse(HttpResponseMessage response, Stream? stream) : IAsyncDisposable { +public sealed class HttpStreamResponse(HttpResponseMessage response, Stream? stream) + : IAsyncDisposable { public HttpResponseMessage Response { get; } = response; public Stream Stream { get; } = stream ?? EmptyStreamLazy.Value; - public bool HasValidStream => stream is not null && (!EmptyStreamLazy.IsValueCreated || !ReferenceEquals(EmptyStreamLazy.Value, Stream)); + public bool HasValidStream => + stream is not null + && (!EmptyStreamLazy.IsValueCreated || !ReferenceEquals(EmptyStreamLazy.Value, Stream)); public async Task ReadAsStringAsync(CancellationToken cancellationToken) { using StreamReader reader = new(Stream); // assume the encoding is UTF8, cannot be specified as per issue #91 @@ -177,5 +253,7 @@ public async ValueTask DisposeAsync() { await task.ConfigureAwait(false); } - private static readonly Lazy EmptyStreamLazy = new(static () => new MemoryStream([], false)); + private static readonly Lazy EmptyStreamLazy = new( + static () => new MemoryStream([], false) + ); } diff --git a/ASFFreeGames/HttpClientSimple/SimpleHttpClientFactory.cs b/ASFFreeGames/HttpClientSimple/SimpleHttpClientFactory.cs index cf18428..fce58dc 100644 --- a/ASFFreeGames/HttpClientSimple/SimpleHttpClientFactory.cs +++ b/ASFFreeGames/HttpClientSimple/SimpleHttpClientFactory.cs @@ -8,7 +8,10 @@ namespace Maxisoft.ASF.HttpClientSimple; public sealed class SimpleHttpClientFactory(ASFFreeGamesOptions options) : IDisposable { - private readonly HashSet DisableProxyStrings = new(StringComparer.InvariantCultureIgnoreCase) { + private readonly HashSet DisableProxyStrings = new( + StringComparer.InvariantCultureIgnoreCase + ) + { "no", "0", "false", @@ -18,7 +21,7 @@ public sealed class SimpleHttpClientFactory(ASFFreeGamesOptions options) : IDisp "null", "off", "noproxy", - "no-proxy" + "no-proxy", }; private readonly Dictionary> Cache = new(); @@ -27,7 +30,7 @@ private enum ECacheKey { Generic, Reddit, Redlib, - Github + Github, } private SimpleHttpClient CreateFor(ECacheKey key, string? proxy = null) { @@ -43,7 +46,10 @@ private SimpleHttpClient CreateFor(ECacheKey key, string? proxy = null) { else if (!string.IsNullOrWhiteSpace(proxy)) { webProxy = new WebProxy(proxy, BypassOnLocal: true); - if (Uri.TryCreate(proxy, UriKind.Absolute, out Uri? uri) && !string.IsNullOrWhiteSpace(uri.UserInfo)) { + if ( + Uri.TryCreate(proxy, UriKind.Absolute, out Uri? uri) + && !string.IsNullOrWhiteSpace(uri.UserInfo) + ) { string[] split = uri.UserInfo.Split(':'); if (split.Length == 2) { @@ -66,7 +72,10 @@ private SimpleHttpClient CreateFor(ECacheKey key, string? proxy = null) { } #pragma warning disable CA2000 - Tuple tuple = new(webProxy, new SimpleHttpClient(webProxy)); + Tuple tuple = new( + webProxy, + new SimpleHttpClient(webProxy) + ); #pragma warning restore CA2000 Cache.Add(key, tuple); @@ -74,8 +83,12 @@ private SimpleHttpClient CreateFor(ECacheKey key, string? proxy = null) { } } - public SimpleHttpClient CreateForReddit() => CreateFor(ECacheKey.Reddit, options.RedditProxy ?? options.Proxy); - public SimpleHttpClient CreateForRedlib() => CreateFor(ECacheKey.Redlib, options.RedlibProxy ?? options.RedditProxy ?? options.Proxy); + public SimpleHttpClient CreateForReddit() => + CreateFor(ECacheKey.Reddit, options.RedditProxy ?? options.Proxy); + + public SimpleHttpClient CreateForRedlib() => + CreateFor(ECacheKey.Redlib, options.RedlibProxy ?? options.RedditProxy ?? options.Proxy); + public SimpleHttpClient CreateForGithub() => CreateFor(ECacheKey.Github, options.Proxy); public SimpleHttpClient CreateGeneric() => CreateFor(ECacheKey.Generic, options.Proxy); diff --git a/ASFFreeGames/Maxisoft.Utils/BitSpan.Helpers.cs b/ASFFreeGames/Maxisoft.Utils/BitSpan.Helpers.cs index be68330..e2de23a 100644 --- a/ASFFreeGames/Maxisoft.Utils/BitSpan.Helpers.cs +++ b/ASFFreeGames/Maxisoft.Utils/BitSpan.Helpers.cs @@ -3,71 +3,61 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; -namespace Maxisoft.Utils.Collections.Spans -{ - [SuppressMessage("Design", "CA1034")] - public ref partial struct BitSpan - { - public static int ComputeLongArraySize(int numBits) - { - var n = numBits / LongNumBit; - if (numBits % LongNumBit != 0) - { - n += 1; - } - - return n; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static BitSpan Zeros(int numBits) - { - return new BitSpan(new long[ComputeLongArraySize(numBits)]); - } - - public static BitSpan CreateFromBuffer(Span buff) where TSpan : unmanaged - { - var castedSpan = MemoryMarshal.Cast(buff); - return new BitSpan(castedSpan); - } - - public ref struct Enumerator - { - /// The span being enumerated. - private readonly BitSpan _bitSpan; - - /// The next index to yield. - private int _index; - - /// Initialize the enumerator. - /// The dict to enumerate. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal Enumerator(BitSpan bitSpan) - { - _bitSpan = bitSpan; - _index = -1; - } - - /// Advances the enumerator to the next element of the dict. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool MoveNext() - { - var index = _index + 1; - if (index >= _bitSpan.Count) - { - return false; - } - - _index = index; - return true; - } - - /// Gets the element at the current position of the enumerator. - public bool Current - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => _bitSpan.Get(_index); - } - } - } +namespace Maxisoft.Utils.Collections.Spans { + [SuppressMessage("Design", "CA1034")] + public ref partial struct BitSpan { + public static int ComputeLongArraySize(int numBits) { + var n = numBits / LongNumBit; + if (numBits % LongNumBit != 0) { + n += 1; + } + + return n; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static BitSpan Zeros(int numBits) { + return new BitSpan(new long[ComputeLongArraySize(numBits)]); + } + + public static BitSpan CreateFromBuffer(Span buff) + where TSpan : unmanaged { + var castedSpan = MemoryMarshal.Cast(buff); + return new BitSpan(castedSpan); + } + + public ref struct Enumerator { + /// The span being enumerated. + private readonly BitSpan _bitSpan; + + /// The next index to yield. + private int _index; + + /// Initialize the enumerator. + /// The dict to enumerate. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal Enumerator(BitSpan bitSpan) { + _bitSpan = bitSpan; + _index = -1; + } + + /// Advances the enumerator to the next element of the dict. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool MoveNext() { + var index = _index + 1; + if (index >= _bitSpan.Count) { + return false; + } + + _index = index; + return true; + } + + /// Gets the element at the current position of the enumerator. + public bool Current { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => _bitSpan.Get(_index); + } + } + } } diff --git a/ASFFreeGames/Maxisoft.Utils/OrderedDictionary.cs b/ASFFreeGames/Maxisoft.Utils/OrderedDictionary.cs index 1570079..0b46759 100644 --- a/ASFFreeGames/Maxisoft.Utils/OrderedDictionary.cs +++ b/ASFFreeGames/Maxisoft.Utils/OrderedDictionary.cs @@ -22,11 +22,14 @@ namespace Maxisoft.Utils.Collections.Dictionaries { /// :TValue. /// /// - public abstract class OrderedDictionary : IOrderedDictionary - where TList : class, IList, new() where TDictionary : class, IDictionary, new() { + public abstract class OrderedDictionary + : IOrderedDictionary + where TList : class, IList, new() + where TDictionary : class, IDictionary, new() { protected OrderedDictionary() { } - protected internal OrderedDictionary(in TDictionary initial) : this(in initial, []) { } + protected internal OrderedDictionary(in TDictionary initial) + : this(in initial, []) { } protected internal OrderedDictionary(in TDictionary initial, in TList list) { Dictionary = initial; @@ -68,17 +71,26 @@ public void Clear() { Dictionary.Clear(); } - public bool Contains(KeyValuePair item) => Contains(in item, EqualityComparer.Default); + public bool Contains(KeyValuePair item) => + Contains(in item, EqualityComparer.Default); public void CopyTo(KeyValuePair[] array, int arrayIndex) { #pragma warning disable CA1062 if ((arrayIndex < 0) || (arrayIndex > array.Length)) { #pragma warning restore CA1062 - throw new ArgumentOutOfRangeException(nameof(arrayIndex), arrayIndex, "Out of bounds"); + throw new ArgumentOutOfRangeException( + nameof(arrayIndex), + arrayIndex, + "Out of bounds" + ); } if (array.Length - arrayIndex < Indexes.Count) { - throw new ArgumentOutOfRangeException(nameof(arrayIndex), arrayIndex, "Out of bounds"); + throw new ArgumentOutOfRangeException( + nameof(arrayIndex), + arrayIndex, + "Out of bounds" + ); } int c = 0; @@ -125,7 +137,8 @@ public bool Remove(TKey key) { } #pragma warning disable CS8601 // Possible null reference assignment. - public bool TryGetValue(TKey key, out TValue value) => Dictionary.TryGetValue(key, out value); + public bool TryGetValue(TKey key, out TValue value) => + Dictionary.TryGetValue(key, out value); #pragma warning restore CS8601 // Possible null reference assignment. public TValue this[TKey key] { @@ -133,9 +146,11 @@ public TValue this[TKey key] { set => DoAdd(key, value, true); } - public ICollection Keys => new KeyCollection>(this); + public ICollection Keys => + new KeyCollection>(this); - public ICollection Values => new ValuesCollection>(this); + public ICollection Values => + new ValuesCollection>(this); public TValue this[int index] { get => At(index).Value; @@ -174,9 +189,13 @@ public void RemoveAt(int index) { public int IndexOf(in TValue value) => IndexOf(in value, EqualityComparer.Default); - public bool Contains(in KeyValuePair item, TEqualityComparer comparer) + public bool Contains( + in KeyValuePair item, + TEqualityComparer comparer + ) where TEqualityComparer : IEqualityComparer => - Dictionary.TryGetValue(item.Key, out TValue? value) && comparer.Equals(value, item.Value); + Dictionary.TryGetValue(item.Key, out TValue? value) + && comparer.Equals(value, item.Value); public int IndexOf(in TValue value, TEqualityComparer comparer) where TEqualityComparer : IEqualityComparer { @@ -264,7 +283,10 @@ public TKey UpdateAt(int index, in TValue value) { public void Swap(int firstIndex, int secondIndex) { CheckForOutOfBounds(firstIndex); CheckForOutOfBounds(secondIndex); - (Indexes[secondIndex], Indexes[firstIndex]) = (Indexes[firstIndex], Indexes[secondIndex]); + (Indexes[secondIndex], Indexes[firstIndex]) = ( + Indexes[firstIndex], + Indexes[secondIndex] + ); } /// @@ -354,11 +376,19 @@ public void CopyTo(TKey[] array, int arrayIndex) { #pragma warning disable CA1062 if ((arrayIndex < 0) || (arrayIndex > array.Length)) { #pragma warning restore CA1062 - throw new ArgumentOutOfRangeException(nameof(arrayIndex), arrayIndex, "Out of bounds"); + throw new ArgumentOutOfRangeException( + nameof(arrayIndex), + arrayIndex, + "Out of bounds" + ); } if (array.Length - arrayIndex < Dictionary.Count) { - throw new ArgumentOutOfRangeException(nameof(arrayIndex), arrayIndex, "Out of bounds"); + throw new ArgumentOutOfRangeException( + nameof(arrayIndex), + arrayIndex, + "Out of bounds" + ); } Dictionary.Indexes.CopyTo(array, arrayIndex); @@ -373,7 +403,7 @@ public void CopyTo(TKey[] array, int arrayIndex) { protected class ValuesCollection : ICollection where TDict : OrderedDictionary { - protected private readonly TDict Dictionary; + private protected readonly TDict Dictionary; protected internal ValuesCollection(TDict dictionary) => Dictionary = dictionary; @@ -396,11 +426,19 @@ public void CopyTo(TValue[] array, int arrayIndex) { #pragma warning disable CA1062 if ((arrayIndex < 0) || (arrayIndex > array.Length)) { #pragma warning restore CA1062 - throw new ArgumentOutOfRangeException(nameof(arrayIndex), arrayIndex, "Out of bounds"); + throw new ArgumentOutOfRangeException( + nameof(arrayIndex), + arrayIndex, + "Out of bounds" + ); } if (array.Length - arrayIndex < Dictionary.Count) { - throw new ArgumentOutOfRangeException(nameof(arrayIndex), arrayIndex, "Out of bounds"); + throw new ArgumentOutOfRangeException( + nameof(arrayIndex), + arrayIndex, + "Out of bounds" + ); } int c = 0; @@ -421,17 +459,18 @@ public void CopyTo(TValue[] array, int arrayIndex) { } } - public class - OrderedDictionary : OrderedDictionary, Dictionary> where TKey : notnull { + public class OrderedDictionary + : OrderedDictionary, Dictionary> + where TKey : notnull { public OrderedDictionary() { } - public OrderedDictionary(int capacity) : base( - new Dictionary(capacity), - new List(capacity) - ) { } + public OrderedDictionary(int capacity) + : base(new Dictionary(capacity), new List(capacity)) { } - public OrderedDictionary(IEqualityComparer comparer) : base(new Dictionary(comparer)) { } + public OrderedDictionary(IEqualityComparer comparer) + : base(new Dictionary(comparer)) { } - public OrderedDictionary(int capacity, IEqualityComparer comparer) : base(new Dictionary(capacity, comparer), new List(capacity)) { } + public OrderedDictionary(int capacity, IEqualityComparer comparer) + : base(new Dictionary(capacity, comparer), new List(capacity)) { } } } diff --git a/ASFFreeGames/Maxisoft.Utils/SpanDict.Helpers.cs b/ASFFreeGames/Maxisoft.Utils/SpanDict.Helpers.cs index 3ce6197..3264ad3 100644 --- a/ASFFreeGames/Maxisoft.Utils/SpanDict.Helpers.cs +++ b/ASFFreeGames/Maxisoft.Utils/SpanDict.Helpers.cs @@ -4,7 +4,8 @@ using System.Runtime.CompilerServices; namespace Maxisoft.Utils.Collections.Spans { - public ref partial struct SpanDict where TKey : notnull { + public ref partial struct SpanDict + where TKey : notnull { public readonly KeyValuePair[] ToArray() { var array = new KeyValuePair[Count]; CopyTo(array, 0); @@ -12,7 +13,8 @@ public readonly KeyValuePair[] ToArray() { return array; } - public readonly TDictionary ToDictionary() where TDictionary : IDictionary, new() { + public readonly TDictionary ToDictionary() + where TDictionary : IDictionary, new() { var dict = new TDictionary(); foreach (var pair in this) { diff --git a/ASFFreeGames/Maxisoft.Utils/SpanDict.cs b/ASFFreeGames/Maxisoft.Utils/SpanDict.cs index 9dfe715..75f5cbe 100644 --- a/ASFFreeGames/Maxisoft.Utils/SpanDict.cs +++ b/ASFFreeGames/Maxisoft.Utils/SpanDict.cs @@ -16,15 +16,18 @@ namespace Maxisoft.Utils.Collections.Spans { [DebuggerDisplay("Count = {Count}, Capacity = {Capacity}")] [DebuggerTypeProxy(typeof(SpanDict<,>.DebuggerTypeProxyImpl))] [SuppressMessage("Design", "CA1051")] - public ref partial struct SpanDict where TKey : notnull { + public ref partial struct SpanDict + where TKey : notnull { public readonly Span> Buckets; internal BitSpan Mask; public readonly IEqualityComparer Comparer; - public SpanDict(int capacity, IEqualityComparer? comparer = null) : this(new KeyValuePair[capacity], comparer) { } + public SpanDict(int capacity, IEqualityComparer? comparer = null) + : this(new KeyValuePair[capacity], comparer) { } public SpanDict( - Span> buckets, BitSpan mask, + Span> buckets, + BitSpan mask, IEqualityComparer? comparer = null ) { if (mask.Count < buckets.Length) { @@ -42,7 +45,11 @@ public SpanDict( /// /// /// This constructor Allocate an array in order to store the - public SpanDict(Span> buckets, IEqualityComparer? comparer = null) : this(buckets, BitSpan.Zeros(buckets.Length), comparer) { } + public SpanDict( + Span> buckets, + IEqualityComparer? comparer = null + ) + : this(buckets, BitSpan.Zeros(buckets.Length), comparer) { } public readonly Enumerator GetEnumerator() { return new Enumerator(this); @@ -64,11 +71,19 @@ public readonly bool Contains(in KeyValuePair item) { public readonly void CopyTo([NotNull] KeyValuePair[] array, int arrayIndex) { if ((arrayIndex < 0) || (arrayIndex > array.Length)) { - throw new ArgumentOutOfRangeException(nameof(arrayIndex), arrayIndex, "Out of bounds"); + throw new ArgumentOutOfRangeException( + nameof(arrayIndex), + arrayIndex, + "Out of bounds" + ); } if (array.Length - arrayIndex < Count) { - throw new ArgumentOutOfRangeException(nameof(arrayIndex), arrayIndex, "Out of bounds"); + throw new ArgumentOutOfRangeException( + nameof(arrayIndex), + arrayIndex, + "Out of bounds" + ); } Span> span = array; @@ -204,7 +219,9 @@ private bool RemoveAt(int index) { Mask.Set(index, true); Mask.Set(forward, false); - if (RuntimeHelpers.IsReferenceOrContainsReferences>()) { + if ( + RuntimeHelpers.IsReferenceOrContainsReferences>() + ) { Buckets[forward] = default; } @@ -214,7 +231,10 @@ private bool RemoveAt(int index) { forward = (forward + 1) % Capacity; } while (c++ < limit); - if (RuntimeHelpers.IsReferenceOrContainsReferences>() && !Mask[originalIndex]) { + if ( + RuntimeHelpers.IsReferenceOrContainsReferences>() + && !Mask[originalIndex] + ) { Buckets[originalIndex] = default; } @@ -263,9 +283,13 @@ public TValue this[in TKey key] { [SuppressMessage("Design", "CA1000")] public static SpanDict CreateFromBuffers( - Span buckets, Span mask, - IEqualityComparer? comparer = null, int count = -1 - ) where TBucket : struct where TMask : struct { + Span buckets, + Span mask, + IEqualityComparer? comparer = null, + int count = -1 + ) + where TBucket : struct + where TMask : struct { var cBucket = MemoryMarshal.Cast>(buckets); var cMask = MemoryMarshal.Cast(mask); var res = new SpanDict(cBucket, cMask, comparer); @@ -298,8 +322,10 @@ public static SpanDict CreateFromBuffers( [SuppressMessage("Design", "CA1000")] public static SpanDict CreateFromBuffer( Span buff, - IEqualityComparer? comparer = null, int count = -1 - ) where TSpan : unmanaged { + IEqualityComparer? comparer = null, + int count = -1 + ) + where TSpan : unmanaged { unsafe { while ((buff.Length > 0) && ((buff.Length * sizeof(TSpan)) % sizeof(long) != 0)) { buff = buff.Slice(0, buff.Length - 1); @@ -310,7 +336,12 @@ public static SpanDict CreateFromBuffer( var reserved = BitSpan.ComputeLongArraySize(kvSpan.Length); var longSpan = MemoryMarshal.Cast(buff); - return CreateFromBuffers(longSpan.Slice(reserved), longSpan.Slice(0, reserved), comparer, count); + return CreateFromBuffers( + longSpan.Slice(reserved), + longSpan.Slice(0, reserved), + comparer, + count + ); } } } diff --git a/ASFFreeGames/Maxisoft.Utils/SpanExtensions.cs b/ASFFreeGames/Maxisoft.Utils/SpanExtensions.cs index 6cea4d6..de551f3 100644 --- a/ASFFreeGames/Maxisoft.Utils/SpanExtensions.cs +++ b/ASFFreeGames/Maxisoft.Utils/SpanExtensions.cs @@ -4,52 +4,48 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; -namespace Maxisoft.Utils.Collections.Spans -{ - public static class SpanExtensions - { - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int AddSorted(this SpanList list, in T item, IComparer? comparer = null) - { - if ((uint) list.Count >= (uint) list.Capacity) - { - throw new InvalidOperationException("span is full"); - } +namespace Maxisoft.Utils.Collections.Spans { + public static class SpanExtensions { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int AddSorted( + this SpanList list, + in T item, + IComparer? comparer = null + ) { + if ((uint) list.Count >= (uint) list.Capacity) { + throw new InvalidOperationException("span is full"); + } - var index = list.BinarySearch(in item, comparer); - var res = index < 0 ? ~index : index; - list.Insert(res, in item); - Debug.Assert((comparer ?? Comparer.Default).Compare(item, list[res]) == 0); - return res; - } - - public static Span ASpan(this SpanList list) - where TIn : struct where TOut : struct - { - return MemoryMarshal.Cast(list.AsSpan()); - } + var index = list.BinarySearch(in item, comparer); + var res = index < 0 ? ~index : index; + list.Insert(res, in item); + Debug.Assert((comparer ?? Comparer.Default).Compare(item, list[res]) == 0); + return res; + } - public static SpanList Cast(this SpanList list) - where TIn : unmanaged where TOut : unmanaged - { - var span = MemoryMarshal.Cast(list.Span); - int count; - unsafe - { - if (sizeof(TIn) > sizeof(TOut)) - { - Debug.Assert(sizeof(TIn) % sizeof(TOut) == 0); - count = list.Count * (sizeof(TIn) / sizeof(TOut)); - } - else - { - Debug.Assert(sizeof(TOut) % sizeof(TIn) == 0); - count = list.Count / (sizeof(TOut) / sizeof(TIn)); - } - } + public static Span ASpan(this SpanList list) + where TIn : struct + where TOut : struct { + return MemoryMarshal.Cast(list.AsSpan()); + } - return new SpanList(span, count); - } - } -} \ No newline at end of file + public static SpanList Cast(this SpanList list) + where TIn : unmanaged + where TOut : unmanaged { + var span = MemoryMarshal.Cast(list.Span); + int count; + unsafe { + if (sizeof(TIn) > sizeof(TOut)) { + Debug.Assert(sizeof(TIn) % sizeof(TOut) == 0); + count = list.Count * (sizeof(TIn) / sizeof(TOut)); + } + else { + Debug.Assert(sizeof(TOut) % sizeof(TIn) == 0); + count = list.Count / (sizeof(TOut) / sizeof(TIn)); + } + } + + return new SpanList(span, count); + } + } +} diff --git a/ASFFreeGames/Maxisoft.Utils/WrappedIndex.cs b/ASFFreeGames/Maxisoft.Utils/WrappedIndex.cs index b66f396..c978672 100644 --- a/ASFFreeGames/Maxisoft.Utils/WrappedIndex.cs +++ b/ASFFreeGames/Maxisoft.Utils/WrappedIndex.cs @@ -31,7 +31,8 @@ public int Resolve(int size) { } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public int Resolve([NotNull] in ICollection collection) where TCollection : ICollection { + public int Resolve([NotNull] in ICollection collection) + where TCollection : ICollection { return Resolve(collection.Count); } @@ -41,7 +42,8 @@ public int Resolve(in ICollection collection) { } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public int Resolve(in TCollection collection) where TCollection : ICollection { + public int Resolve(in TCollection collection) + where TCollection : ICollection { return Resolve(collection.Count); } diff --git a/ASFFreeGames/PluginContext.cs b/ASFFreeGames/PluginContext.cs index 150adfa..8996631 100644 --- a/ASFFreeGames/PluginContext.cs +++ b/ASFFreeGames/PluginContext.cs @@ -7,13 +7,20 @@ namespace Maxisoft.ASF; -internal sealed record PluginContext(IReadOnlyCollection Bots, IContextRegistry BotContexts, ASFFreeGamesOptions Options, LoggerFilter LoggerFilter, bool Valid = false) { +internal sealed record PluginContext( + IReadOnlyCollection Bots, + IContextRegistry BotContexts, + ASFFreeGamesOptions Options, + LoggerFilter LoggerFilter, + bool Valid = false +) { /// /// Gets the cancellation token associated with this context. /// public CancellationToken CancellationToken => CancellationTokenLazy.Value; - internal Lazy CancellationTokenLazy { private get; set; } = new(static () => default(CancellationToken)); + internal Lazy CancellationTokenLazy { private get; set; } = + new(static () => default(CancellationToken)); /// /// A struct that implements IDisposable and temporarily changes the cancellation token of the PluginContext instance. @@ -45,5 +52,7 @@ public CancellationTokenChanger(PluginContext context, Func f /// /// The function that creates a new cancellation token. /// A new instance of the struct. - public CancellationTokenChanger TemporaryChangeCancellationToken(Func factory) => new(this, factory); + public CancellationTokenChanger TemporaryChangeCancellationToken( + Func factory + ) => new(this, factory); } diff --git a/ASFFreeGames/Reddit/RedditHelper.cs b/ASFFreeGames/Reddit/RedditHelper.cs index 783db75..87ab95f 100644 --- a/ASFFreeGames/Reddit/RedditHelper.cs +++ b/ASFFreeGames/Reddit/RedditHelper.cs @@ -22,8 +22,13 @@ internal static class RedditHelper { /// Gets a collection of Reddit game entries from a JSON object. /// /// A collection of Reddit game entries. - public static async ValueTask> GetGames(SimpleHttpClient httpClient, uint retry = 5, CancellationToken cancellationToken = default) { - JsonNode? jsonPayload = await GetPayload(httpClient, cancellationToken, retry).ConfigureAwait(false); + public static async ValueTask> GetGames( + SimpleHttpClient httpClient, + uint retry = 5, + CancellationToken cancellationToken = default + ) { + JsonNode? jsonPayload = await GetPayload(httpClient, cancellationToken, retry) + .ConfigureAwait(false); JsonNode? childrenElement = jsonPayload["data"]?["children"]; @@ -31,7 +36,10 @@ public static async ValueTask> GetGames(Sim } internal static IReadOnlyCollection LoadMessages(JsonNode children) { - Maxisoft.Utils.Collections.Dictionaries.OrderedDictionary games = new(new GameEntryIdentifierEqualityComparer()); + Maxisoft.Utils.Collections.Dictionaries.OrderedDictionary< + RedditGameEntry, + EmptyStruct + > games = new(new GameEntryIdentifierEqualityComparer()); IReadOnlyCollection returnValue() { while (games.Count is > 0 and > MaxGameEntry) { @@ -120,10 +128,15 @@ IReadOnlyCollection returnValue() { /// 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(SimpleHttpClient httpClient, CancellationToken cancellationToken, uint retry = 5) { + private static async ValueTask GetPayload( + SimpleHttpClient httpClient, + CancellationToken cancellationToken, + uint retry = 5 + ) { HttpStreamResponse? response = null; - Dictionary headers = new() { + Dictionary headers = new() + { { "Pragma", "no-cache" }, { "Cache-Control", "no-cache" }, { "Accept", "application/json" }, @@ -132,32 +145,40 @@ private static async ValueTask GetPayload(SimpleHttpClient httpClient, { "Sec-Fetch-Dest", "empty" }, { "x-sec-fetch-dest", "empty" }, { "x-sec-fetch-mode", "no-cors" }, - { "x-sec-fetch-site", "none" } + { "x-sec-fetch-site", "none" }, }; for (int t = 0; t < retry; t++) { try { #pragma warning disable CA2000 - response = await httpClient.GetStreamAsync(GetUrl(), headers, cancellationToken).ConfigureAwait(false); + response = await httpClient + .GetStreamAsync(GetUrl(), headers, cancellationToken) + .ConfigureAwait(false); #pragma warning restore CA2000 - if (await HandleTooManyRequest(response, cancellationToken: cancellationToken).ConfigureAwait(false)) { + if ( + await HandleTooManyRequest(response, cancellationToken: cancellationToken) + .ConfigureAwait(false) + ) { continue; } if (!response.StatusCode.IsSuccessCode()) { - throw new RedditServerException($"reddit http error code is {response.StatusCode}", response.StatusCode); + throw new RedditServerException( + $"reddit http error code is {response.StatusCode}", + response.StatusCode + ); } - JsonNode? res = await ParseJsonNode(response, cancellationToken).ConfigureAwait(false); + JsonNode? res = await ParseJsonNode(response, cancellationToken) + .ConfigureAwait(false); if (res is null) { throw new RedditServerException("empty response", response.StatusCode); } try { - if ((res["kind"]?.GetValue() != "Listing") || - res["data"] is null) { + if ((res["kind"]?.GetValue() != "Listing") || res["data"] is null) { throw new RedditServerException("invalid response", response.StatusCode); } } @@ -167,7 +188,13 @@ private static async ValueTask GetPayload(SimpleHttpClient httpClient, return res; } - catch (Exception e) when (e is JsonException or IOException or RedditServerException or HttpRequestException) { + catch (Exception e) + when (e + is JsonException + or IOException + or RedditServerException + or HttpRequestException + ) { // If it's the last retry, re-throw the original Exception if (t + 1 == retry) { throw; @@ -190,7 +217,8 @@ private static async ValueTask GetPayload(SimpleHttpClient httpClient, return JsonNode.Parse("{}")!; } - private static Uri GetUrl() => new($"https://www.reddit.com/user/{User}.json?sort=new", UriKind.Absolute); + private static Uri GetUrl() => + new($"https://www.reddit.com/user/{User}.json?sort=new", UriKind.Absolute); /// /// Handles too many requests by checking the status code and headers of the response. @@ -202,14 +230,35 @@ private static async ValueTask GetPayload(SimpleHttpClient httpClient, /// /// The cancellation token. /// True if the request was handled & awaited, false otherwise. - private static async ValueTask HandleTooManyRequest(HttpStreamResponse response, int maxTimeToWait = 45, CancellationToken cancellationToken = default) { + private static async ValueTask HandleTooManyRequest( + HttpStreamResponse response, + int maxTimeToWait = 45, + CancellationToken cancellationToken = default + ) { if (response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.TooManyRequests) { - if (response.Response.Headers.TryGetValues("x-ratelimit-remaining", out IEnumerable? rateLimitRemaining)) { - if (int.TryParse(rateLimitRemaining.FirstOrDefault(), out int remaining) && (remaining <= 0)) { - if (response.Response.Headers.TryGetValues("x-ratelimit-reset", out IEnumerable? rateLimitReset) - && float.TryParse(rateLimitReset.FirstOrDefault(), out float reset) && double.IsNormal(reset) && (0 < reset) && (reset < maxTimeToWait)) { + if ( + response.Response.Headers.TryGetValues( + "x-ratelimit-remaining", + out IEnumerable? rateLimitRemaining + ) + ) { + if ( + int.TryParse(rateLimitRemaining.FirstOrDefault(), out int remaining) + && (remaining <= 0) + ) { + if ( + response.Response.Headers.TryGetValues( + "x-ratelimit-reset", + out IEnumerable? rateLimitReset + ) + && float.TryParse(rateLimitReset.FirstOrDefault(), out float reset) + && double.IsNormal(reset) + && (0 < reset) + && (reset < maxTimeToWait) + ) { try { - await Task.Delay(TimeSpan.FromSeconds(reset), cancellationToken).ConfigureAwait(false); + await Task.Delay(TimeSpan.FromSeconds(reset), cancellationToken) + .ConfigureAwait(false); } catch (TaskCanceledException) { return false; @@ -236,7 +285,10 @@ private static async ValueTask HandleTooManyRequest(HttpStreamResponse res /// The stream response containing the JSON data. /// The cancellation token. /// The parsed JSON object, or null if parsing fails. - internal static async Task ParseJsonNode(HttpStreamResponse stream, CancellationToken cancellationToken) { + internal static async Task ParseJsonNode( + HttpStreamResponse stream, + CancellationToken cancellationToken + ) { string data = await stream.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); return JsonNode.Parse(data); diff --git a/ASFFreeGames/Reddit/RedditServerException.cs b/ASFFreeGames/Reddit/RedditServerException.cs index 2dc79ec..45b88f3 100644 --- a/ASFFreeGames/Reddit/RedditServerException.cs +++ b/ASFFreeGames/Reddit/RedditServerException.cs @@ -8,11 +8,14 @@ public class RedditServerException : Exception { public HttpStatusCode StatusCode { get; } // A constructor that takes a message and a status code as parameters - public RedditServerException(string message, HttpStatusCode statusCode) : base(message) => StatusCode = statusCode; + public RedditServerException(string message, HttpStatusCode statusCode) + : base(message) => StatusCode = statusCode; public RedditServerException() { } - public RedditServerException(string message) : base(message) { } + public RedditServerException(string message) + : base(message) { } - public RedditServerException(string message, Exception innerException) : base(message, innerException) { } + public RedditServerException(string message, Exception innerException) + : base(message, innerException) { } } diff --git a/ASFFreeGames/Redlib/GameIdentifiersEqualityComparer.cs b/ASFFreeGames/Redlib/GameIdentifiersEqualityComparer.cs index 9c0ab57..4be7b09 100644 --- a/ASFFreeGames/Redlib/GameIdentifiersEqualityComparer.cs +++ b/ASFFreeGames/Redlib/GameIdentifiersEqualityComparer.cs @@ -3,6 +3,7 @@ using ASFFreeGames.ASFExtensions.Games; namespace Maxisoft.ASF.Redlib; + #pragma warning disable CA1819 public sealed class GameIdentifiersEqualityComparer : IEqualityComparer { diff --git a/ASFFreeGames/Redlib/Html/RedditHtmlParser.cs b/ASFFreeGames/Redlib/Html/RedditHtmlParser.cs index 9dcb3f7..4d57105 100644 --- a/ASFFreeGames/Redlib/Html/RedditHtmlParser.cs +++ b/ASFFreeGames/Redlib/Html/RedditHtmlParser.cs @@ -11,8 +11,18 @@ namespace Maxisoft.ASF.Redlib.Html; public static class RedlibHtmlParser { private const int MaxIdentifierPerEntry = 32; - public static IReadOnlyCollection ParseGamesFromHtml(ReadOnlySpan html, bool dedup = true) { - Maxisoft.Utils.Collections.Dictionaries.OrderedDictionary entries = new(dedup ? new GameIdentifiersEqualityComparer() : EqualityComparer.Default); + public static IReadOnlyCollection ParseGamesFromHtml( + ReadOnlySpan html, + bool dedup = true + ) { + Maxisoft.Utils.Collections.Dictionaries.OrderedDictionary< + RedlibGameEntry, + EmptyStruct + > entries = new( + dedup + ? new GameIdentifiersEqualityComparer() + : EqualityComparer.Default + ); int startIndex = 0; Span gameIdentifiers = stackalloc GameIdentifier[MaxIdentifierPerEntry]; @@ -28,36 +38,55 @@ public static IReadOnlyCollection ParseGamesFromHtml(ReadOnlySp ReadOnlySpan command = html[startOfCommandIndex..endOfCommandIndex].Trim(); if (!RedlibHtmlParserRegex.CommandRegex().IsMatch(command)) { - throw new SkipAndContinueParsingException("Invalid asf command") { StartIndex = startOfCommandIndex + 1 }; + throw new SkipAndContinueParsingException("Invalid asf command") { + StartIndex = startOfCommandIndex + 1, + }; } - Span effectiveGameIdentifiers = SplitCommandAndGetGameIdentifiers(command, gameIdentifiers); + Span effectiveGameIdentifiers = SplitCommandAndGetGameIdentifiers( + command, + gameIdentifiers + ); if (effectiveGameIdentifiers.IsEmpty) { - throw new SkipAndContinueParsingException("No game identifiers found") { StartIndex = startOfCommandIndex + 1 }; + throw new SkipAndContinueParsingException("No game identifiers found") { + StartIndex = startOfCommandIndex + 1, + }; } - EGameType flag = ParseGameTypeFlags(html[indices.StartOfCommandIndex..indices.StartOfFooterIndex]); + EGameType flag = ParseGameTypeFlags( + html[indices.StartOfCommandIndex..indices.StartOfFooterIndex] + ); ReadOnlySpan title = ExtractTitle(html, indices); DateTimeOffset createdDate = default; if ((indices.DateStartIndex < indices.DateEndIndex) && (indices.DateEndIndex > 0)) { - ReadOnlySpan dateString = html[indices.DateStartIndex..indices.DateEndIndex].Trim(); + ReadOnlySpan dateString = html[ + indices.DateStartIndex..indices.DateEndIndex + ] + .Trim(); if (!TryParseCreatedDate(dateString, out createdDate)) { createdDate = default(DateTimeOffset); } } - RedlibGameEntry entry = new(effectiveGameIdentifiers.ToArray(), title.ToString(), flag, createdDate); + RedlibGameEntry entry = new( + effectiveGameIdentifiers.ToArray(), + title.ToString(), + flag, + createdDate + ); try { entries.Add(entry, default(EmptyStruct)); } catch (ArgumentException e) { - throw new SkipAndContinueParsingException("entry already found", e) { StartIndex = startOfCommandIndex + 1 }; + throw new SkipAndContinueParsingException("entry already found", e) { + StartIndex = startOfCommandIndex + 1, + }; } } catch (SkipAndContinueParsingException e) { @@ -72,9 +101,25 @@ public static IReadOnlyCollection ParseGamesFromHtml(ReadOnlySp return (IReadOnlyCollection) entries.Keys; } - private static readonly string[] CommonDateFormat = ["MM dd yyyy, HH:mm:ss zzz", "MM dd yyyy, HH:mm:ss zzz", "MMM dd yyyy, HH:mm:ss UTC", "yyyy-MM-ddTHH:mm:ssZ", "yyyy-MM-ddTHH:mm:ss", "yyyy-MM-dd HH:mm:ss zzz", "yyyy-MM-dd HH:mm:ss.fffffff zzz", "yyyy-MM-ddTHH:mm:ss.fffffffzzz", "yyyy-MM-dd HH:mm:ss", "yyyyMMddHHmmss", "yyyyMMddHHmmss.fffffff"]; - - private static bool TryParseCreatedDate(ReadOnlySpan dateString, out DateTimeOffset createdDate) { + private static readonly string[] CommonDateFormat = + [ + "MM dd yyyy, HH:mm:ss zzz", + "MM dd yyyy, HH:mm:ss zzz", + "MMM dd yyyy, HH:mm:ss UTC", + "yyyy-MM-ddTHH:mm:ssZ", + "yyyy-MM-ddTHH:mm:ss", + "yyyy-MM-dd HH:mm:ss zzz", + "yyyy-MM-dd HH:mm:ss.fffffff zzz", + "yyyy-MM-ddTHH:mm:ss.fffffffzzz", + "yyyy-MM-dd HH:mm:ss", + "yyyyMMddHHmmss", + "yyyyMMddHHmmss.fffffff", + ]; + + private static bool TryParseCreatedDate( + ReadOnlySpan dateString, + out DateTimeOffset createdDate + ) { // parse date like May 31 2024, 12:28:53 UTC if (dateString.IsEmpty) { @@ -84,7 +129,15 @@ private static bool TryParseCreatedDate(ReadOnlySpan dateString, out DateT } foreach (string format in CommonDateFormat) { - if (DateTimeOffset.TryParseExact(dateString, format, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.AssumeUniversal | DateTimeStyles.AllowWhiteSpaces, out createdDate)) { + if ( + DateTimeOffset.TryParseExact( + dateString, + format, + DateTimeFormatInfo.InvariantInfo, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AllowWhiteSpaces, + out createdDate + ) + ) { return true; } } @@ -101,7 +154,11 @@ private static bool TryParseCreatedDate(ReadOnlySpan dateString, out DateT internal static ReadOnlySpan ExtractTitle(ReadOnlySpan html, ParserIndices indices) { Span ranges = stackalloc Range[MaxIdentifierPerEntry]; ReadOnlySpan hrefSpan = html[indices.HrefStartIndex..indices.HrefEndIndex]; - int splitCount = hrefSpan.Split(ranges, '/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + int splitCount = hrefSpan.Split( + ranges, + '/', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries + ); if (splitCount > 2) { Range range = ranges[..splitCount][^3]; @@ -132,55 +189,74 @@ internal static EGameType ParseGameTypeFlags(ReadOnlySpan content) { internal static ParserIndices ParseIndices(ReadOnlySpan html, int start) { // Find the index of the next !addlicense asf command - int startIndex = html[start..].IndexOf("!addlicense asf ", StringComparison.OrdinalIgnoreCase); + int startIndex = html[start..] + .IndexOf("!addlicense asf ", StringComparison.OrdinalIgnoreCase); if (startIndex < 0) { - startIndex = html[start..].IndexOf("
!addlicense asf ", StringComparison.OrdinalIgnoreCase);
+			startIndex = html[start..]
+				.IndexOf("
!addlicense asf ", StringComparison.OrdinalIgnoreCase);
 
 			if (startIndex < 0) {
-				throw new SkipAndContinueParsingException("No !addlicense asf command found") { StartIndex = -1 };
+				throw new SkipAndContinueParsingException("No !addlicense asf command found") {
+					StartIndex = -1,
+				};
 			}
 		}
 
 		startIndex += start;
 
-		int commentLinkIndex = html[start..startIndex].LastIndexOf(" html, int start) {
 		hrefEndIndex = html[hrefStartIndex..hrefEndIndex].IndexOf('>');
 
 		if (hrefEndIndex < 0) {
-			throw new SkipAndContinueParsingException("No comment href end found") { StartIndex = startIndex + 1 };
+			throw new SkipAndContinueParsingException("No comment href end found") {
+				StartIndex = startIndex + 1,
+			};
 		}
 
 		hrefEndIndex += hrefStartIndex;
 
-		if (!RedlibHtmlParserRegex.HrefCommentLinkRegex().IsMatch(html[hrefStartIndex..(hrefEndIndex + 1)])) {
-			throw new SkipAndContinueParsingException("Invalid comment link") { StartIndex = startIndex + 1 };
+		if (
+			!RedlibHtmlParserRegex
+				.HrefCommentLinkRegex()
+				.IsMatch(html[hrefStartIndex..(hrefEndIndex + 1)])
+		) {
+			throw new SkipAndContinueParsingException("Invalid comment link") {
+				StartIndex = startIndex + 1,
+			};
 		}
 
 		// Find the ASF info bot footer
-		int footerStartIndex = html[startIndex..].IndexOf("bot", StringComparison.InvariantCultureIgnoreCase);
+		int footerStartIndex = html[startIndex..]
+			.IndexOf("bot", StringComparison.InvariantCultureIgnoreCase);
 
 		if (footerStartIndex < 0) {
-			throw new SkipAndContinueParsingException("No bot in footer found") { StartIndex = startIndex + 1 };
+			throw new SkipAndContinueParsingException("No bot in footer found") {
+				StartIndex = startIndex + 1,
+			};
 		}
 
 		footerStartIndex += startIndex;
 
-		int infoFooterStartIndex = html[footerStartIndex..].IndexOf("Info", StringComparison.InvariantCultureIgnoreCase);
+		int infoFooterStartIndex = html[footerStartIndex..]
+			.IndexOf("Info", StringComparison.InvariantCultureIgnoreCase);
 
 		if (infoFooterStartIndex < 0) {
-			throw new SkipAndContinueParsingException("No Info in footer found") { StartIndex = startIndex + 1 };
+			throw new SkipAndContinueParsingException("No Info in footer found") {
+				StartIndex = startIndex + 1,
+			};
 		}
 
 		infoFooterStartIndex += footerStartIndex;
@@ -219,26 +309,47 @@ internal static ParserIndices ParseIndices(ReadOnlySpan html, int start) {
 		// now we have a kind of typical ASFInfo post
 
 		// Extract the comment link
-		int commandEndIndex = html[startIndex..infoFooterStartIndex].IndexOf("", StringComparison.InvariantCultureIgnoreCase);
+		int commandEndIndex = html[startIndex..infoFooterStartIndex]
+			.IndexOf("", StringComparison.InvariantCultureIgnoreCase);
 
 		if (commandEndIndex < 0) {
-			commandEndIndex = html[startIndex..infoFooterStartIndex].IndexOf("
", StringComparison.InvariantCultureIgnoreCase); + commandEndIndex = html[startIndex..infoFooterStartIndex] + .IndexOf("
", StringComparison.InvariantCultureIgnoreCase); if (commandEndIndex < 0) { - throw new SkipAndContinueParsingException("No command end found") { StartIndex = startIndex + 1 }; + throw new SkipAndContinueParsingException("No command end found") { + StartIndex = startIndex + 1, + }; } } commandEndIndex += startIndex; - startIndex = html[startIndex..commandEndIndex].IndexOf("!addlicense", StringComparison.OrdinalIgnoreCase) + startIndex; - - return new ParserIndices(startIndex, commandEndIndex, infoFooterStartIndex, hrefStartIndex, hrefEndIndex, createdTitleStartIndex, createdTitleEndIndex); + startIndex = + html[startIndex..commandEndIndex] + .IndexOf("!addlicense", StringComparison.OrdinalIgnoreCase) + startIndex; + + return new ParserIndices( + startIndex, + commandEndIndex, + infoFooterStartIndex, + hrefStartIndex, + hrefEndIndex, + createdTitleStartIndex, + createdTitleEndIndex + ); } - internal static Span SplitCommandAndGetGameIdentifiers(ReadOnlySpan command, Span gameIdentifiers) { + internal static Span SplitCommandAndGetGameIdentifiers( + ReadOnlySpan command, + Span gameIdentifiers + ) { Span ranges = stackalloc Range[MaxIdentifierPerEntry]; - int splits = command.Split(ranges, ',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + int splits = command.Split( + ranges, + ',', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries + ); if (splits <= 0) { return Span.Empty; @@ -247,7 +358,10 @@ internal static Span SplitCommandAndGetGameIdentifiers(ReadOnlyS // fix the first range because it contains the command ref Range firstRange = ref ranges[0]; int startFirstRange = command[firstRange].LastIndexOf(' '); - firstRange = new Range(firstRange.Start.GetOffset(command.Length) + startFirstRange + 1, firstRange.End); + firstRange = new Range( + firstRange.Start.GetOffset(command.Length) + startFirstRange + 1, + firstRange.End + ); int gameIdentifiersCount = 0; diff --git a/ASFFreeGames/Redlib/Html/RedlibHtmlParserRegex.cs b/ASFFreeGames/Redlib/Html/RedlibHtmlParserRegex.cs index 54912e2..0e1b66e 100644 --- a/ASFFreeGames/Redlib/Html/RedlibHtmlParserRegex.cs +++ b/ASFFreeGames/Redlib/Html/RedlibHtmlParserRegex.cs @@ -5,19 +5,34 @@ namespace Maxisoft.ASF.Redlib.Html; #pragma warning disable CA1052 public partial class RedlibHtmlParserRegex { - [GeneratedRegex(@"(.addlicense)\s+(asf)?\s*((?(s/|a/)\d+)\s*,?\s*)+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + [GeneratedRegex( + @"(.addlicense)\s+(asf)?\s*((?(s/|a/)\d+)\s*,?\s*)+", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant + )] internal static partial Regex CommandRegex(); - [GeneratedRegex(@"href\s*=\s*.\s*/r/[\P{Cc}\P{Cn}\P{Cs}]+?comments[\P{Cc}\P{Cn}\P{Cs}/]+?.\s*/?\s*>.*", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + [GeneratedRegex( + @"href\s*=\s*.\s*/r/[\P{Cc}\P{Cn}\P{Cs}]+?comments[\P{Cc}\P{Cn}\P{Cs}/]+?.\s*/?\s*>.*", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant + )] internal static partial Regex HrefCommentLinkRegex(); - [GeneratedRegex(@".*free\s+DLC\s+for\s+a.*", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + [GeneratedRegex( + @".*free\s+DLC\s+for\s+a.*", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant + )] internal static partial Regex IsDlcRegex(); - [GeneratedRegex(@".*free\s+to\s+play.*", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + [GeneratedRegex( + @".*free\s+to\s+play.*", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant + )] internal static partial Regex IsFreeToPlayRegex(); - [GeneratedRegex(@".*permanently\s+free.*", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + [GeneratedRegex( + @".*permanently\s+free.*", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant + )] internal static partial Regex IsPermanentlyFreeRegex(); } diff --git a/ASFFreeGames/Redlib/Instances/RedlibInstanceList.cs b/ASFFreeGames/Redlib/Instances/RedlibInstanceList.cs index ea61a3b..8b52d56 100644 --- a/ASFFreeGames/Redlib/Instances/RedlibInstanceList.cs +++ b/ASFFreeGames/Redlib/Instances/RedlibInstanceList.cs @@ -21,29 +21,38 @@ namespace Maxisoft.ASF.Redlib.Instances; public class RedlibInstanceList(ASFFreeGamesOptions options) : IRedlibInstanceList { private const string EmbeddedFileName = "redlib_instances.json"; - private static readonly HashSet DisabledKeywords = new(StringComparer.OrdinalIgnoreCase) { + private static readonly HashSet DisabledKeywords = new(StringComparer.OrdinalIgnoreCase) + { "disabled", "off", "no", - "false" + "false", }; - private readonly ASFFreeGamesOptions Options = options ?? throw new ArgumentNullException(nameof(options)); + private readonly ASFFreeGamesOptions Options = + options ?? throw new ArgumentNullException(nameof(options)); - public async Task> ListInstances([NotNull] SimpleHttpClient httpClient, CancellationToken cancellationToken) { + public async Task> ListInstances( + [NotNull] SimpleHttpClient httpClient, + CancellationToken cancellationToken + ) { if (IsDisabled(Options.RedlibInstanceUrl)) { throw new RedlibDisabledException(); } if (!Uri.TryCreate(Options.RedlibInstanceUrl, UriKind.Absolute, out Uri? uri)) { - ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericError("[FreeGames] Invalid redlib instances url: " + Options.RedlibInstanceUrl); + ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericError( + "[FreeGames] Invalid redlib instances url: " + Options.RedlibInstanceUrl + ); return await ListFromEmbedded(cancellationToken).ConfigureAwait(false); } #pragma warning disable CAC001 #pragma warning disable CA2007 - await using HttpStreamResponse response = await httpClient.GetStreamAsync(uri, cancellationToken: cancellationToken).ConfigureAwait(false); + await using HttpStreamResponse response = await httpClient + .GetStreamAsync(uri, cancellationToken: cancellationToken) + .ConfigureAwait(false); #pragma warning restore CA2007 #pragma warning restore CAC001 @@ -61,15 +70,25 @@ public async Task> ListInstances([NotNull] SimpleHttpClient httpClient List res = ParseUrls(node); - return res.Count > 0 ? res : await ListFromEmbedded(cancellationToken).ConfigureAwait(false); + return res.Count > 0 + ? res + : await ListFromEmbedded(cancellationToken).ConfigureAwait(false); } internal static void CheckUpToDate(JsonNode node) { int currentYear = DateTime.Now.Year; string updated = node["updated"]?.GetValue() ?? ""; - if (!updated.StartsWith(currentYear.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal) && - !updated.StartsWith((currentYear - 1).ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal)) { + if ( + !updated.StartsWith( + currentYear.ToString(CultureInfo.InvariantCulture), + StringComparison.Ordinal + ) + && !updated.StartsWith( + (currentYear - 1).ToString(CultureInfo.InvariantCulture), + StringComparison.Ordinal + ) + ) { throw new RedlibOutDatedListException(); } } @@ -101,7 +120,10 @@ internal static List ParseUrls(JsonNode json) { foreach (JsonNode? instance in (JsonArray) instances) { JsonNode? url = instance?["url"]; - if (Uri.TryCreate(url?.GetValue() ?? "", UriKind.Absolute, out Uri? instanceUri) && instanceUri.Scheme is "http" or "https") { + if ( + Uri.TryCreate(url?.GetValue() ?? "", UriKind.Absolute, out Uri? instanceUri) + && instanceUri.Scheme is "http" or "https" + ) { uris.Add(instanceUri); } } @@ -109,14 +131,17 @@ internal static List ParseUrls(JsonNode json) { return uris; } - private static bool IsDisabled(string? instanceUrl) => instanceUrl is not null && DisabledKeywords.Contains(instanceUrl.Trim()); + private static bool IsDisabled(string? instanceUrl) => + instanceUrl is not null && DisabledKeywords.Contains(instanceUrl.Trim()); private static async Task LoadEmbeddedInstance(CancellationToken cancellationToken) { Assembly assembly = Assembly.GetExecutingAssembly(); #pragma warning disable CAC001 #pragma warning disable CA2007 - await using Stream stream = assembly.GetManifestResourceStream($"{assembly.GetName().Name}.Resources.{EmbeddedFileName}")!; + await using Stream stream = assembly.GetManifestResourceStream( + $"{assembly.GetName().Name}.Resources.{EmbeddedFileName}" + )!; #pragma warning restore CA2007 #pragma warning restore CAC001 @@ -126,5 +151,8 @@ internal static List ParseUrls(JsonNode json) { return JsonNode.Parse(data); } - private static Task ParseJsonNode(HttpStreamResponse stream, CancellationToken cancellationToken) => RedditHelper.ParseJsonNode(stream, cancellationToken); + private static Task ParseJsonNode( + HttpStreamResponse stream, + CancellationToken cancellationToken + ) => RedditHelper.ParseJsonNode(stream, cancellationToken); } diff --git a/ASFFreeGames/Redlib/RedlibGameEntry.cs b/ASFFreeGames/Redlib/RedlibGameEntry.cs index 9678bcc..e69ccf9 100644 --- a/ASFFreeGames/Redlib/RedlibGameEntry.cs +++ b/ASFFreeGames/Redlib/RedlibGameEntry.cs @@ -8,13 +8,22 @@ namespace Maxisoft.ASF.Redlib; #pragma warning disable CA1819 -public readonly record struct RedlibGameEntry(IReadOnlyCollection GameIdentifiers, string CommentLink, EGameType TypeFlags, DateTimeOffset Date) { +public readonly record struct RedlibGameEntry( + IReadOnlyCollection GameIdentifiers, + string CommentLink, + EGameType TypeFlags, + DateTimeOffset Date +) { public RedditGameEntry ToRedditGameEntry(long date = default) { if ((Date != default(DateTimeOffset)) && (Date != DateTimeOffset.MinValue)) { date = Date.ToUnixTimeMilliseconds(); } - return new RedditGameEntry(string.Join(',', GameIdentifiers), TypeFlags.ToRedditGameEntryKind(), date); + return new RedditGameEntry( + string.Join(',', GameIdentifiers), + TypeFlags.ToRedditGameEntryKind(), + date + ); } } diff --git a/ASFFreeGames/Utils/LoggerFilter.cs b/ASFFreeGames/Utils/LoggerFilter.cs index f7c292b..fee6cbf 100644 --- a/ASFFreeGames/Utils/LoggerFilter.cs +++ b/ASFFreeGames/Utils/LoggerFilter.cs @@ -23,113 +23,143 @@ namespace Maxisoft.ASF.Utils; /// Represents a class that provides methods for filtering log events based on custom criteria. ///
public partial class LoggerFilter { - // A concurrent dictionary that maps bot names to lists of filter functions - private readonly ConcurrentDictionary>> Filters = new(); - - // A custom filter that invokes the FilterLogEvent method - private readonly MarkedWhenMethodFilter MethodFilter; - - /// - /// Initializes a new instance of the class. - /// - public LoggerFilter() => MethodFilter = new MarkedWhenMethodFilter(FilterLogEvent); - - /// - /// Disables logging for a specific bot based on a filter function. - /// - /// The filter function that determines whether to ignore a log event. - /// The bot instance whose logging should be disabled. - /// A disposable object that can be used to re-enable logging when disposed. - public IDisposable DisableLogging(Func filter, [NotNull] Bot bot) { - Logger logger = GetLogger(bot.ArchiLogger, bot.BotName); - - lock (Filters) { - Filters.TryGetValue(bot.BotName, out LinkedList>? filters); - - if (filters is null) { - filters = new LinkedList>(); - - if (!Filters.TryAdd(bot.BotName, filters)) { - filters = Filters[bot.BotName]; - } - } - - LinkedListNode> node = filters.AddLast(filter); - LoggingConfiguration? config = logger.Factory.Configuration; - - bool reconfigure = false; - - foreach (LoggingRule loggingRule in config.LoggingRules.Where(loggingRule => !loggingRule.Filters.Any(f => ReferenceEquals(f, MethodFilter)))) { - loggingRule.Filters.Insert(0, MethodFilter); - reconfigure = true; - } - - if (reconfigure) { - logger.Factory.ReconfigExistingLoggers(); - } - - return new LoggerRemoveFilterDisposable(node); - } - } - - /// - /// Disables logging for a specific bot based on a filter function and a regex pattern for common errors when adding licenses. - /// - /// The filter function that determines whether to ignore a log event. - /// The bot instance whose logging should be disabled. - /// A disposable object that can be used to re-enable logging when disposed. - public IDisposable DisableLoggingForAddLicenseCommonErrors(Func filter, [NotNull] Bot bot) { - bool filter2(LogEventInfo info) => (info.Level == LogLevel.Debug) && filter(info) && AddLicenseCommonErrorsRegex().IsMatch(info.Message); - - return DisableLogging(filter2, bot); - } - - /// - /// Removes all filters for a specific bot. - /// - /// The bot instance whose filters should be removed. - /// True if the removal was successful; otherwise, false. - public bool RemoveFilters(Bot? bot) => bot is not null && RemoveFilters(bot.BotName); - - // A regex pattern for common errors when adding licenses - [GeneratedRegex(@"^.*?InternalRequest(?>\s*)\(\w*?\)(?>\s*)(?:(?:InternalServerError)|(?:Forbidden)).*?$", RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.CultureInvariant)] - private static partial Regex AddLicenseCommonErrorsRegex(); - - // A method that filters log events based on the registered filter functions - private FilterResult FilterLogEvent(LogEventInfo eventInfo) { - Bot? bot = eventInfo.LoggerName == "ASF" ? null : Bot.GetBot(eventInfo.LoggerName ?? ""); - - if (Filters.TryGetValue(bot?.BotName ?? eventInfo.LoggerName ?? "", out LinkedList>? filters)) { - return filters.Any(func => func(eventInfo)) ? FilterResult.IgnoreFinal : FilterResult.Log; - } - - return FilterResult.Log; - } - - // A method that gets the logger instance from the ArchiLogger instance using introspection - private static Logger GetLogger(ArchiLogger logger, string name = "ASF") { - FieldInfo? field = logger.GetType().GetField("Logger", BindingFlags.IgnoreCase | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.GetField | BindingFlags.GetProperty); - - // Check if the field is null or the value is not a Logger instance - return field?.GetValue(logger) is not Logger loggerInstance - ? - - // Return a default logger with the given name - LogManager.GetLogger(name) - : - - // Return the logger instance from the field - loggerInstance; - } - - // A method that removes filters by bot name - private bool RemoveFilters(BotName botName) => Filters.TryRemove(botName, out _); - - // A class that implements a disposable object for removing filters - 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(Func filterMethod) : WhenMethodFilter(filterMethod); + // A concurrent dictionary that maps bot names to lists of filter functions + private readonly ConcurrentDictionary>> Filters = + new(); + + // A custom filter that invokes the FilterLogEvent method + private readonly MarkedWhenMethodFilter MethodFilter; + + /// + /// Initializes a new instance of the class. + /// + public LoggerFilter() => MethodFilter = new MarkedWhenMethodFilter(FilterLogEvent); + + /// + /// Disables logging for a specific bot based on a filter function. + /// + /// The filter function that determines whether to ignore a log event. + /// The bot instance whose logging should be disabled. + /// A disposable object that can be used to re-enable logging when disposed. + public IDisposable DisableLogging(Func filter, [NotNull] Bot bot) { + Logger logger = GetLogger(bot.ArchiLogger, bot.BotName); + + lock (Filters) { + Filters.TryGetValue(bot.BotName, out LinkedList>? filters); + + if (filters is null) { + filters = new LinkedList>(); + + if (!Filters.TryAdd(bot.BotName, filters)) { + filters = Filters[bot.BotName]; + } + } + + LinkedListNode> node = filters.AddLast(filter); + LoggingConfiguration? config = logger.Factory.Configuration; + + bool reconfigure = false; + + foreach ( + LoggingRule loggingRule in config.LoggingRules.Where(loggingRule => + !loggingRule.Filters.Any(f => ReferenceEquals(f, MethodFilter)) + ) + ) { + loggingRule.Filters.Insert(0, MethodFilter); + reconfigure = true; + } + + if (reconfigure) { + logger.Factory.ReconfigExistingLoggers(); + } + + return new LoggerRemoveFilterDisposable(node); + } + } + + /// + /// Disables logging for a specific bot based on a filter function and a regex pattern for common errors when adding licenses. + /// + /// The filter function that determines whether to ignore a log event. + /// The bot instance whose logging should be disabled. + /// A disposable object that can be used to re-enable logging when disposed. + public IDisposable DisableLoggingForAddLicenseCommonErrors( + Func filter, + [NotNull] Bot bot + ) { + bool filter2(LogEventInfo info) => + (info.Level == LogLevel.Debug) + && filter(info) + && AddLicenseCommonErrorsRegex().IsMatch(info.Message); + + return DisableLogging(filter2, bot); + } + + /// + /// Removes all filters for a specific bot. + /// + /// The bot instance whose filters should be removed. + /// True if the removal was successful; otherwise, false. + public bool RemoveFilters(Bot? bot) => bot is not null && RemoveFilters(bot.BotName); + + // A regex pattern for common errors when adding licenses + [GeneratedRegex( + @"^.*?InternalRequest(?>\s*)\(\w*?\)(?>\s*)(?:(?:InternalServerError)|(?:Forbidden)).*?$", + RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.CultureInvariant + )] + private static partial Regex AddLicenseCommonErrorsRegex(); + + // A method that filters log events based on the registered filter functions + private FilterResult FilterLogEvent(LogEventInfo eventInfo) { + Bot? bot = eventInfo.LoggerName == "ASF" ? null : Bot.GetBot(eventInfo.LoggerName ?? ""); + + if ( + Filters.TryGetValue( + bot?.BotName ?? eventInfo.LoggerName ?? "", + out LinkedList>? filters + ) + ) { + return filters.Any(func => func(eventInfo)) + ? FilterResult.IgnoreFinal + : FilterResult.Log; + } + + return FilterResult.Log; + } + + // A method that gets the logger instance from the ArchiLogger instance using introspection + private static Logger GetLogger(ArchiLogger logger, string name = "ASF") { + FieldInfo? field = logger + .GetType() + .GetField( + "Logger", + BindingFlags.IgnoreCase + | BindingFlags.NonPublic + | BindingFlags.Instance + | BindingFlags.GetField + | BindingFlags.GetProperty + ); + + // Check if the field is null or the value is not a Logger instance + return field?.GetValue(logger) is not Logger loggerInstance + ? + // Return a default logger with the given name + LogManager.GetLogger(name) + : + // Return the logger instance from the field + loggerInstance; + } + + // A method that removes filters by bot name + private bool RemoveFilters(BotName botName) => Filters.TryRemove(botName, out _); + + // A class that implements a disposable object for removing filters + 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(Func filterMethod) + : WhenMethodFilter(filterMethod); } diff --git a/ASFFreeGames/Utils/Workarounds/AsyncLocal.cs b/ASFFreeGames/Utils/Workarounds/AsyncLocal.cs index 737df59..f562827 100644 --- a/ASFFreeGames/Utils/Workarounds/AsyncLocal.cs +++ b/ASFFreeGames/Utils/Workarounds/AsyncLocal.cs @@ -22,7 +22,6 @@ static AsyncLocal() { AsyncLocalType ??= Type.GetType("System.Threading.AsyncLocal") ?.MakeGenericType(typeof(T)); } - catch (InvalidOperationException) { // ignore } diff --git a/ASFFreeGames/Utils/Workarounds/BotPackageChecker.cs b/ASFFreeGames/Utils/Workarounds/BotPackageChecker.cs index 567ca1f..a0ad8f3 100644 --- a/ASFFreeGames/Utils/Workarounds/BotPackageChecker.cs +++ b/ASFFreeGames/Utils/Workarounds/BotPackageChecker.cs @@ -50,7 +50,10 @@ public static bool BotOwnsPackage(Bot? bot, uint appId) { } #region Cache Configuration - private static readonly ConcurrentDictionary> OwnershipCache = new(); + private static readonly ConcurrentDictionary< + string, + ConcurrentDictionary + > OwnershipCache = new(); private static readonly Lock CacheLock = new(); private static Guid LastKnownBotAssemblyMvid; #endregion @@ -82,7 +85,8 @@ private static bool CheckOwnership(Bot bot, uint appId) { } // ReSharper disable once ConditionalAccessQualifierIsNonNullableAccordingToAPIContract - private static bool DirectOwnershipCheck(Bot bot, uint appId) => bot.OwnedPackages?.ContainsKey(appId) ?? false; + private static bool DirectOwnershipCheck(Bot bot, uint appId) => + bot.OwnedPackages?.ContainsKey(appId) ?? false; #endregion #region Reflection Implementation @@ -98,8 +102,9 @@ private static bool ReflectiveOwnershipCheck(Bot bot, uint appId) { Type dictType = ownedPackages.GetType(); - Type? iDictType = dictType.GetInterface("System.Collections.Generic.IDictionary`2") ?? - dictType.GetInterface("System.Collections.Generic.IReadOnlyDictionary`2"); + Type? iDictType = + dictType.GetInterface("System.Collections.Generic.IDictionary`2") + ?? dictType.GetInterface("System.Collections.Generic.IReadOnlyDictionary`2"); if (iDictType is null) { bot.ArchiLogger.LogGenericError("Owned packages is not a recognized dictionary type"); @@ -119,7 +124,9 @@ private static bool ReflectiveOwnershipCheck(Bot bot, uint appId) { return false; } catch (InvalidCastException) { - bot.ArchiLogger.LogGenericError($"Invalid cast converting AppID {appId} to {keyType.Name}"); + bot.ArchiLogger.LogGenericError( + $"Invalid cast converting AppID {appId} to {keyType.Name}" + ); return false; } @@ -136,7 +143,9 @@ private static bool ReflectiveOwnershipCheck(Bot bot, uint appId) { return (bool) (containsKeyMethod.Invoke(ownedPackages, [convertedKey]) ?? false); } catch (TargetInvocationException e) { - bot.ArchiLogger.LogGenericError($"Invocation of {containsKeyMethod.Name} failed: {e.InnerException?.Message ?? e.Message}"); + bot.ArchiLogger.LogGenericError( + $"Invocation of {containsKeyMethod.Name} failed: {e.InnerException?.Message ?? e.Message}" + ); return false; } @@ -148,12 +157,16 @@ private static bool ReflectiveOwnershipCheck(Bot bot, uint appId) { } const StringComparison comparison = StringComparison.Ordinal; - PropertyInfo[] properties = typeof(Bot).GetProperties(BindingFlags.Public | BindingFlags.Instance); + PropertyInfo[] properties = typeof(Bot).GetProperties( + BindingFlags.Public | BindingFlags.Instance + ); // ReSharper disable once LoopCanBePartlyConvertedToQuery foreach (PropertyInfo property in properties) { - if (property.Name.Equals("OwnedPackages", comparison) || - property.Name.Equals("OwnedPackageIDs", comparison)) { + if ( + property.Name.Equals("OwnedPackages", comparison) + || property.Name.Equals("OwnedPackageIDs", comparison) + ) { CachedOwnershipProperty = property; return property; From 0847a0e5fd25ecc51e2d6d92e7bd0b705d0ce4b5 Mon Sep 17 00:00:00 2001 From: peter9811 Date: Fri, 7 Mar 2025 15:02:17 +1000 Subject: [PATCH 7/7] Enhance FreeGamesCommand: Add retryAttempts variable for error handling --- ASFFreeGames/Commands/FreeGamesCommand.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/ASFFreeGames/Commands/FreeGamesCommand.cs b/ASFFreeGames/Commands/FreeGamesCommand.cs index 155a7ac..50afb1b 100644 --- a/ASFFreeGames/Commands/FreeGamesCommand.cs +++ b/ASFFreeGames/Commands/FreeGamesCommand.cs @@ -566,6 +566,7 @@ or RedditServerException int maxRetries = Options?.MaxRetryAttempts ?? 1; bool isTransientError = false; + int retryAttempts = 0; do {