├── .editorconfig ├── .gitattributes ├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md ├── RELEASE_TEMPLATE.md ├── SECURITY.md ├── SUPPORT.md ├── dependabot.yml ├── renovate.json5 └── workflows │ ├── bump-asf-reference.yml │ ├── ci.yml │ ├── keepalive.yml │ ├── publish.yml │ └── test_integration.yml ├── .gitignore ├── .gitmodules ├── ASFFreeGames.Tests ├── ASFFreeGames.Tests.csproj ├── ASFinfo.json ├── AssemblyInfo.cs ├── Configurations │ └── ASFFreeGamesOptionsSaverTests.cs ├── GameIdentifierParserTests.cs ├── GameIdentifierTests.cs ├── RandomUtilsTests.cs ├── Reddit │ └── RedditHelperTests.cs ├── Redlib │ ├── RedlibHtmlParserTests.cs │ └── RedlibInstancesListTests.cs └── redlib_asfinfo.html ├── ASFFreeGames.sln ├── ASFFreeGames.sln.DotSettings ├── ASFFreeGames ├── ASFExtentions │ ├── Bot │ │ ├── BotContext.cs │ │ ├── BotEqualityComparer.cs │ │ └── BotName.cs │ └── Games │ │ ├── GameIdentifier.cs │ │ ├── GameIdentifierParser.cs │ │ └── GameIdentifierType.cs ├── ASFFreeGames.csproj ├── ASFFreeGames.csproj.DotSettings ├── ASFFreeGamesPlugin.cs ├── AppLists │ ├── CompletedAppList.cs │ └── RecentGameMapping.cs ├── AssemblyInfo.cs ├── CollectIntervalManager.cs ├── Commands │ ├── CommandDispatcher.cs │ ├── FreeGamesCommand.cs │ ├── GetIp │ │ ├── GetIPCommand.cs │ │ ├── GetIpReponse.cs │ │ └── GetIpReponseContext.cs │ └── IBotCommand.cs ├── Configurations │ ├── ASFFreeGamesOptions.cs │ ├── ASFFreeGamesOptionsContext.cs │ ├── ASFFreeGamesOptionsLoader.cs │ └── ASFFreeGamesOptionsSaver.cs ├── ContextRegistry.cs ├── ECollectGameRequestSource.cs ├── FreeGames │ └── Strategies │ │ ├── EListFreeGamesStrategy.cs │ │ ├── HttpRequestRedlibException.cs │ │ ├── IListFreeGamesStrategy.cs │ │ ├── ListFreeGamesContext.cs │ │ ├── ListFreeGamesMainStrategy.cs │ │ ├── RedditListFreeGamesStrategy.cs │ │ └── RedlibListFreeGamesStrategy.cs ├── Github │ └── GithubPluginUpdater.cs ├── HttpClientSimple │ ├── SimpleHttpClient.cs │ └── SimpleHttpClientFactory.cs ├── Maxisoft.Utils │ ├── BitSpan.Helpers.cs │ ├── BitSpan.Operators.cs │ ├── BitSpan.cs │ ├── IOrderedDictionary.cs │ ├── OrderedDictionary.cs │ ├── SpanDict.Helpers.cs │ ├── SpanDict.cs │ ├── SpanExtensions.cs │ ├── SpanList.cs │ └── WrappedIndex.cs ├── PluginContext.cs ├── Reddit │ ├── ERedditGameEntryKind.cs │ ├── EmptyStruct.cs │ ├── GameEntryIdentifierEqualityComparer.cs │ ├── RedditGameEntry.cs │ ├── RedditHelper.cs │ ├── RedditHelperRegexes.cs │ └── RedditServerException.cs ├── Redlib │ ├── EGameType.cs │ ├── Exceptions │ │ ├── RedlibDisabledException.cs │ │ ├── RedlibException.cs │ │ └── RedlibOutDatedListException.cs │ ├── GameIdentifiersEqualityComparer.cs │ ├── Html │ │ ├── ParserIndices.cs │ │ ├── RedditHtmlParser.cs │ │ ├── RedlibHtmlParserRegex.cs │ │ └── SkipAndContinueParsingException.cs │ ├── Instances │ │ ├── CachedRedlibInstanceList.cs │ │ ├── CachedRedlibInstanceListStorage.cs │ │ ├── IRedlibInstanceList.cs │ │ └── RedlibInstanceList.cs │ └── RedlibGameEntry.cs ├── Resources │ └── redlib_instances.json └── Utils │ ├── LoggerFilter.cs │ ├── RandomUtils.cs │ └── Workarounds │ ├── AsyncLocal.cs │ └── BotPackageChecker.cs ├── Directory.Build.props ├── Directory.Packages.props ├── LICENSE.txt ├── README.md └── nuget.config /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | *.sh text eol=lf 5 | 6 | # Custom for Visual Studio 7 | *.cs diff=csharp 8 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, caste, color, religion, or sexual identity 11 | and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the 27 | overall community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or 32 | advances of any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email 36 | address, without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at **[TODO@example.com](mailto:TODO@example.com)**. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available 126 | at [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | This page can be used for telling your users about your contributing guidelines. 4 | 5 | You can check **[ASF's contributing guidelines](https://github.com/JustArchiNET/ArchiSteamFarm/blob/main/.github/CONTRIBUTING.md)** for some inspiration. 6 | 7 | --- 8 | 9 | If by any chance you're contributing to our ASF-PluginTemplate repo and currently reading this document, then **[ASF's contributing guidelines](https://github.com/JustArchiNET/ArchiSteamFarm/blob/main/.github/CONTRIBUTING.md)** apply. 10 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: maxisoft -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Pull request 2 | 3 | -------------------------------------------------------------------------------- /.github/RELEASE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Changelog 2 | 3 | This is automated GitHub deployment, human-readable changelog should be available soon. 4 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security policy 2 | 3 | This page can be used for telling your users about your security policy. 4 | 5 | You can check **[ASF's Security policy](https://github.com/JustArchiNET/ArchiSteamFarm/blob/main/.github/SECURITY.md)** for some inspiration. 6 | -------------------------------------------------------------------------------- /.github/SUPPORT.md: -------------------------------------------------------------------------------- 1 | # Support 2 | 3 | This page can be used for telling your users how they can get ask for support in regards to your plugin. **[GitHub discussions](https://docs.github.com/discussions)** is one of the examples that should satisfy you. 4 | 5 | You can check **[ASF's Support](https://github.com/JustArchiNET/ArchiSteamFarm/blob/main/.github/SUPPORT.md)** for some inspiration. 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base", 5 | ":assignee(JustArchi)", 6 | ":automergeBranch", 7 | ":automergeDigest", 8 | ":automergeMinor", 9 | ":disableDependencyDashboard", 10 | ":disableRateLimiting", 11 | ":label(🤖 Automatic)" 12 | ], 13 | "git-submodules": { 14 | "enabled": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/bump-asf-reference.yml: -------------------------------------------------------------------------------- 1 | # This action is responsible for automatically bumping the ArchiSteamFarm submodule reference to the latest stable release 2 | # Please note that this DOES NOT update the actual commit the submodule itself is binded to, as that part is the responsibility of the developer or chosen dependencies management tool (such as Renovate or Dependabot), that will actually build and test whether the plugin project requires any kind of corrections before doing so 3 | # Because of that, commit created through this workflow can't possibly create any kind of build regression, as we only limit the actual commit the above action can actually update to 4 | name: Plugin-bump-asf-reference 5 | 6 | on: 7 | schedule: 8 | - cron: '17 1 * * *' 9 | 10 | workflow_dispatch: 11 | 12 | env: 13 | # You can specify your own credentials if you'd like to, simply change ARCHIBOT_GPG_PRIVATE_KEY and/or ARCHIBOT_GITHUB_TOKEN secrets here to the ones you want to use 14 | GPG_PRIVATE_KEY: ${{ secrets.ARCHIBOT_GPG_PRIVATE_KEY }} # Optional, if secret not provided, will skip signing commit with GPG key 15 | PUSH_GITHUB_TOKEN: ${{ secrets.ARCHIBOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} # Optional, if secret not provided, will use the default token 16 | 17 | jobs: 18 | main: 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v4.2.2 24 | with: 25 | token: ${{ env.PUSH_GITHUB_TOKEN }} 26 | 27 | - name: Fetch latest ArchiSteamFarm release 28 | id: asf-release 29 | uses: pozetroninc/github-action-get-latest-release@v0.8.0 30 | with: 31 | owner: JustArchiNET 32 | repo: ArchiSteamFarm 33 | excludes: draft,prerelease 34 | 35 | - name: Import GPG key for signing 36 | uses: crazy-max/ghaction-import-gpg@v6.2.0 37 | if: ${{ env.GPG_PRIVATE_KEY != null }} 38 | with: 39 | gpg_private_key: ${{ env.GPG_PRIVATE_KEY }} 40 | git_user_signingkey: true 41 | git_commit_gpgsign: true 42 | 43 | - name: Update ASF reference if needed 44 | env: 45 | LATEST_ASF_RELEASE: ${{ steps.asf-release.outputs.release }} 46 | shell: sh 47 | run: | 48 | set -eu 49 | 50 | git config -f .gitmodules submodule.ArchiSteamFarm.branch "$LATEST_ASF_RELEASE" 51 | 52 | git add -A ".gitmodules" 53 | 54 | if ! git diff --cached --quiet; then 55 | if ! git config --get user.email > /dev/null; then 56 | git config --local user.email "${{ github.repository_owner }}@users.noreply.github.com" 57 | fi 58 | 59 | if ! git config --get user.name > /dev/null; then 60 | git config --local user.name "${{ github.repository_owner }}" 61 | fi 62 | 63 | git commit -m "Automatic ArchiSteamFarm reference update to ${LATEST_ASF_RELEASE}" 64 | fi 65 | 66 | - name: Push changes to the repo 67 | uses: ad-m/github-push-action@v0.8.0 68 | with: 69 | github_token: ${{ env.PUSH_GITHUB_TOKEN }} 70 | branch: ${{ github.ref }} 71 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Plugin-ci 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | DOTNET_CLI_TELEMETRY_OPTOUT: true 7 | DOTNET_NOLOGO: true 8 | DOTNET_SDK_VERSION: 9.0.x 9 | DOTNET_FRAMEWORK: net9.0 10 | 11 | jobs: 12 | main: 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | configuration: [Debug, Release] 17 | os: [macos-latest, ubuntu-latest, windows-latest] 18 | 19 | runs-on: ${{ matrix.os }} 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v4.2.2 24 | with: 25 | submodules: recursive 26 | 27 | - name: Setup .NET Core 28 | uses: actions/setup-dotnet@v4 29 | with: 30 | dotnet-version: ${{ env.DOTNET_SDK_VERSION }} 31 | 32 | - name: Verify .NET Core 33 | run: dotnet --info 34 | 35 | - name: Build ${{ matrix.configuration }} 36 | uses: nick-fields/retry@v3 37 | with: 38 | timeout_minutes: 10 39 | max_attempts: 10 40 | shell: pwsh 41 | command: dotnet build -c "${{ matrix.configuration }}" -p:ContinuousIntegrationBuild=true -p:UseAppHost=false -p:isolate=true --nologo --framework=${{ env.DOTNET_FRAMEWORK }} 42 | 43 | - name: Test ${{ matrix.configuration }} 44 | run: dotnet test --no-build --verbosity normal -c "${{ matrix.configuration }}" -p:ContinuousIntegrationBuild=true -p:UseAppHost=false --nologo --framework=${{ env.DOTNET_FRAMEWORK }} 45 | -------------------------------------------------------------------------------- /.github/workflows/keepalive.yml: -------------------------------------------------------------------------------- 1 | name: Keep the repo alive 2 | on: 3 | schedule: 4 | - cron: "22 12 */3 * *" 5 | push: 6 | pull_request: 7 | fork: 8 | status: 9 | issues: 10 | 11 | concurrency: 12 | group: "${{ github.workflow }}-${{ github.head_ref || github.run_id }}" 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | keep-alive: 17 | name: Keep the repo alive 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4.2.2 21 | timeout-minutes: 5 22 | - uses: gautamkrishnar/keepalive-workflow@v2 23 | timeout-minutes: 5 24 | with: 25 | use_api: false 26 | committer_username: ${{ github.repository_owner }} 27 | committer_email: ${{ github.repository_owner }}@users.noreply.github.com 28 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Plugin-publish 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | CONFIGURATION: Release 7 | DOTNET_CLI_TELEMETRY_OPTOUT: true 8 | DOTNET_NOLOGO: true 9 | DOTNET_SDK_VERSION: 9.0.x 10 | NET_CORE_VERSION: net9.0 11 | NET_FRAMEWORK_VERSION: net48 12 | PLUGIN_NAME: ASFFreeGames 13 | 14 | jobs: 15 | publish: 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | os: [ 20 | macos-latest, 21 | ubuntu-latest, 22 | #windows-latest 23 | ] 24 | 25 | runs-on: ${{ matrix.os }} 26 | 27 | steps: 28 | - name: Checkout code 29 | uses: actions/checkout@v4.2.2 30 | with: 31 | submodules: recursive 32 | 33 | - name: Setup .NET Core 34 | uses: actions/setup-dotnet@v4 35 | with: 36 | dotnet-version: ${{ env.DOTNET_SDK_VERSION }} 37 | 38 | - name: Verify .NET Core 39 | run: dotnet --info 40 | 41 | - name: Restore packages in preparation for plugin publishing 42 | run: dotnet restore ${{ env.PLUGIN_NAME }} -p:ContinuousIntegrationBuild=true --nologo 43 | 44 | - name: Publish plugin on Unix 45 | if: startsWith(matrix.os, 'macos-') || startsWith(matrix.os, 'ubuntu-') 46 | env: 47 | VARIANTS: generic 48 | shell: sh 49 | run: | 50 | set -eu 51 | 52 | publish() { 53 | 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 54 | 55 | # By default use fastest compression 56 | seven_zip_args="-mx=1" 57 | zip_args="-1" 58 | 59 | # Remove useless dlls 60 | 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 61 | 62 | # Include extra logic for builds marked for release 63 | case "$GITHUB_REF" in 64 | "refs/tags/"*) 65 | # Tweak compression args for release publishing 66 | seven_zip_args="-mx=9 -mfb=258 -mpass=15" 67 | zip_args="-9" 68 | ;; 69 | esac 70 | 71 | # Create the final zip file 72 | case "$(uname -s)" in 73 | "Darwin") 74 | # We prefer to use zip on OS X as 7z implementation on that OS doesn't handle file permissions (chmod +x) 75 | if command -v zip >/dev/null; then 76 | ( 77 | cd "${GITHUB_WORKSPACE}/out/${1}" 78 | zip -q -r $zip_args "../${PLUGIN_NAME}-${1}.zip" . 79 | ) 80 | elif command -v 7z >/dev/null; then 81 | 7z a -bd -slp -tzip -mm=Deflate $seven_zip_args "out/${PLUGIN_NAME}-${1}.zip" "${GITHUB_WORKSPACE}/out/${1}/*" 82 | else 83 | echo "ERROR: No supported zip tool!" 84 | return 1 85 | fi 86 | ;; 87 | *) 88 | if command -v 7z >/dev/null; then 89 | 7z a -bd -slp -tzip -mm=Deflate $seven_zip_args "out/${PLUGIN_NAME}-${1}.zip" "${GITHUB_WORKSPACE}/out/${1}/*" 90 | elif command -v zip >/dev/null; then 91 | ( 92 | cd "${GITHUB_WORKSPACE}/out/${1}" 93 | zip -q -r $zip_args "../${PLUGIN_NAME}-${1}.zip" . 94 | ) 95 | else 96 | echo "ERROR: No supported zip tool!" 97 | return 1 98 | fi 99 | ;; 100 | esac 101 | } 102 | 103 | jobs="" 104 | 105 | for variant in $VARIANTS; do 106 | publish "$variant" & 107 | jobs="$jobs $!" 108 | done 109 | 110 | for job in $jobs; do 111 | wait "$job" 112 | done 113 | 114 | - name: Publish plugin on Windows 115 | if: startsWith(matrix.os, 'windows-') 116 | env: 117 | VARIANTS: generic 118 | shell: pwsh 119 | run: | 120 | Set-StrictMode -Version Latest 121 | $ErrorActionPreference = 'Stop' 122 | $ProgressPreference = 'SilentlyContinue' 123 | 124 | $PublishBlock = { 125 | param($variant) 126 | 127 | Set-StrictMode -Version Latest 128 | $ErrorActionPreference = 'Stop' 129 | $ProgressPreference = 'SilentlyContinue' 130 | 131 | Set-Location "$env:GITHUB_WORKSPACE" 132 | 133 | if ($variant -like '*-netf') { 134 | $targetFramework = $env:NET_FRAMEWORK_VERSION 135 | } else { 136 | $targetFramework = $env:NET_CORE_VERSION 137 | } 138 | 139 | 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 140 | 141 | if ($LastExitCode -ne 0) { 142 | throw "Last command failed." 143 | } 144 | 145 | # By default use fastest compression 146 | $compressionArgs = '-mx=1' 147 | 148 | # Include extra logic for builds marked for release 149 | if ($env:GITHUB_REF -like 'refs/tags/*') { 150 | # Tweak compression args for release publishing 151 | $compressionArgs = '-mx=9', '-mfb=258', '-mpass=15' 152 | } 153 | 154 | # Remove useless dlls 155 | Get-Item "$env:GITHUB_WORKSPACE\out\$variant\$env:PLUGIN_NAME\System.IO.Hashing.dll" -ErrorAction SilentlyContinue | Remove-Item 156 | Get-Item "$env:GITHUB_WORKSPACE\out\$variant\$env:PLUGIN_NAME\NLog.dll" -ErrorAction SilentlyContinue | Remove-Item 157 | Get-Item "$env:GITHUB_WORKSPACE\out\$variant\$env:PLUGIN_NAME\SteamKit2.dll" -ErrorAction SilentlyContinue | Remove-Item 158 | Get-Item "$env:GITHUB_WORKSPACE\out\$variant\$env:PLUGIN_NAME\System.IO.Hashing.dll" -ErrorAction SilentlyContinue | Remove-Item 159 | Get-Item "$env:GITHUB_WORKSPACE\out\$variant\$env:PLUGIN_NAME\protobuf-net.Core.dll" -ErrorAction SilentlyContinue | Remove-Item 160 | Get-Item "$env:GITHUB_WORKSPACE\out\$variant\$env:PLUGIN_NAME\protobuf-net.dll" -ErrorAction SilentlyContinue | Remove-Item 161 | 162 | # Create the final zip file 163 | 7z a -bd -slp -tzip -mm=Deflate $compressionArgs "out\$env:PLUGIN_NAME-$variant.zip" "$env:GITHUB_WORKSPACE\out\$variant\*" 164 | 165 | if ($LastExitCode -ne 0) { 166 | throw "Last command failed." 167 | } 168 | } 169 | 170 | foreach ($variant in $env:VARIANTS.Split([char[]] $null, [System.StringSplitOptions]::RemoveEmptyEntries)) { 171 | Start-Job -Name "$variant" $PublishBlock -ArgumentList "$variant" 172 | } 173 | 174 | Get-Job | Receive-Job -Wait 175 | 176 | - name: Upload generic 177 | continue-on-error: true 178 | uses: actions/upload-artifact@v4.6.1 179 | with: 180 | name: ${{ matrix.os }}_${{ env.PLUGIN_NAME }}-generic 181 | path: out/${{ env.PLUGIN_NAME }}-generic.zip 182 | 183 | release: 184 | if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') }} 185 | needs: publish 186 | runs-on: ubuntu-latest 187 | permissions: 188 | id-token: write 189 | attestations: write 190 | packages: write 191 | contents: write 192 | 193 | steps: 194 | - name: Checkout code 195 | uses: actions/checkout@v4.2.2 196 | 197 | - name: Download generic artifact from ubuntu-latest 198 | uses: actions/download-artifact@v4.1.9 199 | with: 200 | name: ubuntu-latest_${{ env.PLUGIN_NAME }}-generic 201 | path: out 202 | 203 | - name: Unzip and copy generic artifact 204 | run: | 205 | mkdir -p attest_provenance 206 | unzip out/${{ env.PLUGIN_NAME }}-generic.zip -d attest_provenance 207 | cp --archive out/${{ env.PLUGIN_NAME }}-generic.zip attest_provenance 208 | 209 | - name: Clean up dll files 210 | run: | 211 | pushd attest_provenance/${{ env.PLUGIN_NAME }} 212 | rm -rf NLog.dll SteamKit2.dll System.IO.Hashing.dll protobuf-net.Core.dll protobuf-net.dll 213 | popd 214 | 215 | - uses: actions/attest-build-provenance@v2 216 | with: 217 | subject-path: 'attest_provenance/*' 218 | 219 | - name: Create GitHub release 220 | id: github_release 221 | uses: softprops/action-gh-release@v2.2.1 222 | with: 223 | tag_name: ${{ github.ref }} 224 | name: ${{ env.PLUGIN_NAME }} ${{ github.ref }} 225 | body_path: .github/RELEASE_TEMPLATE.md 226 | prerelease: true 227 | files: | 228 | out/${{ env.PLUGIN_NAME }}-generic.zip 229 | attest_provenance/${{ env.PLUGIN_NAME }}/ASFFreeGames.dll 230 | -------------------------------------------------------------------------------- /.github/workflows/test_integration.yml: -------------------------------------------------------------------------------- 1 | name: Integration Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - dev 8 | schedule: 9 | - cron: '55 22 */3 * *' 10 | 11 | workflow_dispatch: 12 | 13 | env: 14 | DOTNET_CLI_TELEMETRY_OPTOUT: true 15 | DOTNET_NOLOGO: true 16 | DOTNET_SDK_VERSION: 9.0.x 17 | 18 | concurrency: 19 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 20 | cancel-in-progress: true 21 | 22 | jobs: 23 | integration: 24 | concurrency: 25 | group: integration 26 | if: ${{ github.actor == github.repository_owner }} 27 | strategy: 28 | max-parallel: 1 # only 1 else asf may crash due to parallel login using the same config file 29 | matrix: 30 | configuration: [Release] 31 | asf_docker_tag: [latest, main, released] 32 | 33 | runs-on: ubuntu-latest 34 | 35 | steps: 36 | - name: Checkout code 37 | uses: actions/checkout@v4.2.2 38 | timeout-minutes: 5 39 | with: 40 | submodules: recursive 41 | 42 | - name: Setup .NET Core 43 | timeout-minutes: 5 44 | uses: actions/setup-dotnet@v4 45 | with: 46 | dotnet-version: ${{ env.DOTNET_SDK_VERSION }} 47 | 48 | - name: Verify .NET Core 49 | run: dotnet --info 50 | 51 | - name: Build ${{ matrix.configuration }} 52 | uses: nick-fields/retry@v3 53 | with: 54 | timeout_minutes: 10 55 | max_attempts: 10 56 | shell: pwsh 57 | command: dotnet build -c "${{ matrix.configuration }}" -p:ContinuousIntegrationBuild=true -p:UseAppHost=false -p:isolate=true --nologo 58 | 59 | - name: Populate config.zip 60 | shell: python 61 | run: | 62 | import base64 63 | 64 | data = rb'''${{ secrets.CONFIGZIP_B64 }}''' 65 | with open('config.zip', 'wb') as f: 66 | f.write(base64.b64decode(data)) 67 | 68 | - name: Extract config.zip 69 | run: unzip -qq config.zip 70 | 71 | - name: Create plugin dir 72 | run: | 73 | mkdir -p plugins/ASFFreeGames 74 | cp --archive -f ASFFreeGames/bin/${{ matrix.configuration }}/*/ASFFreeGames.* plugins/ASFFreeGames/ 75 | du -h plugins 76 | 77 | - name: run docker 78 | shell: python 79 | timeout-minutes: 60 80 | run: | 81 | import subprocess 82 | import sys 83 | 84 | 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 }}""" 85 | 86 | with open('out.txt', 'ab+') as out, subprocess.Popen(cmd, shell=True, stdout=out, stderr=out) as process: 87 | def flush_out() -> str: 88 | out.flush() 89 | out.seek(0) 90 | output = out.read() 91 | output = output.decode() 92 | print(output) 93 | return output 94 | 95 | exit_code = None 96 | try: 97 | exit_code = process.wait(timeout=120) 98 | except (TimeoutError, subprocess.TimeoutExpired): 99 | print("Process reached timeout as expected") 100 | process.kill() 101 | exit_code = process.wait(timeout=10) 102 | if exit_code is None: 103 | process.terminate() 104 | output = flush_out() 105 | assert 'CollectGames() [FreeGames] found' in output, "unable to start docker with ASFFreeGames installed" 106 | sys.exit(0) 107 | 108 | print(f'Process stopped earlier than expected with {exit_code} code', file=sys.stderr) 109 | flush_out() 110 | if exit_code != 0: 111 | sys.exit(exit_code) 112 | sys.exit(111) 113 | 114 | - name: compress artifact files 115 | continue-on-error: true 116 | if: always() 117 | run: | 118 | mkdir -p tmp_7z 119 | openssl rand -base64 32 | tr -d '\r\n' > archive_pass.txt 120 | echo ::add-mask::$(cat archive_pass.txt) 121 | if [[ -z "${{ secrets.SEVENZIP_PASSWORD }}" ]]; then 122 | export SEVENZIP_PASSWORD="$(cat archive_pass.txt)" 123 | echo "**One must set SEVENZIP_PASSWORD seceret**" >> $GITHUB_STEP_SUMMARY 124 | echo "- output.7z created with a random password good luck guessing ..." >> $GITHUB_STEP_SUMMARY 125 | fi 126 | 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 127 | 128 | - name: Upload 7z artifact 129 | continue-on-error: true 130 | if: always() 131 | uses: actions/upload-artifact@v4.6.1 132 | with: 133 | name: ${{ matrix.configuration }}_${{ matrix.asf_docker_tag }}_stdout 134 | path: tmp_7z/output.7z 135 | 136 | 137 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "ArchiSteamFarm"] 2 | path = ArchiSteamFarm 3 | url = https://github.com/JustArchiNET/ArchiSteamFarm.git 4 | branch = 6.1.6.7 5 | [submodule "BloomFilter"] 6 | path = BloomFilter 7 | url = https://gist.github.com/8c74d66798a21e05a35b0023573f48e9.git 8 | -------------------------------------------------------------------------------- /ASFFreeGames.Tests/ASFFreeGames.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | enable 5 | 6 | false 7 | 8 | net9.0 9 | 10 | 11 | 12 | 13 | 14 | 15 | runtime; build; native; contentfiles; analyzers; buildtransitive 16 | all 17 | 18 | 19 | runtime; build; native; contentfiles; analyzers; buildtransitive 20 | all 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /ASFFreeGames.Tests/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Runtime.CompilerServices; 3 | 4 | [assembly: CLSCompliant(false)] 5 | -------------------------------------------------------------------------------- /ASFFreeGames.Tests/Configurations/ASFFreeGamesOptionsSaverTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Text; 5 | using System.Text.Json; 6 | using System.Threading.Tasks; 7 | using ASFFreeGames.Configurations; 8 | using Xunit; 9 | 10 | namespace Maxisoft.ASF.Tests.Configurations; 11 | 12 | public class ASFFreeGamesOptionsSaverTests { 13 | [Fact] 14 | #pragma warning disable CA1707 15 | public async Task SaveOptions_WritesValidJson_ParsesCorrectly() { 16 | #pragma warning restore CA1707 17 | 18 | // Arrange 19 | ASFFreeGamesOptions options = new() { 20 | RecheckInterval = TimeSpan.FromHours(1), 21 | RandomizeRecheckInterval = true, 22 | SkipFreeToPlay = false, 23 | SkipDLC = true, 24 | Blacklist = new HashSet { 25 | "game1", 26 | "game2", 27 | "a gamewith2xquote(\")'", 28 | "game with strange char €$çêà /\\\n\r\t" 29 | }, 30 | VerboseLog = null, 31 | Proxy = "http://localhost:1080", 32 | RedditProxy = "socks5://192.168.1.1:1081" 33 | }; 34 | 35 | using MemoryStream memoryStream = new(); 36 | 37 | // Act 38 | _ = await ASFFreeGamesOptionsSaver.SaveOptions(memoryStream, options).ConfigureAwait(true); 39 | 40 | // Assert - Validate UTF-8 encoding 41 | memoryStream.Position = 0; 42 | Assert.NotEmpty(Encoding.UTF8.GetString(memoryStream.ToArray())); 43 | 44 | // Assert - Parse JSON and access properties 45 | memoryStream.Position = 0; 46 | string json = Encoding.UTF8.GetString(memoryStream.ToArray()); 47 | ASFFreeGamesOptions? deserializedOptions = JsonSerializer.Deserialize(json); 48 | 49 | Assert.NotNull(deserializedOptions); 50 | Assert.Equal(options.RecheckInterval, deserializedOptions.RecheckInterval); 51 | Assert.Equal(options.RandomizeRecheckInterval, deserializedOptions.RandomizeRecheckInterval); 52 | Assert.Equal(options.SkipFreeToPlay, deserializedOptions.SkipFreeToPlay); 53 | Assert.Equal(options.SkipDLC, deserializedOptions.SkipDLC); 54 | Assert.Equal(options.Blacklist, deserializedOptions.Blacklist); 55 | Assert.Equal(options.VerboseLog, deserializedOptions.VerboseLog); 56 | Assert.Equal(options.Proxy, deserializedOptions.Proxy); 57 | Assert.Equal(options.RedditProxy, deserializedOptions.RedditProxy); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /ASFFreeGames.Tests/GameIdentifierParserTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using ASFFreeGames.ASFExtensions.Games; 3 | using Maxisoft.ASF.ASFExtensions; 4 | using Maxisoft.ASF.ASFExtensions.Games; 5 | using Xunit; 6 | 7 | namespace Maxisoft.ASF.Tests; 8 | 9 | #pragma warning disable CA1707 10 | 11 | // A test class for the GameIdentifierParser class 12 | public sealed class GameIdentifierParserTests { 13 | // A test method that checks if the TryParse method can handle invalid game identifiers 14 | [Theory] 15 | [InlineData("a/-1")] // Negative AppID 16 | [InlineData("s/0")] // Zero SubID 17 | [InlineData("x/123")] // Unknown type prefix 18 | [InlineData("app/foo")] // Non-numeric ID 19 | [InlineData("")] // Empty query 20 | [InlineData("/")] // Missing ID 21 | [InlineData("a/")] // Missing AppID 22 | [InlineData("s/")] // Missing SubID 23 | public void TryParse_InvalidGameIdentifiers_ReturnsFalseAndDefaultResult(string query) { 24 | // Act and Assert 25 | Assert.False(GameIdentifierParser.TryParse(query, out _)); // Parsing should return false 26 | } 27 | 28 | // A test method that checks if the TryParse method can parse valid game identifiers 29 | [Theory] 30 | [InlineData("a/730", 730, GameIdentifierType.App)] // AppID 730 (Counter-Strike: Global Offensive) 31 | [InlineData("s/303386", 303386, GameIdentifierType.Sub)] // SubID 303386 (Humble Monthly - May 2017) 32 | [InlineData("app/570", 570, GameIdentifierType.App)] // AppID 570 (Dota 2) 33 | [InlineData("sub/29197", 29197, GameIdentifierType.Sub)] // SubID 29197 (Portal Bundle) 34 | [InlineData("570", 570, GameIdentifierType.Sub)] // AppID 570 (Dota 2), default type is Sub 35 | [InlineData("A/440", 440, GameIdentifierType.App)] // AppID 440 (Team Fortress 2) 36 | [InlineData("APP/218620", 218620, GameIdentifierType.App)] // AppID 218620 (PAYDAY 2) 37 | [InlineData("S/29197", 29197, GameIdentifierType.Sub)] // SubID 29197 (Portal Bundle) 38 | public void TryParse_ValidGameIdentifiers_ReturnsTrueAndCorrectResult(string query, long id, GameIdentifierType type) { 39 | // Arrange 40 | // The expected result for the query 41 | GameIdentifier expectedResult = new(id, type); 42 | 43 | // Act and Assert 44 | Assert.True(GameIdentifierParser.TryParse(query, out GameIdentifier result)); // Parsing should return true 45 | Assert.Equal(expectedResult, result); // Result should match the expected result 46 | } 47 | } 48 | #pragma warning restore CA1707 49 | -------------------------------------------------------------------------------- /ASFFreeGames.Tests/GameIdentifierTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using ASFFreeGames.ASFExtensions.Games; 3 | using Maxisoft.ASF.ASFExtensions; 4 | using Maxisoft.ASF.ASFExtensions.Games; 5 | using Xunit; 6 | 7 | namespace Maxisoft.ASF.Tests; 8 | 9 | #pragma warning disable CA1707 // Identifiers should not contain underscores 10 | 11 | // A test class for the GameIdentifier struct 12 | public sealed class GameIdentifierTests { 13 | // A test method that checks if the Valid property returns true for valid game identifiers 14 | [Theory] 15 | [InlineData(730, GameIdentifierType.App)] // AppID 730 (Counter-Strike: Global Offensive) 16 | [InlineData(303386, GameIdentifierType.Sub)] // SubID 303386 (Humble Monthly - May 2017) 17 | [InlineData(570, GameIdentifierType.App)] // AppID 570 (Dota 2) 18 | [InlineData(29197, GameIdentifierType.Sub)] // SubID 29197 (Portal Bundle) 19 | public void Valid_ReturnsTrueForValidGameIdentifiers(long id, GameIdentifierType type) { 20 | // Arrange 21 | // Create a game identifier with the given id and type 22 | GameIdentifier gameIdentifier = new(id, type); 23 | 24 | // Act and Assert 25 | Assert.True(gameIdentifier.Valid); // The Valid property should return true 26 | } 27 | 28 | // A test method that checks if the Valid property returns false for invalid game identifiers 29 | [Theory] 30 | [InlineData(-1, GameIdentifierType.App)] // Negative AppID 31 | [InlineData(0, GameIdentifierType.Sub)] // Zero SubID 32 | [InlineData(456, (GameIdentifierType) 4)] // Invalid type value 33 | public void Valid_ReturnsFalseForInvalidGameIdentifiers(long id, GameIdentifierType type) { 34 | // Arrange 35 | // Create a game identifier with the given id and type 36 | GameIdentifier gameIdentifier = new(id, type); 37 | 38 | // Act and Assert 39 | Assert.False(gameIdentifier.Valid); // The Valid property should return false 40 | } 41 | 42 | // A test method that checks if the ToString method returns the correct string representation of the game identifier 43 | [Theory] 44 | [InlineData(730, GameIdentifierType.App, "a/730")] // AppID 730 (Counter-Strike: Global Offensive) 45 | [InlineData(303386, GameIdentifierType.Sub, "s/303386")] // SubID 303386 (Humble Monthly - May 2017) 46 | [InlineData(570, GameIdentifierType.None, "570")] // AppID 570 (Dota 2), no type specified 47 | public void ToString_ReturnsCorrectStringRepresentation(long id, GameIdentifierType type, string expectedString) { 48 | // Arrange 49 | // Create a game identifier with the given id and type 50 | GameIdentifier gameIdentifier = new(id, type); 51 | 52 | // Act and Assert 53 | Assert.Equal(expectedString, gameIdentifier.ToString()); // The ToString method should return the expected string 54 | } 55 | 56 | // A test method that checks if the GetHashCode method returns the same value for equal game identifiers 57 | [Theory] 58 | [InlineData(730, GameIdentifierType.App)] // AppID 730 (Counter-Strike: Global Offensive) 59 | [InlineData(303386, GameIdentifierType.Sub)] // SubID 303386 (Humble Monthly - May 2017) 60 | [InlineData(570, GameIdentifierType.None)] // AppID 570 (Dota 2), no type specified 61 | public void GetHashCode_ReturnsSameValueForEqualGameIdentifiers(long id, GameIdentifierType type) { 62 | // Arrange 63 | // Create two equal game identifiers with the given id and type 64 | GameIdentifier gameIdentifier1 = new(id, type); 65 | GameIdentifier gameIdentifier2 = new(id, type); 66 | 67 | // Act and Assert 68 | Assert.Equal(gameIdentifier1.GetHashCode(), gameIdentifier2.GetHashCode()); // The GetHashCode method should return the same value for both game identifiers 69 | } 70 | 71 | // A test method that checks if the GetHashCode method returns different values for unequal game identifiers 72 | [Theory] 73 | [InlineData(730, GameIdentifierType.App, 731, GameIdentifierType.App)] // Different AppIDs with same type 74 | [InlineData(303386, GameIdentifierType.Sub, 303387, GameIdentifierType.Sub)] // Different SubIDs with same type 75 | [InlineData(570, GameIdentifierType.App, 570, GameIdentifierType.Sub)] // Same ID with different types 76 | public void GetHashCode_ReturnsDifferentValueForUnequalGameIdentifiers(long id1, GameIdentifierType type1, long id2, GameIdentifierType type2) { 77 | // Arrange 78 | // Create two unequal game identifiers with the given ids and types 79 | GameIdentifier gameIdentifier1 = new(id1, type1); 80 | GameIdentifier gameIdentifier2 = new(id2, type2); 81 | 82 | // Act and Assert 83 | Assert.NotEqual(gameIdentifier1.GetHashCode(), gameIdentifier2.GetHashCode()); // The GetHashCode method should return different values for both game identifiers 84 | } 85 | } 86 | 87 | #pragma warning restore CA1707 88 | -------------------------------------------------------------------------------- /ASFFreeGames.Tests/RandomUtilsTests.cs: -------------------------------------------------------------------------------- 1 | #pragma warning disable CA1707 // Identifiers should not contain underscores 2 | using System; 3 | using System.Diagnostics.CodeAnalysis; 4 | using System.Linq; 5 | using Maxisoft.ASF.Utils; 6 | using Xunit; 7 | 8 | namespace Maxisoft.ASF.Tests; 9 | 10 | public class RandomUtilsTests { 11 | // A static method to provide test data for the theory 12 | public static TheoryData GetTestData() => 13 | new TheoryData { 14 | // mean, std, sample size, margin of error 15 | { 0, 1, 10000, 0.05 }, 16 | { 10, 2, 10000, 0.1 }, 17 | { -5, 3, 50000, 0.15 }, 18 | { 20, 5, 100000, 0.2 } 19 | }; 20 | 21 | // A test method to check if the mean and standard deviation of the normal distribution are close to the expected values 22 | [Theory] 23 | [MemberData(nameof(GetTestData))] 24 | [SuppressMessage("ReSharper", "InconsistentNaming")] 25 | public void NextGaussian_Should_Have_Expected_Mean_And_Std(double mean, double standardDeviation, int sampleSize, double marginOfError) { 26 | // Arrange 27 | RandomUtils.GaussianRandom rng = new(); 28 | 29 | // Act 30 | // Generate a large number of samples from the normal distribution 31 | double[] samples = Enumerable.Range(0, sampleSize).Select(_ => rng.NextGaussian(mean, standardDeviation)).ToArray(); 32 | 33 | // Calculate the sample mean and sample standard deviation using local functions 34 | double sampleMean = Mean(samples); 35 | double sampleStd = StandardDeviation(samples); 36 | 37 | // Assert 38 | // Check if the sample mean and sample standard deviation are close to the expected values within the margin of error 39 | Assert.InRange(sampleMean, mean - marginOfError, mean + marginOfError); 40 | Assert.InRange(sampleStd, standardDeviation - marginOfError, standardDeviation + marginOfError); 41 | } 42 | 43 | // Local function to calculate the mean of a span of doubles 44 | private static double Mean(ReadOnlySpan values) { 45 | // Check if the span is empty 46 | if (values.IsEmpty) { 47 | // Throw an exception 48 | throw new InvalidOperationException("The span is empty."); 49 | } 50 | 51 | // Sum up all the values 52 | double sum = 0; 53 | 54 | foreach (double value in values) { 55 | sum += value; 56 | } 57 | 58 | // Divide by the number of values 59 | return sum / values.Length; 60 | } 61 | 62 | // Local function to calculate the standard deviation of a span of doubles 63 | private static double StandardDeviation(ReadOnlySpan values) { 64 | // Calculate the mean using the local function 65 | double mean = Mean(values); 66 | 67 | // Sum up the squares of the differences from the mean 68 | double sumOfSquares = 0; 69 | 70 | foreach (double value in values) { 71 | sumOfSquares += (value - mean) * (value - mean); 72 | } 73 | 74 | // Divide by the number of values and take the square root 75 | return Math.Sqrt(sumOfSquares / values.Length); 76 | } 77 | } 78 | #pragma warning restore CA1707 // Identifiers should not contain underscores 79 | -------------------------------------------------------------------------------- /ASFFreeGames.Tests/Reddit/RedditHelperTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Buffers; 3 | using System.Collections.Generic; 4 | using System.IO; 5 | using System.Linq; 6 | using System.Reflection; 7 | using System.Text.Json; 8 | using System.Text.Json.Nodes; 9 | using System.Threading.Tasks; 10 | using Maxisoft.ASF.Reddit; 11 | using Maxisoft.Utils.Collections.Spans; 12 | using Xunit; 13 | 14 | namespace Maxisoft.ASF.Tests.Reddit; 15 | 16 | public sealed class RedditHelperTests { 17 | [Fact] 18 | public async Task TestNotEmpty() { 19 | RedditGameEntry[] entries = await LoadAsfinfoEntries().ConfigureAwait(true); 20 | Assert.NotEmpty(entries); 21 | } 22 | 23 | [Theory] 24 | [InlineData("s/762440")] 25 | [InlineData("a/1601550")] 26 | public async Task TestContains(string appid) { 27 | RedditGameEntry[] entries = await LoadAsfinfoEntries().ConfigureAwait(true); 28 | Assert.Contains(new RedditGameEntry(appid, default(ERedditGameEntryKind), long.MaxValue), entries, new GameEntryIdentifierEqualityComparer()); 29 | } 30 | 31 | [Fact] 32 | public async Task TestMaintainOrder() { 33 | RedditGameEntry[] entries = await LoadAsfinfoEntries().ConfigureAwait(true); 34 | int app762440 = Array.FindIndex(entries, static entry => entry.Identifier == "s/762440"); 35 | int app1601550 = Array.FindIndex(entries, static entry => entry.Identifier == "a/1601550"); 36 | Assert.InRange(app762440, 0, long.MaxValue); 37 | Assert.InRange(app1601550, checked(app762440 + 1), long.MaxValue); // app1601550 is after app762440 38 | 39 | int app1631250 = Array.FindIndex(entries, static entry => entry.Identifier == "a/1631250"); 40 | Assert.InRange(app1631250, checked(app1601550 + 1), long.MaxValue); // app1631250 is after app1601550 41 | Assert.Equal(entries.Length - 1, app1631250); 42 | } 43 | 44 | [Fact] 45 | public async Task TestFreeToPlayParsing() { 46 | RedditGameEntry[] entries = await LoadAsfinfoEntries().ConfigureAwait(true); 47 | RedditGameEntry f2PEntry = Array.Find(entries, static entry => entry.Identifier == "a/1631250"); 48 | Assert.True(f2PEntry.IsFreeToPlay); 49 | 50 | RedditGameEntry getEntry(string identifier) => Array.Find(entries, entry => entry.Identifier == identifier); 51 | 52 | f2PEntry = getEntry("a/431650"); // F2P 53 | Assert.True(f2PEntry.IsFreeToPlay); 54 | 55 | f2PEntry = getEntry("a/579730"); 56 | Assert.True(f2PEntry.IsFreeToPlay); 57 | 58 | RedditGameEntry dlcEntry = getEntry("s/791643"); // DLC 59 | Assert.False(dlcEntry.IsFreeToPlay); 60 | 61 | dlcEntry = getEntry("s/791642"); 62 | Assert.False(dlcEntry.IsFreeToPlay); 63 | 64 | RedditGameEntry paidEntry = getEntry("s/762440"); // Warhammer: Vermintide 2 65 | Assert.False(paidEntry.IsFreeToPlay); 66 | 67 | paidEntry = getEntry("a/1601550"); 68 | Assert.False(paidEntry.IsFreeToPlay); 69 | } 70 | 71 | [Fact] 72 | public async Task TestDlcParsing() { 73 | RedditGameEntry[] entries = await LoadAsfinfoEntries().ConfigureAwait(true); 74 | RedditGameEntry f2PEntry = Array.Find(entries, static entry => entry.Identifier == "a/1631250"); 75 | Assert.False(f2PEntry.IsForDlc); 76 | 77 | RedditGameEntry getEntry(string identifier) => Array.Find(entries, entry => entry.Identifier == identifier); 78 | 79 | f2PEntry = getEntry("a/431650"); // F2P 80 | Assert.False(f2PEntry.IsForDlc); 81 | 82 | f2PEntry = getEntry("a/579730"); 83 | Assert.False(f2PEntry.IsForDlc); 84 | 85 | RedditGameEntry dlcEntry = getEntry("s/791643"); // DLC 86 | Assert.True(dlcEntry.IsForDlc); 87 | 88 | dlcEntry = getEntry("s/791642"); 89 | Assert.True(dlcEntry.IsForDlc); 90 | 91 | RedditGameEntry paidEntry = getEntry("s/762440"); // Warhammer: Vermintide 2 92 | Assert.False(paidEntry.IsForDlc); 93 | 94 | paidEntry = getEntry("a/1601550"); 95 | Assert.False(paidEntry.IsForDlc); 96 | } 97 | 98 | private static async Task LoadAsfinfoEntries() { 99 | Assembly assembly = Assembly.GetExecutingAssembly(); 100 | 101 | #pragma warning disable CA2007 102 | await using Stream stream = assembly.GetManifestResourceStream($"{assembly.GetName().Name}.ASFinfo.json")!; 103 | #pragma warning restore CA2007 104 | JsonNode jsonNode = await JsonNode.ParseAsync(stream).ConfigureAwait(false) ?? JsonNode.Parse("{}")!; 105 | 106 | return RedditHelper.LoadMessages(jsonNode["data"]?["children"]!).ToArray(); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /ASFFreeGames.Tests/Redlib/RedlibHtmlParserTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Reflection; 6 | using System.Text; 7 | using System.Threading.Tasks; 8 | using Maxisoft.ASF.Redlib; 9 | using Maxisoft.ASF.Redlib.Html; 10 | using Xunit; 11 | 12 | namespace Maxisoft.ASF.Tests.Redlib; 13 | 14 | public class RedlibHtmlParserTests { 15 | [Fact] 16 | public async Task Test() { 17 | string html = await LoadHtml().ConfigureAwait(true); 18 | 19 | // ReSharper disable once ArgumentsStyleLiteral 20 | IReadOnlyCollection result = RedlibHtmlParser.ParseGamesFromHtml(html, dedup: false); 21 | Assert.NotEmpty(result); 22 | Assert.Equal(25, result.Count); 23 | 24 | Assert.Equal(new DateTimeOffset(2024, 6, 1, 23, 43, 40, TimeSpan.Zero), result.Skip(1).FirstOrDefault().Date); 25 | 26 | // ReSharper disable once ArgumentsStyleLiteral 27 | result = RedlibHtmlParser.ParseGamesFromHtml(html, dedup: true); 28 | Assert.NotEmpty(result); 29 | Assert.Equal(13, result.Count); 30 | } 31 | 32 | private static async Task LoadHtml() { 33 | Assembly assembly = Assembly.GetExecutingAssembly(); 34 | 35 | #pragma warning disable CA2007 36 | await using Stream stream = assembly.GetManifestResourceStream($"{assembly.GetName().Name}.redlib_asfinfo.html")!; 37 | #pragma warning restore CA2007 38 | using StreamReader reader = new(stream, Encoding.UTF8, true); 39 | 40 | return await reader.ReadToEndAsync().ConfigureAwait(false); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /ASFFreeGames.Tests/Redlib/RedlibInstancesListTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Reflection; 5 | using System.Text; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | using ASFFreeGames.Configurations; 9 | using Maxisoft.ASF.Redlib; 10 | using Maxisoft.ASF.Redlib.Html; 11 | using Maxisoft.ASF.Redlib.Instances; 12 | using Xunit; 13 | 14 | namespace Maxisoft.ASF.Tests.Redlib; 15 | 16 | public class RedlibInstanceListTests { 17 | [Fact] 18 | public async Task Test() { 19 | RedlibInstanceList lister = new(new ASFFreeGamesOptions()); 20 | List uris = await RedlibInstanceList.ListFromEmbedded(CancellationToken.None).ConfigureAwait(true); 21 | 22 | Assert.NotEmpty(uris); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /ASFFreeGames.sln: -------------------------------------------------------------------------------- 1 | Microsoft Visual Studio Solution File, Format Version 12.00 2 | # Visual Studio Version 16 3 | VisualStudioVersion = 16.0.30114.105 4 | MinimumVisualStudioVersion = 10.0.40219.1 5 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFFD}") = "ASFFreeGames", "ASFFreeGames\ASFFreeGames.csproj", "{A64A35BD-25B6-4F4F-8C3C-E0CF9CE843F9}" 6 | EndProject 7 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFFD}") = "ArchiSteamFarm", "ArchiSteamFarm\ArchiSteamFarm\ArchiSteamFarm.csproj", "{50744701-4C54-49BE-8189-518DA2A65797}" 8 | EndProject 9 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ASFFreeGames.Tests", "ASFFreeGames.Tests\ASFFreeGames.Tests.csproj", "{CC4DC8D7-AF9D-464D-BC93-DD829B3D1837}" 10 | EndProject 11 | Global 12 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 13 | Debug|Any CPU = Debug|Any CPU 14 | DebugFast|Any CPU = DebugFast|Any CPU 15 | Release|Any CPU = Release|Any CPU 16 | EndGlobalSection 17 | GlobalSection(SolutionProperties) = preSolution 18 | HideSolutionNode = FALSE 19 | EndGlobalSection 20 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 21 | {A64A35BD-25B6-4F4F-8C3C-E0CF9CE843F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 22 | {A64A35BD-25B6-4F4F-8C3C-E0CF9CE843F9}.Debug|Any CPU.Build.0 = Debug|Any CPU 23 | {A64A35BD-25B6-4F4F-8C3C-E0CF9CE843F9}.DebugFast|Any CPU.ActiveCfg = DebugFast|Any CPU 24 | {A64A35BD-25B6-4F4F-8C3C-E0CF9CE843F9}.DebugFast|Any CPU.Build.0 = DebugFast|Any CPU 25 | {A64A35BD-25B6-4F4F-8C3C-E0CF9CE843F9}.Release|Any CPU.ActiveCfg = Release|Any CPU 26 | {A64A35BD-25B6-4F4F-8C3C-E0CF9CE843F9}.Release|Any CPU.Build.0 = Release|Any CPU 27 | {50744701-4C54-49BE-8189-518DA2A65797}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 28 | {50744701-4C54-49BE-8189-518DA2A65797}.Debug|Any CPU.Build.0 = Debug|Any CPU 29 | {50744701-4C54-49BE-8189-518DA2A65797}.DebugFast|Any CPU.ActiveCfg = DebugFast|Any CPU 30 | {50744701-4C54-49BE-8189-518DA2A65797}.DebugFast|Any CPU.Build.0 = DebugFast|Any CPU 31 | {50744701-4C54-49BE-8189-518DA2A65797}.Release|Any CPU.ActiveCfg = Release|Any CPU 32 | {50744701-4C54-49BE-8189-518DA2A65797}.Release|Any CPU.Build.0 = Release|Any CPU 33 | {CC4DC8D7-AF9D-464D-BC93-DD829B3D1837}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 34 | {CC4DC8D7-AF9D-464D-BC93-DD829B3D1837}.Debug|Any CPU.Build.0 = Debug|Any CPU 35 | {CC4DC8D7-AF9D-464D-BC93-DD829B3D1837}.DebugFast|Any CPU.ActiveCfg = DebugFast|Any CPU 36 | {CC4DC8D7-AF9D-464D-BC93-DD829B3D1837}.DebugFast|Any CPU.Build.0 = DebugFast|Any CPU 37 | {CC4DC8D7-AF9D-464D-BC93-DD829B3D1837}.Release|Any CPU.ActiveCfg = Release|Any CPU 38 | {CC4DC8D7-AF9D-464D-BC93-DD829B3D1837}.Release|Any CPU.Build.0 = Release|Any CPU 39 | EndGlobalSection 40 | EndGlobal 41 | -------------------------------------------------------------------------------- /ASFFreeGames/ASFExtentions/Bot/BotContext.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Globalization; 4 | using System.Threading; 5 | using System.Reflection; 6 | using System.Threading.Tasks; 7 | using ASFFreeGames.ASFExtensions.Games; 8 | using Maxisoft.ASF; 9 | using Maxisoft.ASF.AppLists; 10 | using Maxisoft.ASF.Utils.Workarounds; 11 | 12 | namespace ASFFreeGames.ASFExtensions.Bot; 13 | 14 | using Bot = ArchiSteamFarm.Steam.Bot; 15 | using static ArchiSteamFarm.Localization.Strings; 16 | 17 | internal sealed class BotContext : IDisposable { 18 | private const ulong TriesBeforeBlacklistingGameEntry = 5; 19 | 20 | public long RunElapsedMilli => Environment.TickCount64 - LastRunMilli; 21 | 22 | private readonly Dictionary AppRegistrationContexts = new(); 23 | private readonly TimeSpan BlacklistTimeout = TimeSpan.FromDays(1); 24 | private readonly string BotIdentifier; 25 | private readonly CompletedAppList CompletedApps = new(); 26 | private long LastRunMilli; 27 | 28 | public BotContext(Bot bot) { 29 | BotIdentifier = bot.BotName; 30 | NewRun(); 31 | } 32 | 33 | public void Dispose() => CompletedApps.Dispose(); 34 | 35 | public ulong AppTickCount(in GameIdentifier gameIdentifier, bool increment = false) { 36 | ulong res = 0; 37 | 38 | DateTime? dateTime = null; 39 | 40 | if (AppRegistrationContexts.TryGetValue(gameIdentifier, out (ulong counter, DateTime date) tuple)) { 41 | if (DateTime.UtcNow - tuple.date > BlacklistTimeout) { 42 | AppRegistrationContexts.Remove(gameIdentifier); 43 | } 44 | else { 45 | res = tuple.counter; 46 | dateTime = tuple.date; 47 | } 48 | } 49 | 50 | if (increment) { 51 | checked { 52 | res += 1; 53 | } 54 | 55 | AppRegistrationContexts[gameIdentifier] = (res, dateTime ?? DateTime.UtcNow); 56 | } 57 | 58 | return res; 59 | } 60 | 61 | public bool HasApp(in GameIdentifier gameIdentifier) { 62 | if (!gameIdentifier.Valid) { 63 | return false; 64 | } 65 | 66 | if (AppRegistrationContexts.TryGetValue(gameIdentifier, out (ulong counter, DateTime date) tuple) && (tuple.counter >= TriesBeforeBlacklistingGameEntry)) { 67 | if (DateTime.UtcNow - tuple.date > BlacklistTimeout) { 68 | AppRegistrationContexts.Remove(gameIdentifier); 69 | } 70 | else { 71 | return true; 72 | } 73 | } 74 | 75 | if (CompletedApps.Contains(in gameIdentifier)) { 76 | return true; 77 | } 78 | 79 | Bot? bot = Bot.GetBot(BotIdentifier); 80 | 81 | return bot is not null && BotPackageChecker.BotOwnsPackage(bot, checked((uint) gameIdentifier.Id)); 82 | } 83 | 84 | public async Task LoadFromFileSystem(CancellationToken cancellationToken = default) { 85 | string filePath = CompletedAppFilePath(); 86 | await CompletedApps.LoadFromFile(filePath, cancellationToken).ConfigureAwait(false); 87 | } 88 | 89 | public void NewRun() => LastRunMilli = Environment.TickCount64; 90 | 91 | public void RegisterApp(in GameIdentifier gameIdentifier) { 92 | if (!gameIdentifier.Valid || !CompletedApps.Add(in gameIdentifier) || !CompletedApps.Contains(in gameIdentifier)) { 93 | AppRegistrationContexts[gameIdentifier] = (long.MaxValue, DateTime.MaxValue - BlacklistTimeout); 94 | } 95 | } 96 | 97 | public bool RegisterInvalidApp(in GameIdentifier gameIdentifier) => CompletedApps.AddInvalid(in gameIdentifier); 98 | 99 | public async Task SaveToFileSystem(CancellationToken cancellationToken = default) { 100 | string filePath = CompletedAppFilePath(); 101 | await CompletedApps.SaveToFile(filePath, cancellationToken).ConfigureAwait(false); 102 | } 103 | 104 | public bool ShouldHideErrorLogForApp(in GameIdentifier gameIdentifier) => (AppTickCount(in gameIdentifier) > 0) || CompletedApps.ContainsInvalid(in gameIdentifier); 105 | 106 | private string CompletedAppFilePath() { 107 | Bot? bot = Bot.GetBot(BotIdentifier); 108 | 109 | if (bot is null) { 110 | return string.Empty; 111 | } 112 | 113 | string file = bot.GetFilePath(Bot.EFileType.Config); 114 | 115 | string res = file.Replace(".json", CompletedAppList.FileExtension, StringComparison.InvariantCultureIgnoreCase); 116 | 117 | if (res == file) { 118 | throw new FormatException("unable to replace json ext"); 119 | } 120 | 121 | return res; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /ASFFreeGames/ASFExtentions/Bot/BotEqualityComparer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace ASFFreeGames.ASFExtensions.Bot; 5 | 6 | using Bot = ArchiSteamFarm.Steam.Bot; 7 | 8 | internal sealed class BotEqualityComparer : IEqualityComparer { 9 | public bool Equals(Bot? x, Bot? y) { 10 | if (ReferenceEquals(x, y)) { 11 | return true; 12 | } 13 | 14 | if (ReferenceEquals(x, null)) { 15 | return false; 16 | } 17 | 18 | if (ReferenceEquals(y, null)) { 19 | return false; 20 | } 21 | 22 | return (x.BotName == y.BotName) && (x.SteamID == y.SteamID); 23 | } 24 | 25 | public int GetHashCode(Bot? obj) => obj != null ? HashCode.Combine(obj.BotName, obj.SteamID) : 0; 26 | } 27 | -------------------------------------------------------------------------------- /ASFFreeGames/ASFExtentions/Bot/BotName.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics.CodeAnalysis; 3 | 4 | namespace ASFFreeGames.ASFExtensions.Bot { 5 | /// 6 | /// Represents a readonly record struct that encapsulates bot's name (a string) and provides implicit conversion and comparison methods. 7 | /// 8 | 9 | // ReSharper disable once InheritdocConsiderUsage 10 | public readonly record struct BotName(string Name) : IComparable { 11 | // The culture-invariant comparer for string comparison 12 | private static readonly StringComparer Comparer = StringComparer.InvariantCultureIgnoreCase; 13 | 14 | /// 15 | /// Converts a instance to a implicitly. 16 | /// 17 | public static implicit operator string(BotName botName) => botName.Name; 18 | 19 | /// 20 | /// Converts a to a instance implicitly. 21 | /// 22 | [SuppressMessage("Usage", "CA2225:Operator overloads have named alternates", Justification = "The constructor serves as an alternative method.")] 23 | public static implicit operator BotName(string value) => new BotName(value); 24 | 25 | /// 26 | /// Returns the string representation of this instance. 27 | /// 28 | public override string ToString() => Name; 29 | 30 | /// 31 | public bool Equals(BotName other) => Comparer.Equals(Name, other.Name); 32 | 33 | /// 34 | public override int GetHashCode() => Comparer.GetHashCode(Name); 35 | 36 | /// 37 | public int CompareTo(BotName other) => Comparer.Compare(Name, other.Name); 38 | 39 | // Implement the relational operators using the CompareTo method 40 | public static bool operator <(BotName left, BotName right) => left.CompareTo(right) < 0; 41 | public static bool operator <=(BotName left, BotName right) => left.CompareTo(right) <= 0; 42 | public static bool operator >(BotName left, BotName right) => left.CompareTo(right) > 0; 43 | public static bool operator >=(BotName left, BotName right) => left.CompareTo(right) >= 0; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /ASFFreeGames/ASFExtentions/Games/GameIdentifier.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Buffers.Binary; 3 | using System.Diagnostics.CodeAnalysis; 4 | using System.Globalization; 5 | using Maxisoft.ASF.ASFExtensions; 6 | using Maxisoft.ASF.ASFExtensions.Games; 7 | 8 | // ReSharper disable RedundantNullableFlowAttribute 9 | 10 | namespace ASFFreeGames.ASFExtensions.Games; 11 | 12 | /// 13 | /// Represents a readonly record struct that encapsulates a game identifier with a numeric ID and a type. 14 | /// 15 | public readonly record struct GameIdentifier(long Id, GameIdentifierType Type = GameIdentifierType.None) { 16 | /// 17 | /// Gets a value indicating whether the game identifier is valid. 18 | /// 19 | public bool Valid => (Id > 0) && Type is >= GameIdentifierType.None and <= GameIdentifierType.App; 20 | 21 | public override int GetHashCode() => unchecked(((ulong) Id ^ BinaryPrimitives.ReverseEndianness((ulong) Type)).GetHashCode()); 22 | 23 | /// 24 | /// Returns the string representation of the game identifier. 25 | /// 26 | [SuppressMessage("Design", "CA1065")] 27 | public override string ToString() => 28 | Type switch { 29 | GameIdentifierType.None => Id.ToString(CultureInfo.InvariantCulture), 30 | GameIdentifierType.Sub => $"s/{Id}", 31 | GameIdentifierType.App => $"a/{Id}", 32 | _ => throw new ArgumentOutOfRangeException(nameof(Type)) 33 | }; 34 | 35 | /// 36 | /// Tries to parse a game identifier from a query string. 37 | /// 38 | /// The query string to parse. 39 | /// The resulting game identifier if the parsing was successful. 40 | /// True if the parsing was successful; otherwise, false. 41 | public static bool TryParse([NotNull] ReadOnlySpan query, out GameIdentifier result) => GameIdentifierParser.TryParse(query, out result); 42 | } 43 | -------------------------------------------------------------------------------- /ASFFreeGames/ASFExtentions/Games/GameIdentifierParser.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics.CodeAnalysis; 3 | using ASFFreeGames.ASFExtensions.Games; 4 | 5 | namespace Maxisoft.ASF.ASFExtensions.Games; 6 | 7 | /// 8 | /// Represents a static class that provides methods for parsing game identifiers from strings. 9 | /// 10 | internal static class GameIdentifierParser { 11 | /// 12 | /// Tries to parse a game identifier from a query string. 13 | /// 14 | /// The query string to parse. 15 | /// The resulting game identifier if the parsing was successful. 16 | /// True if the parsing was successful; otherwise, false. 17 | public static bool TryParse(ReadOnlySpan query, out GameIdentifier result) { 18 | if (query.IsEmpty) // Check for empty query first 19 | { 20 | result = default(GameIdentifier); 21 | 22 | return false; 23 | } 24 | 25 | ulong gameID; 26 | ReadOnlySpan type; 27 | GameIdentifierType identifierType = GameIdentifierType.None; 28 | 29 | int index = query.IndexOf('/'); 30 | 31 | if ((index > 0) && (query.Length > index + 1)) { 32 | if (!ulong.TryParse(query[(index + 1)..], out gameID) || (gameID == 0)) { 33 | result = default(GameIdentifier); 34 | 35 | return false; 36 | } 37 | 38 | type = query[..index]; 39 | } 40 | else if (ulong.TryParse(query, out gameID) && (gameID > 0)) { 41 | type = "SUB"; 42 | } 43 | else { 44 | result = default(GameIdentifier); 45 | 46 | return false; 47 | } 48 | 49 | if (type.Length > 3) { 50 | type = type[..3]; 51 | } 52 | 53 | if (type.Length == 1) { 54 | char c = char.ToUpperInvariant(type[0]); 55 | 56 | identifierType = c switch { 57 | 'A' => GameIdentifierType.App, 58 | 'S' => GameIdentifierType.Sub, 59 | _ => identifierType 60 | }; 61 | } 62 | 63 | if (identifierType is GameIdentifierType.None) { 64 | switch (type.Length) { 65 | case 0: 66 | break; 67 | case 1 when char.ToUpperInvariant(type[0]) == 'A': 68 | case 3 when (char.ToUpperInvariant(type[0]) == 'A') && (char.ToUpperInvariant(type[1]) == 'P') && (char.ToUpperInvariant(type[2]) == 'P'): 69 | identifierType = GameIdentifierType.App; 70 | 71 | break; 72 | case 1 when char.ToUpperInvariant(type[0]) == 'S': 73 | case 3 when (char.ToUpperInvariant(type[0]) == 'S') && (char.ToUpperInvariant(type[1]) == 'U') && (char.ToUpperInvariant(type[2]) == 'B'): 74 | identifierType = GameIdentifierType.Sub; 75 | 76 | break; 77 | default: 78 | result = default(GameIdentifier); 79 | 80 | return false; 81 | } 82 | } 83 | 84 | result = new GameIdentifier((long) gameID, identifierType); 85 | 86 | return result.Valid; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /ASFFreeGames/ASFExtentions/Games/GameIdentifierType.cs: -------------------------------------------------------------------------------- 1 | namespace Maxisoft.ASF.ASFExtensions.Games; 2 | 3 | public enum GameIdentifierType : sbyte { 4 | None = 0, 5 | Sub = 1, 6 | App = 2 7 | } 8 | -------------------------------------------------------------------------------- /ASFFreeGames/ASFFreeGames.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | Library 4 | true 5 | True 6 | pdbonly 7 | net9.0 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | .github\CODE_OF_CONDUCT.md 25 | 26 | 27 | .github\CONTRIBUTING.md 28 | 29 | 30 | .github\dependabot.yml 31 | 32 | 33 | .github\FUNDING.yml 34 | 35 | 36 | .github\ISSUE_TEMPLATE\bug_report.md 37 | 38 | 39 | .github\ISSUE_TEMPLATE\feature_request.md 40 | 41 | 42 | .github\PULL_REQUEST_TEMPLATE.md 43 | 44 | 45 | .github\RELEASE_TEMPLATE.md 46 | 47 | 48 | .github\renovate.json5 49 | 50 | 51 | .github\SECURITY.md 52 | 53 | 54 | .github\SUPPORT.md 55 | 56 | 57 | .github\workflows\bump-asf-reference.yml 58 | 59 | 60 | .github\workflows\ci.yml 61 | 62 | 63 | .github\workflows\keepalive.yml 64 | 65 | 66 | .github\workflows\publish.yml 67 | 68 | 69 | .github\workflows\test_integration.yml 70 | 71 | 72 | Directory.Build.props 73 | 74 | 75 | Directory.Packages.props 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /ASFFreeGames/ASFFreeGames.csproj.DotSettings: -------------------------------------------------------------------------------- 1 |  2 | No 3 | InternalsOnly 4 | 5 | -------------------------------------------------------------------------------- /ASFFreeGames/AppLists/CompletedAppList.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Buffers; 3 | using System.Diagnostics.CodeAnalysis; 4 | using System.IO; 5 | using System.IO.Compression; 6 | using System.Reflection; 7 | using System.Runtime.InteropServices; 8 | using System.Threading; 9 | using System.Threading.Tasks; 10 | using ASFFreeGames.ASFExtensions.Games; 11 | using Maxisoft.ASF.ASFExtensions; 12 | 13 | namespace Maxisoft.ASF.AppLists; 14 | 15 | internal sealed class CompletedAppList : IDisposable { 16 | internal long[]? CompletedAppBuffer { get; private set; } 17 | internal const int CompletedAppBufferSize = 128; 18 | internal Memory CompletedAppMemory => ((Memory) CompletedAppBuffer!)[..CompletedAppBufferSize]; 19 | internal RecentGameMapping CompletedApps { get; } 20 | internal const int FileCompletedAppBufferSize = CompletedAppBufferSize * sizeof(long) * 2; 21 | private static readonly ArrayPool LongMemoryPool = ArrayPool.Create(CompletedAppBufferSize, 10); 22 | private static readonly char Endianness = BitConverter.IsLittleEndian ? 'l' : 'b'; 23 | public static readonly string FileExtension = $".fg{Endianness}dict"; 24 | 25 | public CompletedAppList() { 26 | CompletedAppBuffer = LongMemoryPool.Rent(CompletedAppBufferSize); 27 | CompletedApps = new RecentGameMapping(CompletedAppMemory); 28 | } 29 | 30 | ~CompletedAppList() => ReturnBuffer(); 31 | 32 | private bool ReturnBuffer() { 33 | if (CompletedAppBuffer is { Length: > 0 }) { 34 | LongMemoryPool.Return(CompletedAppBuffer); 35 | 36 | return true; 37 | } 38 | 39 | return false; 40 | } 41 | 42 | public void Dispose() { 43 | if (ReturnBuffer()) { 44 | CompletedAppBuffer = Array.Empty(); 45 | } 46 | 47 | GC.SuppressFinalize(this); 48 | } 49 | 50 | public bool Add(in GameIdentifier gameIdentifier) => CompletedApps.Add(in gameIdentifier); 51 | public bool AddInvalid(in GameIdentifier gameIdentifier) => CompletedApps.AddInvalid(in gameIdentifier); 52 | 53 | public bool Contains(in GameIdentifier gameIdentifier) => CompletedApps.Contains(in gameIdentifier); 54 | 55 | public bool ContainsInvalid(in GameIdentifier gameIdentifier) => CompletedApps.ContainsInvalid(in gameIdentifier); 56 | } 57 | 58 | public static class CompletedAppListSerializer { 59 | [SuppressMessage("Code", "CAC001:ConfigureAwaitChecker")] 60 | internal static async Task SaveToFile(this CompletedAppList appList, string filePath, CancellationToken cancellationToken = default) { 61 | if (string.IsNullOrWhiteSpace(filePath)) { 62 | return; 63 | } 64 | #pragma warning disable CA2007 65 | await using FileStream sourceStream = new( 66 | filePath, 67 | FileMode.Create, FileAccess.Write, FileShare.None, 68 | bufferSize: CompletedAppList.FileCompletedAppBufferSize, useAsync: true 69 | ); 70 | 71 | // ReSharper disable once UseAwaitUsing 72 | using BrotliStream encoder = new(sourceStream, CompressionMode.Compress); 73 | 74 | ChangeBrotliEncoderToFastCompress(encoder); 75 | #pragma warning restore CA2007 76 | 77 | // note: cannot use WriteAsync call due to span & async incompatibilities 78 | // but it shouldn't be an issue as we use a bigger bufferSize than the written payload 79 | encoder.Write(MemoryMarshal.Cast(appList.CompletedAppMemory.Span)); 80 | await encoder.FlushAsync(cancellationToken).ConfigureAwait(false); 81 | } 82 | 83 | [SuppressMessage("Code", "CAC001:ConfigureAwaitChecker")] 84 | internal static async Task LoadFromFile(this CompletedAppList appList, string filePath, CancellationToken cancellationToken = default) { 85 | if (string.IsNullOrWhiteSpace(filePath)) { 86 | return false; 87 | } 88 | 89 | try { 90 | #pragma warning disable CA2007 91 | await using FileStream sourceStream = new( 92 | filePath, 93 | FileMode.Open, FileAccess.Read, FileShare.Read, 94 | bufferSize: CompletedAppList.FileCompletedAppBufferSize, useAsync: true 95 | ); 96 | 97 | // ReSharper disable once UseAwaitUsing 98 | using BrotliStream decoder = new(sourceStream, CompressionMode.Decompress); 99 | #pragma warning restore CA2007 100 | ChangeBrotliEncoderToFastCompress(decoder); 101 | 102 | // ReSharper disable once UseAwaitUsing 103 | using MemoryStream ms = new(); 104 | await decoder.CopyToAsync(ms, cancellationToken).ConfigureAwait(false); 105 | await decoder.FlushAsync(cancellationToken).ConfigureAwait(false); 106 | 107 | if (appList.CompletedAppBuffer is { Length: > 0 } && (ms.Length == appList.CompletedAppMemory.Length * sizeof(long))) { 108 | ms.Seek(0, SeekOrigin.Begin); 109 | int size = ms.Read(MemoryMarshal.Cast(appList.CompletedAppMemory.Span)); 110 | 111 | if (size != appList.CompletedAppMemory.Length * sizeof(long)) { 112 | ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericError("[FreeGames] Unable to load previous completed app dict", nameof(LoadFromFile)); 113 | } 114 | 115 | try { 116 | appList.CompletedApps.Reload(); 117 | } 118 | catch (InvalidDataException e) { 119 | ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericWarningException(e, $"[FreeGames] {nameof(appList.CompletedApps)}.{nameof(appList.CompletedApps.Reload)}"); 120 | appList.CompletedApps.Reload(true); 121 | 122 | return false; 123 | } 124 | } 125 | else { 126 | ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericError("[FreeGames] Unable to load previous completed app dict", nameof(LoadFromFile)); 127 | } 128 | 129 | return true; 130 | } 131 | catch (FileNotFoundException) { 132 | return false; 133 | } 134 | } 135 | 136 | /// 137 | /// Workaround in order to set brotli's compression level to fastest. 138 | /// Uses reflexions as the public methods got removed in the ASF public binary. 139 | /// 140 | /// 141 | /// 142 | private static void ChangeBrotliEncoderToFastCompress(BrotliStream encoder, int level = 1) { 143 | try { 144 | FieldInfo? field = encoder.GetType().GetField("_encoder", BindingFlags.NonPublic | BindingFlags.Instance); 145 | 146 | if (field?.GetValue(encoder) is BrotliEncoder previous) { 147 | BrotliEncoder brotliEncoder = default(BrotliEncoder); 148 | 149 | try { 150 | MethodInfo? method = brotliEncoder.GetType().GetMethod("SetQuality", BindingFlags.NonPublic | BindingFlags.Instance); 151 | method?.Invoke(brotliEncoder, new object?[] { level }); 152 | field.SetValue(encoder, brotliEncoder); 153 | } 154 | catch (Exception) { 155 | brotliEncoder.Dispose(); 156 | 157 | throw; 158 | } 159 | 160 | previous.Dispose(); 161 | } 162 | } 163 | catch (Exception e) { 164 | ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericDebuggingException(e, nameof(ChangeBrotliEncoderToFastCompress)); 165 | } 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /ASFFreeGames/AppLists/RecentGameMapping.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics.CodeAnalysis; 4 | using System.IO; 5 | using System.Runtime.InteropServices; 6 | using System.Text; 7 | using ASFFreeGames.ASFExtensions.Games; 8 | using Maxisoft.ASF.ASFExtensions; 9 | using Maxisoft.Utils.Collections.Spans; 10 | 11 | namespace Maxisoft.ASF.AppLists; 12 | 13 | public class RecentGameMapping { 14 | private static ReadOnlySpan MagicBytes => "mdict"u8; 15 | private readonly Memory Buffer; 16 | private Memory SizeMemory; 17 | private Memory DictData; 18 | 19 | public RecentGameMapping(Memory buffer, bool reset = true) { 20 | Buffer = buffer; 21 | 22 | if (reset) { 23 | InitMemories(); 24 | } 25 | else { 26 | LoadMemories(false); 27 | } 28 | } 29 | 30 | internal void InitMemories() { 31 | if (MagicBytes.Length > sizeof(long)) { 32 | #pragma warning disable CA2201 33 | throw new Exception(); 34 | #pragma warning restore CA2201 35 | } 36 | 37 | MagicBytes.CopyTo(MemoryMarshal.Cast(Buffer.Span)[..MagicBytes.Length]); 38 | 39 | int start = 1; 40 | 41 | SizeMemory = Buffer.Slice(start, ++start); 42 | DictData = Buffer[start..]; 43 | 44 | DictData.Span.Clear(); 45 | CountRef = 0; 46 | } 47 | 48 | public void Reload(bool fix = false) => LoadMemories(fix); 49 | public void Reset() => InitMemories(); 50 | 51 | internal void LoadMemories(bool allowFixes) { 52 | ReadOnlySpan magicBytes = MagicBytes; 53 | ReadOnlySpan magicSpan = MemoryMarshal.Cast(Buffer.Span)[..magicBytes.Length]; 54 | 55 | // ReSharper disable once LoopCanBeConvertedToQuery 56 | for (int i = 0; i < magicBytes.Length; i++) { 57 | if (magicSpan[i] != magicBytes[i]) { 58 | if (allowFixes) { 59 | Reset(); 60 | 61 | continue; 62 | } 63 | 64 | throw new InvalidDataException(); 65 | } 66 | } 67 | 68 | int start = 1; 69 | 70 | SizeMemory = Buffer.Slice(start, ++start); 71 | DictData = Buffer[start..]; 72 | 73 | if ((CountRef < 0) && !allowFixes) { 74 | throw new InvalidDataException(); 75 | } 76 | 77 | SpanDict dict = SpanDict.CreateFromBuffer(DictData.Span); 78 | 79 | if (dict.Count != CountRef) { 80 | if (!allowFixes) { 81 | throw new InvalidDataException("Counters mismatch"); 82 | } 83 | 84 | CountRef = dict.Count; 85 | } 86 | } 87 | 88 | public long Count => CountRef; 89 | 90 | internal ref long CountRef => ref SizeMemory.Span[0]; 91 | 92 | public SpanDict Dict => SpanDict.CreateFromBuffer(DictData.Span, null, checked((int) Count)); 93 | 94 | public bool Contains(in GameIdentifier item) => TryGetDate(in item, out long date) && (date > 0); 95 | 96 | public bool ContainsInvalid(in GameIdentifier item) => TryGetDate(in item, out long date) && (date < 0); 97 | 98 | public bool TryGetDate(in GameIdentifier key, out long value) => Dict.TryGetValue(in key, out value); 99 | 100 | public bool Add(in GameIdentifier item) => Add(in item, DateTimeOffset.UtcNow.ToUnixTimeSeconds()); 101 | 102 | public bool AddInvalid(in GameIdentifier item) => Add(in item, -DateTimeOffset.UtcNow.ToUnixTimeSeconds()); 103 | 104 | public bool Add(in GameIdentifier item, long date) { 105 | SpanDict dict = Dict; 106 | 107 | if (EnsureFillFactor(ref dict) != 0) { 108 | CountRef = dict.Count; 109 | } 110 | 111 | if (!dict.ContainsKey(in item)) { 112 | dict.Add(in item, date); 113 | CountRef = dict.Count; 114 | 115 | return true; 116 | } 117 | 118 | return false; 119 | } 120 | 121 | private static int EnsureFillFactor(ref SpanDict dict, int maxIter = 32) { 122 | int c = maxIter; 123 | int res = 0; 124 | 125 | if (dict.Capacity * 8 < dict.Count * 10) { 126 | while ((dict.Count > 0) && (c-- > 0)) { 127 | long minValue = long.MaxValue; 128 | GameIdentifier minId = default; 129 | 130 | foreach (ref KeyValuePair pair in dict) { 131 | long value = Math.Abs(pair.Value); 132 | 133 | if (value <= minValue) { 134 | minValue = value; 135 | minId = pair.Key; 136 | } 137 | } 138 | 139 | if (dict.Remove(in minId)) { 140 | res++; 141 | } 142 | } 143 | } 144 | 145 | return res; 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /ASFFreeGames/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Runtime.CompilerServices; 3 | 4 | [assembly: CLSCompliant(false)] 5 | [assembly: InternalsVisibleTo("ASFFreeGames.Tests")] 6 | -------------------------------------------------------------------------------- /ASFFreeGames/CollectIntervalManager.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using Maxisoft.ASF.Utils; 4 | 5 | namespace Maxisoft.ASF; 6 | 7 | // The interface that defines the contract for the CollectIntervalManager class 8 | /// 9 | /// 10 | /// An interface that provides methods to manage the collect interval for the ASFFreeGamesPlugin. 11 | /// 12 | internal interface ICollectIntervalManager : IDisposable { 13 | /// 14 | /// Starts the timer with a random initial and regular delay if it is not already started. 15 | /// 16 | void StartTimerIfNeeded(); 17 | 18 | /// 19 | /// Changes the collect interval to a new random value and resets the timer. 20 | /// 21 | /// The source object passed to the timer callback. 22 | /// The new random collect interval. 23 | TimeSpan RandomlyChangeCollectInterval(object? source); 24 | 25 | /// 26 | /// Stops the timer and disposes it. 27 | /// 28 | void StopTimer(); 29 | } 30 | 31 | internal sealed class CollectIntervalManager(IASFFreeGamesPlugin plugin) : ICollectIntervalManager { 32 | private static readonly RandomUtils.GaussianRandom Random = new(); 33 | 34 | /// 35 | /// Gets a value that indicates whether to randomize the collect interval or not. 36 | /// 37 | /// 38 | /// A value of 1 if Options.RandomizeRecheckInterval is true or null, or a value of 0 otherwise. 39 | /// 40 | /// 41 | /// This property is used to multiply the standard deviation of the normal distribution used to generate the random delay in the GetRandomizedTimerDelay method. If this property returns 0, then the random delay will be equal to the mean value. 42 | /// 43 | private int RandomizeIntervalSwitch => plugin.Options.RandomizeRecheckInterval ?? true ? 1 : 0; 44 | 45 | // The timer instance 46 | private Timer? Timer; 47 | 48 | public void Dispose() => StopTimer(); 49 | 50 | // The public method that starts the timer if needed 51 | public void StartTimerIfNeeded() { 52 | if (Timer is null) { 53 | // Get a random initial delay 54 | TimeSpan initialDelay = GetRandomizedTimerDelay(30, 6 * RandomizeIntervalSwitch, 1, 5 * 60); 55 | 56 | // Get a random regular delay 57 | TimeSpan regularDelay = GetRandomizedTimerDelay(plugin.Options.RecheckInterval.TotalSeconds, 7 * 60 * RandomizeIntervalSwitch); 58 | 59 | // Create a new timer with the collect operation as the callback 60 | Timer = new Timer(plugin.CollectGamesOnClock); 61 | 62 | // Start the timer with the initial and regular delays 63 | Timer.Change(initialDelay, regularDelay); 64 | } 65 | } 66 | 67 | /// 68 | /// Calculates a random delay using a normal distribution with a mean of Options.RecheckInterval.TotalSeconds and a standard deviation of 7 minutes. 69 | /// 70 | /// The randomized delay. 71 | /// 72 | private TimeSpan GetRandomizedTimerDelay() => GetRandomizedTimerDelay(plugin.Options.RecheckInterval.TotalSeconds, 7 * 60 * RandomizeIntervalSwitch); 73 | 74 | public TimeSpan RandomlyChangeCollectInterval(object? source) { 75 | // Calculate a random delay using GetRandomizedTimerDelay method 76 | TimeSpan delay = GetRandomizedTimerDelay(); 77 | ResetTimer(() => new Timer(state => plugin.CollectGamesOnClock(state), source, delay, delay)); 78 | 79 | return delay; 80 | } 81 | 82 | public void StopTimer() => ResetTimer(null); 83 | 84 | /// 85 | /// Calculates a random delay using a normal distribution with a given mean and standard deviation. 86 | /// 87 | /// The mean of the normal distribution in seconds. 88 | /// The standard deviation of the normal distribution in seconds. 89 | /// The minimum value of the random delay in seconds. The default value is 11 minutes. 90 | /// The maximum value of the random delay in seconds. The default value is 1 hour. 91 | /// The randomized delay. 92 | /// 93 | /// The random number is clamped between the minSeconds and maxSeconds parameters. 94 | /// This method uses the NextGaussian method from the RandomUtils class to generate normally distributed random numbers. 95 | /// See [Random nextGaussian() method in Java with Examples] for more details on how to implement NextGaussian in C#. 96 | /// 97 | private static TimeSpan GetRandomizedTimerDelay(double meanSeconds, double stdSeconds, double minSeconds = 11 * 60, double maxSeconds = 60 * 60) { 98 | double randomNumber = stdSeconds != 0 ? Random.NextGaussian(meanSeconds, stdSeconds) : meanSeconds; 99 | 100 | TimeSpan delay = TimeSpan.FromSeconds(randomNumber); 101 | 102 | // Convert delay to seconds 103 | double delaySeconds = delay.TotalSeconds; 104 | 105 | // Clamp the delay between minSeconds and maxSeconds in seconds 106 | delaySeconds = Math.Max(delaySeconds, minSeconds); 107 | delaySeconds = Math.Min(delaySeconds, maxSeconds); 108 | 109 | // Convert delay back to TimeSpan 110 | delay = TimeSpan.FromSeconds(delaySeconds); 111 | 112 | return delay; 113 | } 114 | 115 | private void ResetTimer(Func? newTimerFactory) { 116 | Timer?.Dispose(); 117 | Timer = null; 118 | 119 | if (newTimerFactory is not null) { 120 | Timer = newTimerFactory(); 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /ASFFreeGames/Commands/CommandDispatcher.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using ArchiSteamFarm.Steam; 6 | using ASFFreeGames.Commands.GetIp; 7 | using ASFFreeGames.Configurations; 8 | 9 | namespace ASFFreeGames.Commands { 10 | // Implement the IBotCommand interface 11 | internal sealed class CommandDispatcher(ASFFreeGamesOptions options) : IBotCommand, IDisposable { 12 | // Declare a private field for the plugin options instance 13 | private readonly ASFFreeGamesOptions Options = options ?? throw new ArgumentNullException(nameof(options)); 14 | 15 | // Declare a private field for the dictionary that maps command names to IBotCommand instances 16 | private readonly Dictionary Commands = new(StringComparer.OrdinalIgnoreCase) { 17 | { "GETIP", new GetIPCommand() }, 18 | { "FREEGAMES", new FreeGamesCommand(options) } 19 | }; 20 | 21 | public async Task Execute(Bot? bot, string message, string[] args, ulong steamID = 0, CancellationToken cancellationToken = default) { 22 | try { 23 | if (args is { Length: > 0 }) { 24 | // Try to get the corresponding IBotCommand instance from the commands dictionary based on the first argument 25 | if (Commands.TryGetValue(args[0], out IBotCommand? command)) { 26 | // Delegate the command execution to the IBotCommand instance, passing the bot and other parameters 27 | return await command.Execute(bot, message, args, steamID, cancellationToken).ConfigureAwait(false); 28 | } 29 | } 30 | } 31 | catch (Exception ex) { 32 | // Check if verbose logging is enabled or if the build is in debug mode 33 | // ReSharper disable once RedundantAssignment 34 | bool verboseLogging = Options.VerboseLog ?? false; 35 | #if DEBUG 36 | verboseLogging = true; // Enforce verbose logging in debug mode 37 | #endif 38 | 39 | if (verboseLogging) { 40 | // Log the detailed stack trace and full description of the exception 41 | ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericException(ex); 42 | } 43 | else { 44 | // Log a compact error message 45 | ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericError($"An error occurred: {ex.GetType().Name} {ex.Message}"); 46 | } 47 | } 48 | 49 | return null; // Return null if an exception occurs or if no command is found 50 | } 51 | 52 | public void Dispose() { 53 | foreach ((_, IBotCommand? value) in Commands) { 54 | if (value is IDisposable disposable) { 55 | disposable.Dispose(); 56 | } 57 | } 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /ASFFreeGames/Commands/GetIp/GetIPCommand.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Globalization; 3 | using System.IO; 4 | using System.Text.Json; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using ArchiSteamFarm.Localization; 8 | using ArchiSteamFarm.Steam; 9 | using ArchiSteamFarm.Web; 10 | using ArchiSteamFarm.Web.Responses; 11 | using JsonSerializer = System.Text.Json.JsonSerializer; 12 | 13 | namespace ASFFreeGames.Commands.GetIp; 14 | 15 | // ReSharper disable once ClassNeverInstantiated.Local 16 | internal sealed class GetIPCommand : IBotCommand { 17 | private const string GetIPAddressUrl = "https://httpbin.org/ip"; 18 | 19 | public async Task Execute(Bot? bot, string message, string[] args, ulong steamID = 0, CancellationToken cancellationToken = default) { 20 | WebBrowser? web = IBotCommand.GetWebBrowser(bot); 21 | 22 | if (web is null) { 23 | return IBotCommand.FormatBotResponse(bot, "unable to get a valid web browser"); 24 | } 25 | 26 | if (cancellationToken.IsCancellationRequested) { 27 | return ""; 28 | } 29 | 30 | try { 31 | #pragma warning disable CAC001 32 | #pragma warning disable CA2007 33 | await using StreamResponse? result = await web.UrlGetToStream(new Uri(GetIPAddressUrl), cancellationToken: cancellationToken).ConfigureAwait(false); 34 | #pragma warning restore CA2007 35 | #pragma warning restore CAC001 36 | 37 | if (result?.Content is null) { return null; } 38 | 39 | GetIpReponse? reponse = await JsonSerializer.DeserializeAsync(result.Content, cancellationToken: cancellationToken).ConfigureAwait(false); 40 | string? origin = reponse?.Origin; 41 | 42 | if (!string.IsNullOrWhiteSpace(origin)) { 43 | return IBotCommand.FormatBotResponse(bot, origin); 44 | } 45 | } 46 | catch (Exception e) when (e is JsonException or IOException) { 47 | #pragma warning disable CA1863 48 | return IBotCommand.FormatBotResponse(bot, string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsInvalid, e.Message)); 49 | #pragma warning restore CA1863 50 | } 51 | 52 | return null; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /ASFFreeGames/Commands/GetIp/GetIpReponse.cs: -------------------------------------------------------------------------------- 1 | namespace ASFFreeGames.Commands.GetIp; 2 | 3 | internal record GetIpReponse(string Origin) { } 4 | -------------------------------------------------------------------------------- /ASFFreeGames/Commands/GetIp/GetIpReponseContext.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace ASFFreeGames.Commands.GetIp; 4 | 5 | //[JsonSourceGenerationOptions(WriteIndented = false, UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)] 6 | //[JsonSerializable(typeof(GetIpReponse))] 7 | //internal partial class GetIpReponseContext : JsonSerializerContext { } 8 | -------------------------------------------------------------------------------- /ASFFreeGames/Commands/IBotCommand.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using System.Threading.Tasks; 3 | using ArchiSteamFarm.Steam; 4 | using ArchiSteamFarm.Web; 5 | 6 | namespace ASFFreeGames.Commands; 7 | 8 | // Define an interface named IBotCommand 9 | internal interface IBotCommand { 10 | // Define a method named Execute that takes the bot, message, args, steamID, and cancellationToken parameters and returns a string response 11 | Task Execute(Bot? bot, string message, string[] args, ulong steamID = 0, CancellationToken cancellationToken = default); 12 | 13 | protected static string FormatBotResponse(Bot? bot, string resp) => bot?.Commands?.FormatBotResponse(resp) ?? ArchiSteamFarm.Steam.Interaction.Commands.FormatStaticResponse(resp); 14 | protected static WebBrowser? GetWebBrowser(Bot? bot) => bot?.ArchiWebHandler?.WebBrowser ?? ArchiSteamFarm.Core.ASF.WebBrowser; 15 | } 16 | -------------------------------------------------------------------------------- /ASFFreeGames/Configurations/ASFFreeGamesOptions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Globalization; 4 | using System.Linq; 5 | using System.Text.Json.Serialization; 6 | using ArchiSteamFarm.Steam; 7 | using ASFFreeGames.ASFExtensions.Games; 8 | using Maxisoft.ASF; 9 | using Maxisoft.ASF.ASFExtensions; 10 | 11 | namespace ASFFreeGames.Configurations; 12 | 13 | public class ASFFreeGamesOptions { 14 | // Use TimeSpan instead of long for representing time intervals 15 | [JsonPropertyName("recheckInterval")] 16 | public TimeSpan RecheckInterval { get; set; } = TimeSpan.FromMinutes(30); 17 | 18 | // Use Nullable instead of bool? for nullable value types 19 | [JsonPropertyName("randomizeRecheckInterval")] 20 | public bool? RandomizeRecheckInterval { get; set; } 21 | 22 | [JsonPropertyName("skipFreeToPlay")] 23 | public bool? SkipFreeToPlay { get; set; } 24 | 25 | // ReSharper disable once InconsistentNaming 26 | [JsonPropertyName("skipDLC")] 27 | public bool? SkipDLC { get; set; } 28 | 29 | // Use IReadOnlyCollection instead of HashSet for blacklist property 30 | [JsonPropertyName("blacklist")] 31 | public IReadOnlyCollection Blacklist { get; set; } = new HashSet(); 32 | 33 | [JsonPropertyName("verboseLog")] 34 | public bool? VerboseLog { get; set; } 35 | 36 | #region IsBlacklisted 37 | public bool IsBlacklisted(in GameIdentifier gid) { 38 | if (Blacklist.Count <= 0) { 39 | return false; 40 | } 41 | 42 | return Blacklist.Contains(gid.ToString()) || Blacklist.Contains(gid.Id.ToString(CultureInfo.InvariantCulture)); 43 | } 44 | 45 | public bool IsBlacklisted(in Bot? bot) => bot is null || ((Blacklist.Count > 0) && Blacklist.Contains($"bot/{bot.BotName}")); 46 | #endregion 47 | 48 | #region proxy 49 | [JsonPropertyName("proxy")] 50 | public string? Proxy { get; set; } 51 | 52 | [JsonPropertyName("redditProxy")] 53 | public string? RedditProxy { get; set; } 54 | 55 | [JsonPropertyName("redlibProxy")] 56 | public string? RedlibProxy { get; set; } 57 | #endregion 58 | 59 | [JsonPropertyName("redlibInstanceUrl")] 60 | #pragma warning disable CA1056 61 | public string? RedlibInstanceUrl { get; set; } = "https://raw.githubusercontent.com/redlib-org/redlib-instances/main/instances.json"; 62 | #pragma warning restore CA1056 63 | } 64 | -------------------------------------------------------------------------------- /ASFFreeGames/Configurations/ASFFreeGamesOptionsContext.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | using ASFFreeGames.Configurations; 3 | 4 | namespace Maxisoft.ASF.Configurations; 5 | 6 | //[JsonSourceGenerationOptions(WriteIndented = false, UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] 7 | //[JsonSerializable(typeof(ASFFreeGamesOptions))] 8 | //internal partial class ASFFreeGamesOptionsContext : JsonSerializerContext { } 9 | -------------------------------------------------------------------------------- /ASFFreeGames/Configurations/ASFFreeGamesOptionsLoader.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Buffers; 3 | using System.Collections.Generic; 4 | using System.IO; 5 | using System.Text.Json; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | using ArchiSteamFarm; 9 | using ASFFreeGames.Commands.GetIp; 10 | using ASFFreeGames.Configurations; 11 | using Microsoft.Extensions.Configuration; 12 | 13 | namespace Maxisoft.ASF.Configurations; 14 | 15 | public static class ASFFreeGamesOptionsLoader { 16 | public static void Bind(ref ASFFreeGamesOptions options) { 17 | // ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract 18 | options ??= new ASFFreeGamesOptions(); 19 | Semaphore.Wait(); 20 | 21 | try { 22 | IConfigurationRoot configurationRoot = CreateConfigurationRoot(); 23 | 24 | IEnumerable blacklist = configurationRoot.GetValue("Blacklist", options.Blacklist) ?? options.Blacklist; 25 | options.Blacklist = new HashSet(blacklist, StringComparer.InvariantCultureIgnoreCase); 26 | 27 | options.VerboseLog = configurationRoot.GetValue("VerboseLog", options.VerboseLog); 28 | options.RecheckInterval = TimeSpan.FromMilliseconds(configurationRoot.GetValue("RecheckIntervalMs", options.RecheckInterval.TotalMilliseconds)); 29 | options.SkipFreeToPlay = configurationRoot.GetValue("SkipFreeToPlay", options.SkipFreeToPlay); 30 | options.SkipDLC = configurationRoot.GetValue("SkipDLC", options.SkipDLC); 31 | options.RandomizeRecheckInterval = configurationRoot.GetValue("RandomizeRecheckInterval", options.RandomizeRecheckInterval); 32 | options.Proxy = configurationRoot.GetValue("Proxy", options.Proxy); 33 | options.RedditProxy = configurationRoot.GetValue("RedditProxy", options.RedditProxy); 34 | options.RedlibProxy = configurationRoot.GetValue("RedlibProxy", options.RedlibProxy); 35 | options.RedlibInstanceUrl = configurationRoot.GetValue("RedlibInstanceUrl", options.RedlibInstanceUrl); 36 | } 37 | finally { 38 | Semaphore.Release(); 39 | } 40 | } 41 | 42 | private static IConfigurationRoot CreateConfigurationRoot() { 43 | IConfigurationRoot configurationRoot = new ConfigurationBuilder() 44 | .SetBasePath(Path.GetFullPath(BasePath)) 45 | .AddJsonFile(DefaultJsonFile, true, false) 46 | .AddEnvironmentVariables("FREEGAMES_") 47 | .Build(); 48 | 49 | return configurationRoot; 50 | } 51 | 52 | private static readonly SemaphoreSlim Semaphore = new(1, 1); 53 | 54 | public static async Task Save(ASFFreeGamesOptions options, CancellationToken cancellationToken) { 55 | string path = Path.Combine(BasePath, DefaultJsonFile); 56 | 57 | await Semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); 58 | 59 | try { 60 | #pragma warning disable CAC001 61 | #pragma warning disable CA2007 62 | await using FileStream fs = new(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.ReadWrite); 63 | #pragma warning restore CA2007 64 | #pragma warning restore CAC001 65 | byte[] buffer = new byte[fs.Length > 0 ? (int) fs.Length + 1 : 1 << 15]; 66 | 67 | int read = await fs.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); 68 | 69 | try { 70 | fs.Position = 0; 71 | fs.SetLength(0); 72 | int written = await ASFFreeGamesOptionsSaver.SaveOptions(fs, options, true, cancellationToken).ConfigureAwait(false); 73 | fs.SetLength(written); 74 | } 75 | 76 | catch (Exception) { 77 | fs.Position = 0; 78 | 79 | await fs.WriteAsync(((ReadOnlyMemory) buffer)[..read], cancellationToken).ConfigureAwait(false); 80 | fs.SetLength(read); 81 | 82 | throw; 83 | } 84 | } 85 | finally { 86 | Semaphore.Release(); 87 | } 88 | } 89 | 90 | public static string BasePath => SharedInfo.ConfigDirectory; 91 | public const string DefaultJsonFile = "freegames.json.config"; 92 | } 93 | -------------------------------------------------------------------------------- /ASFFreeGames/Configurations/ASFFreeGamesOptionsSaver.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Buffers; 3 | using System.Collections.Generic; 4 | using System.Diagnostics.CodeAnalysis; 5 | using System.Globalization; 6 | using System.IO; 7 | using System.Runtime.CompilerServices; 8 | using System.Text; 9 | using System.Text.Json; 10 | using System.Text.Json.Nodes; 11 | using System.Threading; 12 | using System.Threading.Tasks; 13 | 14 | #nullable enable 15 | namespace ASFFreeGames.Configurations; 16 | 17 | public static class ASFFreeGamesOptionsSaver { 18 | public static async Task SaveOptions([NotNull] Stream stream, [NotNull] ASFFreeGamesOptions options, bool checkValid = true, CancellationToken cancellationToken = default) { 19 | using IMemoryOwner memory = MemoryPool.Shared.Rent(1 << 15); 20 | int written = CreateOptionsBuffer(options, memory); 21 | 22 | if (checkValid) { 23 | PseudoValidate(memory, written); 24 | } 25 | 26 | await stream.WriteAsync(memory.Memory[..written], cancellationToken).ConfigureAwait(false); 27 | 28 | return written; 29 | } 30 | 31 | private static void PseudoValidate(IMemoryOwner memory, int written) { 32 | JsonNode? doc = JsonNode.Parse(Encoding.UTF8.GetString(memory.Memory[..written].Span)); 33 | 34 | doc?["skipFreeToPlay"]?.GetValue(); 35 | } 36 | 37 | internal static int CreateOptionsBuffer(ASFFreeGamesOptions options, IMemoryOwner memory) { 38 | Span buffer = memory.Memory.Span; 39 | buffer.Clear(); 40 | 41 | int written = 0; 42 | written += WriteJsonString("{\n"u8, buffer, written); 43 | 44 | written += WriteNameAndProperty("recheckInterval"u8, options.RecheckInterval, buffer, written); 45 | written += WriteNameAndProperty("randomizeRecheckInterval"u8, options.RandomizeRecheckInterval, buffer, written); 46 | written += WriteNameAndProperty("skipFreeToPlay"u8, options.SkipFreeToPlay, buffer, written); 47 | written += WriteNameAndProperty("skipDLC"u8, options.SkipDLC, buffer, written); 48 | written += WriteNameAndProperty("blacklist"u8, options.Blacklist, buffer, written); 49 | written += WriteNameAndProperty("verboseLog"u8, options.VerboseLog, buffer, written); 50 | written += WriteNameAndProperty("proxy"u8, options.Proxy, buffer, written); 51 | written += WriteNameAndProperty("redditProxy"u8, options.RedditProxy, buffer, written); 52 | written += WriteNameAndProperty("redlibProxy"u8, options.RedlibProxy, buffer, written); 53 | written += WriteNameAndProperty("redlibInstanceUrl"u8, options.RedlibInstanceUrl, buffer, written); 54 | RemoveTrailingCommaAndLineReturn(buffer, ref written); 55 | 56 | written += WriteJsonString("\n}"u8, buffer, written); 57 | 58 | if (written >= buffer.Length) { 59 | throw new InvalidOperationException("Buffer overflow while saving options"); 60 | } 61 | 62 | return written; 63 | } 64 | 65 | private static void RemoveTrailingCommaAndLineReturn(Span buffer, ref int written) { 66 | int c; 67 | 68 | do { 69 | c = RemoveTrailing(buffer, "\n"u8, ref written); 70 | c += RemoveTrailing(buffer, ","u8, ref written); 71 | } while (c > 0); 72 | } 73 | 74 | private static int RemoveTrailing(Span buffer, ReadOnlySpan target, ref int written) { 75 | Span sub = buffer[..written]; 76 | int c = 0; 77 | 78 | while (!sub.IsEmpty) { 79 | if (sub.EndsWith(target)) { 80 | written -= target.Length; 81 | sub = sub[..written]; 82 | c += 1; 83 | } 84 | else { 85 | break; 86 | } 87 | } 88 | 89 | return c; 90 | } 91 | 92 | [MethodImpl(MethodImplOptions.AggressiveOptimization)] 93 | private static int WriteEscapedJsonString(string str, Span buffer, int written) { 94 | const byte quote = (byte) '"'; 95 | const byte backslash = (byte) '\\'; 96 | 97 | int startIndex = written; 98 | buffer[written++] = quote; 99 | Span cstr = stackalloc char[1]; 100 | ReadOnlySpan span = str.AsSpan(); 101 | 102 | // ReSharper disable once ForCanBeConvertedToForeach 103 | for (int index = 0; index < span.Length; index++) { 104 | char c = span[index]; 105 | 106 | switch (c) { 107 | case '"': 108 | buffer[written++] = backslash; 109 | buffer[written++] = quote; 110 | 111 | break; 112 | case '\\': 113 | buffer[written++] = backslash; 114 | buffer[written++] = backslash; 115 | 116 | break; 117 | case '\b': 118 | buffer[written++] = backslash; 119 | buffer[written++] = (byte) 'b'; 120 | 121 | break; 122 | case '\f': 123 | buffer[written++] = backslash; 124 | buffer[written++] = (byte) 'f'; 125 | 126 | break; 127 | case '\n': 128 | buffer[written++] = backslash; 129 | buffer[written++] = (byte) 'n'; 130 | 131 | break; 132 | case '\r': 133 | buffer[written++] = backslash; 134 | buffer[written++] = (byte) 'r'; 135 | 136 | break; 137 | case '\t': 138 | buffer[written++] = backslash; 139 | buffer[written++] = (byte) 't'; 140 | 141 | break; 142 | default: 143 | // Optimize for common case of ASCII characters 144 | if (c < 128) { 145 | buffer[written++] = (byte) c; 146 | } 147 | else { 148 | cstr[0] = c; 149 | written += WriteJsonString(cstr, buffer, written); 150 | } 151 | 152 | break; 153 | } 154 | } 155 | 156 | buffer[written++] = quote; 157 | 158 | return written - startIndex; 159 | } 160 | 161 | [MethodImpl(MethodImplOptions.AggressiveOptimization)] 162 | private static int WriteNameAndProperty(ReadOnlySpan name, T value, Span buffer, int written) { 163 | int startIndex = written; 164 | written += WriteJsonString("\""u8, buffer, written); 165 | written += WriteJsonString(name, buffer, written); 166 | written += WriteJsonString("\": "u8, buffer, written); 167 | 168 | if (value is null) { 169 | written += WriteJsonString("null"u8, buffer, written); 170 | } 171 | else { 172 | written += value switch { 173 | string str => WriteEscapedJsonString(str, buffer, written), 174 | #pragma warning disable CA1308 175 | bool b => WriteJsonString(b ? "true"u8 : "false"u8, buffer, written), 176 | #pragma warning restore CA1308 177 | IReadOnlyCollection collection => WriteJsonArray(collection, buffer, written), 178 | TimeSpan timeSpan => WriteEscapedJsonString(timeSpan.ToString(), buffer, written), 179 | _ => throw new ArgumentException($"Unsupported type for property {Encoding.UTF8.GetString(name)}: {value.GetType()}") 180 | }; 181 | } 182 | 183 | written += WriteJsonString(","u8, buffer, written); 184 | written += WriteJsonString("\n"u8, buffer, written); 185 | 186 | return written - startIndex; 187 | } 188 | 189 | private static int WriteJsonArray(IEnumerable collection, Span buffer, int written) { 190 | int startIndex = written; 191 | written += WriteJsonString("["u8, buffer, written); 192 | bool first = true; 193 | 194 | foreach (string item in collection) { 195 | if (!first) { 196 | written += WriteJsonString(","u8, buffer, written); 197 | } 198 | 199 | written += WriteEscapedJsonString(item, buffer, written); 200 | first = false; 201 | } 202 | 203 | written += WriteJsonString("]"u8, buffer, written); 204 | 205 | return written - startIndex; 206 | } 207 | 208 | [MethodImpl(MethodImplOptions.AggressiveOptimization | MethodImplOptions.AggressiveInlining)] 209 | private static int WriteJsonString(ReadOnlySpan str, Span buffer, int written) { 210 | str.CopyTo(buffer[written..(written + str.Length)]); 211 | 212 | return str.Length; 213 | } 214 | 215 | [MethodImpl(MethodImplOptions.AggressiveOptimization | MethodImplOptions.AggressiveInlining)] 216 | private static int WriteJsonString(ReadOnlySpan str, Span buffer, int written) { 217 | int encodedLength = Encoding.UTF8.GetBytes(str, buffer[written..]); 218 | 219 | return encodedLength; 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /ASFFreeGames/ContextRegistry.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | using System.Collections.Generic; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using ArchiSteamFarm.Steam; 6 | using ASFFreeGames.ASFExtensions.Bot; 7 | using Maxisoft.ASF.ASFExtensions; 8 | 9 | namespace Maxisoft.ASF { 10 | /// 11 | /// Defines an interface for accessing and saving instances in a read-only manner. 12 | /// 13 | internal interface IRegistryReadOnly { 14 | /// 15 | /// Gets the instance associated with the specified . 16 | /// 17 | /// The instance. 18 | /// The instance if found; otherwise, null. 19 | BotContext? GetBotContext(Bot bot); 20 | } 21 | 22 | /// 23 | /// Defines an interface for accessing and saving instances with read/write operations. 24 | /// 25 | internal interface IContextRegistry : IRegistryReadOnly { 26 | /// 27 | /// Removes the instance associated with the specified . 28 | /// 29 | /// The instance. 30 | /// True if the removal was successful; otherwise, false. 31 | ValueTask RemoveBotContext(Bot bot); 32 | 33 | /// 34 | /// Saves the instance associated with the specified . 35 | /// 36 | /// The instance. 37 | /// The instance. 38 | /// The cancellation token. 39 | Task SaveBotContext(Bot bot, BotContext context, CancellationToken cancellationToken); 40 | } 41 | 42 | /// 43 | /// Represents a class that manages the instances for each bot using a concurrent dictionary. 44 | /// 45 | internal sealed class ContextRegistry : IContextRegistry { 46 | // A concurrent dictionary that maps bot names to bot contexts 47 | private readonly ConcurrentDictionary BotContexts = new(); 48 | 49 | /// 50 | public BotContext? GetBotContext(Bot bot) => BotContexts.GetValueOrDefault(bot.BotName); 51 | 52 | /// 53 | public ValueTask RemoveBotContext(Bot bot) => ValueTask.FromResult(BotContexts.TryRemove(bot.BotName, out _)); 54 | 55 | /// 56 | public Task SaveBotContext(Bot bot, BotContext context, CancellationToken cancellationToken) => Task.FromResult(BotContexts[bot.BotName] = context); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /ASFFreeGames/ECollectGameRequestSource.cs: -------------------------------------------------------------------------------- 1 | namespace Maxisoft.ASF; 2 | 3 | internal enum ECollectGameRequestSource { 4 | None = 0, 5 | RequestedByUser = 1, 6 | Scheduled = 2, 7 | } 8 | -------------------------------------------------------------------------------- /ASFFreeGames/FreeGames/Strategies/EListFreeGamesStrategy.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | // ReSharper disable once CheckNamespace 4 | namespace Maxisoft.ASF.FreeGames.Strategies; 5 | 6 | [Flags] 7 | public enum EListFreeGamesStrategy { 8 | None = 0, 9 | Reddit = 1 << 0, 10 | Redlib = 1 << 1 11 | } 12 | -------------------------------------------------------------------------------- /ASFFreeGames/FreeGames/Strategies/HttpRequestRedlibException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net; 3 | using Maxisoft.ASF.Redlib; 4 | 5 | // ReSharper disable once CheckNamespace 6 | namespace Maxisoft.ASF.FreeGames.Strategies; 7 | 8 | public class HttpRequestRedlibException : RedlibException { 9 | public required HttpStatusCode? StatusCode { get; init; } 10 | public required Uri? Uri { get; init; } 11 | 12 | public HttpRequestRedlibException() { } 13 | public HttpRequestRedlibException(string message) : base(message) { } 14 | public HttpRequestRedlibException(string message, Exception inner) : base(message, inner) { } 15 | } 16 | -------------------------------------------------------------------------------- /ASFFreeGames/FreeGames/Strategies/IListFreeGamesStrategy.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics.CodeAnalysis; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using Maxisoft.ASF.Reddit; 7 | 8 | // ReSharper disable once CheckNamespace 9 | namespace Maxisoft.ASF.FreeGames.Strategies; 10 | 11 | [SuppressMessage("ReSharper", "RedundantNullableFlowAttribute")] 12 | public interface IListFreeGamesStrategy : IDisposable { 13 | Task> GetGames([NotNull] ListFreeGamesContext context, CancellationToken cancellationToken); 14 | 15 | public static Exception ExceptionFromTask([NotNull] Task task) { 16 | if (task is { IsFaulted: true, Exception: not null }) { 17 | return task.Exception.InnerExceptions.Count == 1 ? task.Exception.InnerExceptions[0] : task.Exception; 18 | } 19 | 20 | if (task.IsCanceled) { 21 | return new TaskCanceledException(); 22 | } 23 | 24 | throw new InvalidOperationException("Unknown task state"); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /ASFFreeGames/FreeGames/Strategies/ListFreeGamesContext.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using ASFFreeGames.Configurations; 3 | using Maxisoft.ASF.HttpClientSimple; 4 | 5 | // ReSharper disable once CheckNamespace 6 | namespace Maxisoft.ASF.FreeGames.Strategies; 7 | 8 | public sealed record ListFreeGamesContext(ASFFreeGamesOptions Options, Lazy HttpClient, uint Retry = 5) { 9 | public required SimpleHttpClientFactory HttpClientFactory { get; init; } 10 | public EListFreeGamesStrategy PreviousSucessfulStrategy { get; set; } 11 | 12 | public required IListFreeGamesStrategy Strategy { get; init; } 13 | } 14 | -------------------------------------------------------------------------------- /ASFFreeGames/FreeGames/Strategies/ListFreeGamesMainStrategy.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics.CodeAnalysis; 4 | using System.Linq; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using Maxisoft.ASF.HttpClientSimple; 8 | using Maxisoft.ASF.Reddit; 9 | 10 | // ReSharper disable once CheckNamespace 11 | namespace Maxisoft.ASF.FreeGames.Strategies; 12 | 13 | [SuppressMessage("ReSharper", "RedundantNullableFlowAttribute")] 14 | public class ListFreeGamesMainStrategy : IListFreeGamesStrategy { 15 | private readonly RedditListFreeGamesStrategy RedditStrategy = new(); 16 | private readonly RedlibListFreeGamesStrategy RedlibStrategy = new(); 17 | 18 | private SemaphoreSlim StrategySemaphore { get; } = new(1, 1); // prevents concurrent run and access to internal state 19 | 20 | public void Dispose() { 21 | Dispose(true); 22 | GC.SuppressFinalize(this); 23 | } 24 | 25 | public async Task> GetGames([NotNull] ListFreeGamesContext context, CancellationToken cancellationToken) { 26 | await StrategySemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); 27 | 28 | try { 29 | return await DoGetGames(context, cancellationToken).ConfigureAwait(false); 30 | } 31 | finally { 32 | StrategySemaphore.Release(); 33 | } 34 | } 35 | 36 | protected virtual void Dispose(bool disposing) { 37 | if (disposing) { 38 | RedditStrategy.Dispose(); 39 | RedlibStrategy.Dispose(); 40 | StrategySemaphore.Dispose(); 41 | } 42 | } 43 | 44 | private async Task> DoGetGames([NotNull] ListFreeGamesContext context, CancellationToken cancellationToken) { 45 | using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); 46 | List disposables = []; 47 | 48 | try { 49 | Task> redditTask1 = FirstTryRedditStrategy(context, disposables, cts.Token); 50 | disposables.Add(redditTask1); 51 | 52 | try { 53 | await WaitForFirstTryRedditStrategy(context, redditTask1, cts.Token).ConfigureAwait(false); 54 | } 55 | catch (Exception) { 56 | // ignored and handled below 57 | } 58 | 59 | if (redditTask1.IsCompletedSuccessfully) { 60 | IReadOnlyCollection result = await redditTask1.ConfigureAwait(false); 61 | 62 | if (result.Count > 0) { 63 | context.PreviousSucessfulStrategy |= EListFreeGamesStrategy.Reddit; 64 | 65 | return result; 66 | } 67 | } 68 | 69 | CancellationTokenSource cts2 = CancellationTokenSource.CreateLinkedTokenSource(cts.Token); 70 | disposables.Add(cts2); 71 | cts2.CancelAfter(TimeSpan.FromSeconds(45)); 72 | 73 | Task> redlibTask = RedlibStrategy.GetGames(context with { HttpClient = new Lazy(() => context.HttpClientFactory.CreateForRedlib()) }, cts2.Token); 74 | disposables.Add(redlibTask); 75 | 76 | Task> redditTask2 = LastTryRedditStrategy(context, redditTask1, cts2.Token); 77 | disposables.Add(redditTask2); 78 | 79 | context.PreviousSucessfulStrategy = EListFreeGamesStrategy.None; 80 | 81 | Task>[] strategiesTasks = [redditTask1, redditTask2, redlibTask]; // note that order matters 82 | 83 | try { 84 | IReadOnlyCollection? res = await WaitForStrategiesTasks(cts.Token, strategiesTasks).ConfigureAwait(false); 85 | 86 | if (res is { Count: > 0 }) { 87 | return res; 88 | } 89 | } 90 | finally { 91 | if (redditTask1.IsCompletedSuccessfully || redditTask2.IsCompletedSuccessfully) { 92 | context.PreviousSucessfulStrategy |= EListFreeGamesStrategy.Reddit; 93 | } 94 | 95 | #pragma warning disable CA1849 96 | if (redlibTask is { IsCompletedSuccessfully: true, Result.Count: > 0 }) { 97 | #pragma warning restore CA1849 98 | context.PreviousSucessfulStrategy |= EListFreeGamesStrategy.Redlib; 99 | } 100 | 101 | await cts.CancelAsync().ConfigureAwait(false); 102 | await cts2.CancelAsync().ConfigureAwait(false); 103 | 104 | try { 105 | await Task.WhenAll(strategiesTasks).ConfigureAwait(false); 106 | } 107 | catch (Exception) { 108 | // ignored 109 | } 110 | } 111 | 112 | List exceptions = new(strategiesTasks.Length); 113 | exceptions.AddRange(from task in strategiesTasks where task.IsFaulted || task.IsCanceled select IListFreeGamesStrategy.ExceptionFromTask(task)); 114 | 115 | switch (exceptions.Count) { 116 | case 1: 117 | throw exceptions[0]; 118 | case > 0: 119 | throw new AggregateException(exceptions); 120 | } 121 | } 122 | finally { 123 | foreach (IDisposable disposable in disposables) { 124 | disposable.Dispose(); 125 | } 126 | } 127 | 128 | cancellationToken.ThrowIfCancellationRequested(); 129 | 130 | throw new InvalidOperationException("This should never happen"); 131 | } 132 | 133 | // ReSharper disable once SuggestBaseTypeForParameter 134 | private async Task> FirstTryRedditStrategy(ListFreeGamesContext context, List disposables, CancellationToken cancellationToken) { 135 | CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); 136 | disposables.Add(cts); 137 | cts.CancelAfter(TimeSpan.FromSeconds(10)); 138 | 139 | if (!context.PreviousSucessfulStrategy.HasFlag(EListFreeGamesStrategy.Reddit)) { 140 | await Task.Delay(1000, cancellationToken).ConfigureAwait(false); 141 | } 142 | 143 | return await RedditStrategy.GetGames( 144 | context with { 145 | Retry = 1, 146 | HttpClient = new Lazy(() => context.HttpClientFactory.CreateForReddit()) 147 | }, cts.Token 148 | ).ConfigureAwait(false); 149 | } 150 | 151 | private async Task> LastTryRedditStrategy(ListFreeGamesContext context, Task firstTryTask, CancellationToken cancellationToken) { 152 | if (!firstTryTask.IsCompleted) { 153 | try { 154 | await firstTryTask.WaitAsync(cancellationToken).ConfigureAwait(false); 155 | } 156 | catch (Exception) { 157 | // ignored it'll be handled by caller 158 | } 159 | } 160 | 161 | cancellationToken.ThrowIfCancellationRequested(); 162 | 163 | return await RedditStrategy.GetGames( 164 | context with { 165 | Retry = checked(context.Retry - 1), 166 | HttpClient = new Lazy(() => context.HttpClientFactory.CreateForReddit()) 167 | }, cancellationToken 168 | ).ConfigureAwait(false); 169 | } 170 | 171 | private static async Task WaitForFirstTryRedditStrategy(ListFreeGamesContext context, Task redditTask, CancellationToken cancellationToken) { 172 | if (context.PreviousSucessfulStrategy.HasFlag(EListFreeGamesStrategy.Reddit)) { 173 | try { 174 | await Task.WhenAny(redditTask, Task.Delay(2500, cancellationToken)).ConfigureAwait(false); 175 | } 176 | catch (Exception e) { 177 | if (e is OperationCanceledException or TimeoutException && cancellationToken.IsCancellationRequested) { 178 | throw; 179 | } 180 | } 181 | } 182 | } 183 | 184 | private static async Task?> WaitForStrategiesTasks(CancellationToken cancellationToken, params Task>[] p) { 185 | LinkedList>> tasks = []; 186 | 187 | foreach (Task> task in p) { 188 | tasks.AddLast(task); 189 | } 190 | 191 | while ((tasks.Count != 0) && !cancellationToken.IsCancellationRequested) { 192 | try { 193 | await Task.WhenAny(tasks).ConfigureAwait(false); 194 | } 195 | catch (Exception) { 196 | // ignored 197 | } 198 | 199 | LinkedListNode>>? taskNode = tasks.First; 200 | 201 | while (taskNode is not null) { 202 | if (taskNode.Value.IsCompletedSuccessfully) { 203 | IReadOnlyCollection result = await taskNode.Value.ConfigureAwait(false); 204 | 205 | if (result.Count > 0) { 206 | return result; 207 | } 208 | } 209 | 210 | if (taskNode.Value.IsCompleted) { 211 | tasks.Remove(taskNode.Value); 212 | taskNode = tasks.First; 213 | 214 | continue; 215 | } 216 | 217 | taskNode = taskNode.Next; 218 | } 219 | } 220 | 221 | return null; 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /ASFFreeGames/FreeGames/Strategies/RedditListFreeGamesStrategy.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Diagnostics.CodeAnalysis; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using Maxisoft.ASF.Reddit; 6 | 7 | // ReSharper disable once CheckNamespace 8 | namespace Maxisoft.ASF.FreeGames.Strategies; 9 | 10 | [SuppressMessage("ReSharper", "RedundantNullableFlowAttribute")] 11 | public sealed class RedditListFreeGamesStrategy : IListFreeGamesStrategy { 12 | public void Dispose() { } 13 | 14 | public async Task> GetGames([NotNull] ListFreeGamesContext context, CancellationToken cancellationToken) { 15 | cancellationToken.ThrowIfCancellationRequested(); 16 | 17 | return await RedditHelper.GetGames(context.HttpClient.Value, context.Retry, cancellationToken).ConfigureAwait(false); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /ASFFreeGames/FreeGames/Strategies/RedlibListFreeGamesStrategy.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics.CodeAnalysis; 4 | using System.Linq; 5 | using System.Net.Http; 6 | using System.Reflection; 7 | using System.Threading; 8 | using System.Threading.Tasks; 9 | using ArchiSteamFarm.Core; 10 | using Maxisoft.ASF.HttpClientSimple; 11 | using Maxisoft.ASF.Reddit; 12 | using Maxisoft.ASF.Redlib; 13 | using Maxisoft.ASF.Redlib.Html; 14 | using Maxisoft.ASF.Redlib.Instances; 15 | 16 | // ReSharper disable once CheckNamespace 17 | namespace Maxisoft.ASF.FreeGames.Strategies; 18 | 19 | [SuppressMessage("ReSharper", "RedundantNullableFlowAttribute")] 20 | public sealed class RedlibListFreeGamesStrategy : IListFreeGamesStrategy { 21 | private readonly SemaphoreSlim DownloadSemaphore = new(4, 4); 22 | private readonly CachedRedlibInstanceListStorage InstanceListCache = new(Array.Empty(), DateTimeOffset.MinValue); 23 | 24 | public void Dispose() => DownloadSemaphore.Dispose(); 25 | 26 | public async Task> GetGames([NotNull] ListFreeGamesContext context, CancellationToken cancellationToken) { 27 | cancellationToken.ThrowIfCancellationRequested(); 28 | 29 | CachedRedlibInstanceList instanceList = new(context.Options, InstanceListCache); 30 | 31 | List instances = await instanceList.ListInstances(context.HttpClientFactory.CreateForGithub(), cancellationToken).ConfigureAwait(false); 32 | instances = Shuffle(instances); 33 | using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); 34 | cts.CancelAfter(60_000); 35 | 36 | LinkedList>> tasks = []; 37 | Task>[] allTasks = []; 38 | 39 | try { 40 | foreach (Uri uri in instances) { 41 | tasks.AddLast(DownloadUsingInstance(context.HttpClient.Value, uri, context.Retry, cts.Token)); 42 | } 43 | 44 | allTasks = tasks.ToArray(); 45 | IReadOnlyCollection result = await MonitorDownloads(tasks, cts.Token).ConfigureAwait(false); 46 | 47 | if (result.Count > 0) { 48 | return result; 49 | } 50 | } 51 | finally { 52 | await cts.CancelAsync().ConfigureAwait(false); 53 | 54 | try { 55 | await Task.WhenAll(tasks).ConfigureAwait(false); 56 | } 57 | catch (Exception) { 58 | // ignored 59 | } 60 | 61 | foreach (Task> task in allTasks) { 62 | task.Dispose(); 63 | } 64 | } 65 | 66 | List exceptions = new(allTasks.Length); 67 | exceptions.AddRange(from task in allTasks where task.IsCanceled || task.IsFaulted select IListFreeGamesStrategy.ExceptionFromTask(task)); 68 | 69 | switch (exceptions.Count) { 70 | case 1: 71 | throw exceptions[0]; 72 | case > 0: 73 | throw new AggregateException(exceptions); 74 | default: 75 | cts.Token.ThrowIfCancellationRequested(); 76 | 77 | throw new InvalidOperationException("This should never happen"); 78 | } 79 | } 80 | 81 | /// 82 | /// Tries to get the date from the HTTP headers using reflection. 83 | /// 84 | /// The HTTP response. 85 | /// The date from the HTTP headers, or null if not found. 86 | /// 87 | /// This method is used to work around the trimmed binary issue in the release build. 88 | /// In the release build, the property is trimmed, and the Date 89 | /// property is not available. This method uses reflection to safely try to get the date from the HTTP headers. 90 | /// 91 | public static DateTimeOffset? GetDateFromHeaders([NotNull] HttpResponseMessage response) { 92 | try { 93 | Type headersType = response.Headers.GetType(); 94 | 95 | // Try to get the "Date" property using reflection 96 | PropertyInfo? dateProperty = headersType.GetProperty("Date"); 97 | 98 | if (dateProperty != null) { 99 | // Get the value of the "Date" property 100 | object? dateValue = dateProperty.GetValue(response.Headers); 101 | 102 | // Check if the value is of type DateTimeOffset? 103 | if (dateValue is DateTimeOffset?) { 104 | return (DateTimeOffset?) dateValue; 105 | } 106 | } 107 | } 108 | catch (Exception) { 109 | // ignored 110 | } 111 | 112 | return null; 113 | } 114 | 115 | private async Task> DoDownloadUsingInstance(SimpleHttpClient client, Uri uri, CancellationToken cancellationToken) { 116 | await DownloadSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); 117 | string content; 118 | DateTimeOffset date = default; 119 | 120 | try { 121 | #pragma warning disable CAC001 122 | #pragma warning disable CA2007 123 | await using HttpStreamResponse resp = await client.GetStreamAsync(uri, cancellationToken: cancellationToken).ConfigureAwait(false); 124 | #pragma warning restore CA2007 125 | #pragma warning restore CAC001 126 | 127 | if (!resp.HasValidStream) { 128 | throw new HttpRequestRedlibException("invalid stream for " + uri) { 129 | Uri = uri, 130 | StatusCode = resp.StatusCode 131 | }; 132 | } 133 | else if (!resp.StatusCode.IsSuccessCode()) { 134 | throw new HttpRequestRedlibException($"invalid status code {resp.StatusCode} for {uri}") { 135 | Uri = uri, 136 | StatusCode = resp.StatusCode 137 | }; 138 | } 139 | else { 140 | content = await resp.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); 141 | 142 | date = GetDateFromHeaders(resp.Response) ?? date; 143 | } 144 | } 145 | finally { 146 | DownloadSemaphore.Release(); 147 | } 148 | 149 | IReadOnlyCollection entries = RedlibHtmlParser.ParseGamesFromHtml(content); 150 | DateTimeOffset now = DateTimeOffset.Now; 151 | 152 | if ((date == default(DateTimeOffset)) || ((now - date).Duration() > TimeSpan.FromDays(1))) { 153 | date = now; 154 | } 155 | 156 | long dateMillis = date.ToUnixTimeMilliseconds(); 157 | 158 | List redditGameEntries = []; 159 | 160 | // ReSharper disable once LoopCanBeConvertedToQuery 161 | foreach (RedlibGameEntry entry in entries) { 162 | redditGameEntries.Add(entry.ToRedditGameEntry(dateMillis)); 163 | } 164 | 165 | return redditGameEntries; 166 | } 167 | 168 | private async Task> DownloadUsingInstance(SimpleHttpClient client, Uri uri, uint retry, CancellationToken cancellationToken) { 169 | Uri fullUrl = new($"{uri.ToString().TrimEnd('/')}/user/{RedditHelper.User}?sort=new", UriKind.Absolute); 170 | 171 | for (int t = 0; t < retry; t++) { 172 | try { 173 | return await DoDownloadUsingInstance(client, fullUrl, cancellationToken).ConfigureAwait(false); 174 | } 175 | catch (Exception) { 176 | if ((t == retry - 1) || cancellationToken.IsCancellationRequested) { 177 | throw; 178 | } 179 | 180 | await Task.Delay(1000 * (1 << t), cancellationToken).ConfigureAwait(false); 181 | } 182 | } 183 | 184 | cancellationToken.ThrowIfCancellationRequested(); 185 | 186 | throw new InvalidOperationException("This should never happen"); 187 | } 188 | 189 | private static async Task> MonitorDownloads(LinkedList>> tasks, CancellationToken cancellationToken) { 190 | while (tasks.Count > 0) { 191 | cancellationToken.ThrowIfCancellationRequested(); 192 | 193 | try { 194 | await Task.WhenAny(tasks).ConfigureAwait(false); 195 | } 196 | catch (Exception) { 197 | //ignored 198 | } 199 | 200 | LinkedListNode>>? node = tasks.First; 201 | 202 | while (node is not null) { 203 | Task> task = node.Value; 204 | 205 | if (task.IsCompletedSuccessfully) { 206 | IReadOnlyCollection result = await task.ConfigureAwait(false); 207 | 208 | if (result.Count > 0) { 209 | return result; 210 | } 211 | } 212 | 213 | if (task.IsCompleted) { 214 | tasks.Remove(node); 215 | node = tasks.First; 216 | task.Dispose(); 217 | 218 | continue; 219 | } 220 | 221 | node = node.Next; 222 | } 223 | } 224 | 225 | return []; 226 | } 227 | 228 | /// 229 | /// Shuffles a list of URIs.
230 | /// This is done using a non performant guids generation for asf trimmed binary compatibility. 231 | ///
232 | /// The list of URIs to shuffle. 233 | /// A shuffled list of URIs. 234 | private static List Shuffle(TCollection list) where TCollection : ICollection { 235 | List<(Guid, Uri)> randomized = new(list.Count); 236 | randomized.AddRange(list.Select(static uri => (Guid.NewGuid(), uri))); 237 | 238 | randomized.Sort(static (x, y) => x.Item1.CompareTo(y.Item1)); 239 | 240 | return randomized.Select(static x => x.Item2).ToList(); 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /ASFFreeGames/Github/GithubPluginUpdater.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using ArchiSteamFarm; 5 | using ArchiSteamFarm.Localization; 6 | using ArchiSteamFarm.Web.GitHub; 7 | using ArchiSteamFarm.Web.GitHub.Data; 8 | 9 | namespace Maxisoft.ASF.Github; 10 | 11 | public class GithubPluginUpdater(Lazy version) { 12 | public const string RepositoryName = "maxisoft/ASFFreeGames"; 13 | public bool CanUpdate { get; internal set; } = true; 14 | 15 | private Version CurrentVersion => version.Value; 16 | 17 | private static void LogGenericError(string message) { 18 | if (string.IsNullOrEmpty(message)) { 19 | return; 20 | } 21 | 22 | ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericError($"{nameof(GithubPluginUpdater)}: {message}"); 23 | } 24 | 25 | private static void LogGenericDebug(string message) { 26 | if (string.IsNullOrEmpty(message)) { 27 | return; 28 | } 29 | 30 | ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericDebug($"{nameof(GithubPluginUpdater)}: {message}"); 31 | } 32 | 33 | public async Task GetTargetReleaseURL(Version asfVersion, string asfVariant, bool asfUpdate, bool stable, bool forced) { 34 | ArgumentNullException.ThrowIfNull(asfVersion); 35 | ArgumentException.ThrowIfNullOrEmpty(asfVariant); 36 | 37 | if (!CanUpdate) { 38 | LogGenericDebug("CanUpdate is false"); 39 | 40 | return null; 41 | } 42 | 43 | if (string.IsNullOrEmpty(RepositoryName)) { 44 | LogGenericError("RepositoryName is null or empty"); 45 | 46 | return null; 47 | } 48 | 49 | ReleaseResponse? releaseResponse = await GitHubService.GetLatestRelease(RepositoryName).ConfigureAwait(false); 50 | 51 | if (releaseResponse == null) { 52 | LogGenericError("GetLatestRelease returned null"); 53 | 54 | return null; 55 | } 56 | 57 | if (releaseResponse.IsPreRelease) { 58 | LogGenericError("GetLatestRelease returned pre-release"); 59 | 60 | return null; 61 | } 62 | 63 | if (stable && ((releaseResponse.PublishedAt - DateTime.UtcNow).Duration() < TimeSpan.FromHours(3))) { 64 | LogGenericDebug("GetLatestRelease returned too recent"); 65 | 66 | return null; 67 | } 68 | 69 | Version newVersion = new(releaseResponse.Tag.ToUpperInvariant().TrimStart('V')); 70 | 71 | if (!forced && (CurrentVersion >= newVersion)) { 72 | // Allow same version to be re-updated when we're updating ASF release and more than one asset is found - potential compatibility difference 73 | if ((CurrentVersion > newVersion) || !asfUpdate || (releaseResponse.Assets.Count(static asset => asset.Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) < 2)) { 74 | return null; 75 | } 76 | } 77 | 78 | if (releaseResponse.Assets.Count == 0) { 79 | LogGenericError($"GetLatestRelease for version {newVersion} returned no assets"); 80 | 81 | return null; 82 | } 83 | 84 | ReleaseAsset? asset = releaseResponse.Assets.FirstOrDefault(static asset => asset.Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) && (asset.Size > (1 << 18))); 85 | 86 | if ((asset == null) || !releaseResponse.Assets.Contains(asset)) { 87 | LogGenericError($"GetLatestRelease for version {newVersion} returned no valid assets"); 88 | 89 | return null; 90 | } 91 | 92 | LogGenericDebug($"GetLatestRelease for version {newVersion} returned asset {asset.Name} with url {asset.DownloadURL}"); 93 | 94 | return asset.DownloadURL; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /ASFFreeGames/HttpClientSimple/SimpleHttpClient.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Net; 5 | using System.Net.Http; 6 | using System.Net.Http.Headers; 7 | using System.Reflection; 8 | using System.Runtime.CompilerServices; 9 | using System.Threading; 10 | using System.Threading.Tasks; 11 | 12 | namespace Maxisoft.ASF.HttpClientSimple; 13 | 14 | #nullable enable 15 | 16 | public sealed class SimpleHttpClient : IDisposable { 17 | private readonly HttpMessageHandler HttpMessageHandler; 18 | private readonly HttpClient HttpClient; 19 | 20 | public SimpleHttpClient(IWebProxy? proxy = null, long timeout = 25_000) { 21 | SocketsHttpHandler handler = new(); 22 | 23 | SetPropertyWithLogging(handler, nameof(SocketsHttpHandler.AutomaticDecompression), DecompressionMethods.All); 24 | SetPropertyWithLogging(handler, nameof(SocketsHttpHandler.MaxConnectionsPerServer), 5, debugLogLevel: true); 25 | SetPropertyWithLogging(handler, nameof(SocketsHttpHandler.EnableMultipleHttp2Connections), true); 26 | 27 | if (proxy is not null) { 28 | SetPropertyWithLogging(handler, nameof(SocketsHttpHandler.Proxy), proxy); 29 | SetPropertyWithLogging(handler, nameof(SocketsHttpHandler.UseProxy), true); 30 | 31 | if (proxy.Credentials is not null) { 32 | SetPropertyWithLogging(handler, nameof(SocketsHttpHandler.PreAuthenticate), true); 33 | } 34 | } 35 | 36 | HttpMessageHandler = handler; 37 | #pragma warning disable CA5399 38 | HttpClient = new HttpClient(handler, false); 39 | #pragma warning restore CA5399 40 | SetPropertyWithLogging(HttpClient, nameof(HttpClient.DefaultRequestVersion), HttpVersion.Version30); 41 | SetPropertyWithLogging(HttpClient, nameof(HttpClient.Timeout), TimeSpan.FromMilliseconds(timeout)); 42 | 43 | SetExpectContinueProperty(HttpClient, false); 44 | 45 | HttpClient.DefaultRequestHeaders.Add("User-Agent", "Lynx/2.8.8dev.9 libwww-FM/2.14 SSL-MM/1.4.1 GNUTLS/2.12.14"); 46 | HttpClient.DefaultRequestHeaders.Add("DNT", "1"); 47 | HttpClient.DefaultRequestHeaders.Add("Sec-GPC", "1"); 48 | 49 | HttpClient.DefaultRequestHeaders.AcceptLanguage.Add(new StringWithQualityHeaderValue("en-US")); 50 | HttpClient.DefaultRequestHeaders.AcceptLanguage.Add(new StringWithQualityHeaderValue("en", 0.8)); 51 | } 52 | 53 | public async Task GetStreamAsync(Uri uri, IEnumerable>? additionalHeaders = null, CancellationToken cancellationToken = default) { 54 | using HttpRequestMessage request = new(HttpMethod.Get, uri); 55 | request.Version = HttpClient.DefaultRequestVersion; 56 | 57 | // Add additional headers if provided 58 | if (additionalHeaders != null) { 59 | foreach (KeyValuePair header in additionalHeaders) { 60 | request.Headers.TryAddWithoutValidation(header.Key, header.Value); 61 | } 62 | } 63 | 64 | HttpResponseMessage response = await HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); 65 | Stream? stream = null; 66 | 67 | try { 68 | stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); 69 | } 70 | catch (Exception) { 71 | if (response.IsSuccessStatusCode) { 72 | throw; // something is wrong 73 | } 74 | 75 | // assume that the caller checks the status code before reading the stream 76 | } 77 | 78 | return new HttpStreamResponse(response, stream); 79 | } 80 | 81 | public void Dispose() { 82 | HttpClient.Dispose(); 83 | HttpMessageHandler.Dispose(); 84 | } 85 | 86 | # region System.MissingMethodException workarounds 87 | private static bool SetExpectContinueProperty(HttpClient httpClient, bool value) { 88 | try { 89 | // Get the DefaultRequestHeaders property 90 | PropertyInfo? defaultRequestHeadersProperty = httpClient.GetType().GetProperty(nameof(HttpClient.DefaultRequestHeaders), BindingFlags.Public | BindingFlags.Instance) ?? httpClient.GetType().GetProperty("DefaultRequestHeaders", BindingFlags.Public | BindingFlags.Instance); 91 | 92 | if (defaultRequestHeadersProperty == null) { 93 | throw new InvalidOperationException("HttpClient does not have DefaultRequestHeaders property."); 94 | } 95 | 96 | if (defaultRequestHeadersProperty.GetValue(httpClient) is not HttpRequestHeaders defaultRequestHeaders) { 97 | throw new InvalidOperationException("DefaultRequestHeaders is null."); 98 | } 99 | 100 | // Get the ExpectContinue property 101 | PropertyInfo? expectContinueProperty = defaultRequestHeaders.GetType().GetProperty(nameof(HttpRequestHeaders.ExpectContinue), BindingFlags.Public | BindingFlags.Instance) ?? defaultRequestHeaders.GetType().GetProperty("ExpectContinue", BindingFlags.Public | BindingFlags.Instance); 102 | 103 | if ((expectContinueProperty != null) && expectContinueProperty.CanWrite) { 104 | expectContinueProperty.SetValue(defaultRequestHeaders, value); 105 | 106 | return true; 107 | } 108 | } 109 | catch (Exception ex) { 110 | ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericException(ex); 111 | } 112 | 113 | return false; 114 | } 115 | 116 | private static bool TrySetPropertyValue(T targetObject, string propertyName, object value) where T : class { 117 | try { 118 | // Get the type of the target object 119 | Type targetType = targetObject.GetType(); 120 | 121 | // Get the property information 122 | PropertyInfo? propertyInfo = targetType.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance); 123 | 124 | if ((propertyInfo is not null) && propertyInfo.CanWrite) { 125 | // Set the property value 126 | propertyInfo.SetValue(targetObject, value); 127 | 128 | return true; 129 | } 130 | } 131 | catch (Exception ex) { 132 | ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericException(ex); 133 | } 134 | 135 | return false; 136 | } 137 | 138 | private static void SetPropertyWithLogging(T targetObject, string propertyName, object value, bool debugLogLevel = false) where T : class { 139 | try { 140 | if (TrySetPropertyValue(targetObject, propertyName, value)) { 141 | return; 142 | } 143 | } 144 | catch (Exception) { 145 | // ignored 146 | } 147 | 148 | string logMessage = $"Failed to set {targetObject.GetType().Name} property {propertyName} to {value}. Please report this issue to github."; 149 | 150 | if (debugLogLevel) { 151 | ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericDebug(logMessage); 152 | } 153 | else { 154 | ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericWarning(logMessage); 155 | } 156 | } 157 | #endregion 158 | } 159 | 160 | public sealed class HttpStreamResponse(HttpResponseMessage response, Stream? stream) : IAsyncDisposable { 161 | public HttpResponseMessage Response { get; } = response; 162 | public Stream Stream { get; } = stream ?? EmptyStreamLazy.Value; 163 | 164 | public bool HasValidStream => stream is not null && (!EmptyStreamLazy.IsValueCreated || !ReferenceEquals(EmptyStreamLazy.Value, Stream)); 165 | 166 | public async Task ReadAsStringAsync(CancellationToken cancellationToken) { 167 | using StreamReader reader = new(Stream); // assume the encoding is UTF8, cannot be specified as per issue #91 168 | 169 | return await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); 170 | } 171 | 172 | public HttpStatusCode StatusCode => Response.StatusCode; 173 | 174 | public async ValueTask DisposeAsync() { 175 | ValueTask task = HasValidStream ? Stream.DisposeAsync() : ValueTask.CompletedTask; 176 | Response.Dispose(); 177 | await task.ConfigureAwait(false); 178 | } 179 | 180 | private static readonly Lazy EmptyStreamLazy = new(static () => new MemoryStream([], false)); 181 | } 182 | -------------------------------------------------------------------------------- /ASFFreeGames/HttpClientSimple/SimpleHttpClientFactory.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Collections.ObjectModel; 4 | using System.Net; 5 | using ArchiSteamFarm.Storage; 6 | using ASFFreeGames.Configurations; 7 | 8 | namespace Maxisoft.ASF.HttpClientSimple; 9 | 10 | public sealed class SimpleHttpClientFactory(ASFFreeGamesOptions options) : IDisposable { 11 | private readonly HashSet DisableProxyStrings = new(StringComparer.InvariantCultureIgnoreCase) { 12 | "no", 13 | "0", 14 | "false", 15 | "none", 16 | "disable", 17 | "disabled", 18 | "null", 19 | "off", 20 | "noproxy", 21 | "no-proxy" 22 | }; 23 | 24 | private readonly Dictionary> Cache = new(); 25 | 26 | private enum ECacheKey { 27 | Generic, 28 | Reddit, 29 | Redlib, 30 | Github 31 | } 32 | 33 | private SimpleHttpClient CreateFor(ECacheKey key, string? proxy = null) { 34 | if (string.IsNullOrWhiteSpace(proxy)) { 35 | proxy = options.Proxy; 36 | } 37 | 38 | WebProxy? webProxy; 39 | 40 | if (DisableProxyStrings.Contains(proxy ?? "")) { 41 | webProxy = null; 42 | } 43 | else if (!string.IsNullOrWhiteSpace(proxy)) { 44 | webProxy = new WebProxy(proxy, BypassOnLocal: true); 45 | 46 | if (Uri.TryCreate(proxy, UriKind.Absolute, out Uri? uri) && !string.IsNullOrWhiteSpace(uri.UserInfo)) { 47 | string[] split = uri.UserInfo.Split(':'); 48 | 49 | if (split.Length == 2) { 50 | webProxy.Credentials = new NetworkCredential(split[0], split[1]); 51 | } 52 | } 53 | } 54 | else { 55 | webProxy = ArchiSteamFarm.Core.ASF.GlobalConfig?.WebProxy; 56 | } 57 | 58 | lock (Cache) { 59 | if (Cache.TryGetValue(key, out Tuple? cached)) { 60 | if (cached.Item1?.Address == webProxy?.Address) { 61 | return cached.Item2; 62 | } 63 | else { 64 | Cache.Remove(key); 65 | } 66 | } 67 | 68 | #pragma warning disable CA2000 69 | Tuple tuple = new(webProxy, new SimpleHttpClient(webProxy)); 70 | #pragma warning restore CA2000 71 | Cache.Add(key, tuple); 72 | 73 | return tuple.Item2; 74 | } 75 | } 76 | 77 | public SimpleHttpClient CreateForReddit() => CreateFor(ECacheKey.Reddit, options.RedditProxy ?? options.Proxy); 78 | public SimpleHttpClient CreateForRedlib() => CreateFor(ECacheKey.Redlib, options.RedlibProxy ?? options.RedditProxy ?? options.Proxy); 79 | public SimpleHttpClient CreateForGithub() => CreateFor(ECacheKey.Github, options.Proxy); 80 | 81 | public SimpleHttpClient CreateGeneric() => CreateFor(ECacheKey.Generic, options.Proxy); 82 | 83 | public void Dispose() { 84 | lock (Cache) { 85 | foreach ((_, (_, SimpleHttpClient? item2)) in Cache) { 86 | item2.Dispose(); 87 | } 88 | 89 | Cache.Clear(); 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /ASFFreeGames/Maxisoft.Utils/BitSpan.Helpers.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics.CodeAnalysis; 3 | using System.Runtime.CompilerServices; 4 | using System.Runtime.InteropServices; 5 | 6 | namespace Maxisoft.Utils.Collections.Spans 7 | { 8 | [SuppressMessage("Design", "CA1034")] 9 | public ref partial struct BitSpan 10 | { 11 | public static int ComputeLongArraySize(int numBits) 12 | { 13 | var n = numBits / LongNumBit; 14 | if (numBits % LongNumBit != 0) 15 | { 16 | n += 1; 17 | } 18 | 19 | return n; 20 | } 21 | 22 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 23 | public static BitSpan Zeros(int numBits) 24 | { 25 | return new BitSpan(new long[ComputeLongArraySize(numBits)]); 26 | } 27 | 28 | public static BitSpan CreateFromBuffer(Span buff) where TSpan : unmanaged 29 | { 30 | var castedSpan = MemoryMarshal.Cast(buff); 31 | return new BitSpan(castedSpan); 32 | } 33 | 34 | public ref struct Enumerator 35 | { 36 | /// The span being enumerated. 37 | private readonly BitSpan _bitSpan; 38 | 39 | /// The next index to yield. 40 | private int _index; 41 | 42 | /// Initialize the enumerator. 43 | /// The dict to enumerate. 44 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 45 | internal Enumerator(BitSpan bitSpan) 46 | { 47 | _bitSpan = bitSpan; 48 | _index = -1; 49 | } 50 | 51 | /// Advances the enumerator to the next element of the dict. 52 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 53 | public bool MoveNext() 54 | { 55 | var index = _index + 1; 56 | if (index >= _bitSpan.Count) 57 | { 58 | return false; 59 | } 60 | 61 | _index = index; 62 | return true; 63 | } 64 | 65 | /// Gets the element at the current position of the enumerator. 66 | public bool Current 67 | { 68 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 69 | get => _bitSpan.Get(_index); 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /ASFFreeGames/Maxisoft.Utils/BitSpan.Operators.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections; 3 | using System.Diagnostics.CodeAnalysis; 4 | using System.Runtime.CompilerServices; 5 | using System.Runtime.InteropServices; 6 | 7 | namespace Maxisoft.Utils.Collections.Spans { 8 | [SuppressMessage("Usage", "CA2225")] 9 | public ref partial struct BitSpan { 10 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 11 | public static implicit operator ReadOnlySpan(BitSpan bs) { 12 | return bs.Span; 13 | } 14 | 15 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 16 | public static implicit operator Span(BitSpan bs) { 17 | return bs.Span; 18 | } 19 | 20 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 21 | public static implicit operator BitSpan(Span span) { 22 | return new BitSpan(span); 23 | } 24 | 25 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 26 | public static implicit operator BitSpan(Span span) { 27 | return new BitSpan(MemoryMarshal.Cast(span)); 28 | } 29 | 30 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 31 | public static explicit operator BitArray(BitSpan span) { 32 | return span.ToBitArray(); 33 | } 34 | 35 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 36 | public static implicit operator BitSpan([NotNull] BitArray bitArray) { 37 | var arr = new int[ComputeLongArraySize(bitArray.Count) * (sizeof(long) / sizeof(int))]; 38 | bitArray.CopyTo(arr, 0); 39 | 40 | return (Span) arr; 41 | } 42 | 43 | public readonly int CompareTo(BitSpan other) { 44 | var limit = Math.Min(Span.Length, other.Span.Length); 45 | 46 | for (var i = 0; i < limit; i++) { 47 | var c = Span[i].CompareTo(other.Span[i]); 48 | 49 | if (c != 0) { 50 | return c; 51 | } 52 | } 53 | 54 | return Span.Length.CompareTo(other.Span.Length); 55 | } 56 | 57 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 58 | public readonly bool Equals(BitSpan other) { 59 | if (Length != other.Length) { 60 | return false; 61 | } 62 | 63 | return CompareTo(other) == 0; 64 | } 65 | 66 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 67 | public static bool operator ==(BitSpan left, BitSpan right) { 68 | return left.Equals(right); 69 | } 70 | 71 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 72 | public static bool operator !=(BitSpan left, BitSpan right) { 73 | return !(left == right); 74 | } 75 | 76 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 77 | public static BitSpan operator ~(BitSpan bs) { 78 | var buff = new long[bs.Count]; 79 | var res = CreateFromBuffer(buff); 80 | bs.Span.CopyTo(res); 81 | 82 | return res.Not(); 83 | } 84 | 85 | internal readonly bool IsTrue() { 86 | if (Span.IsEmpty) { 87 | return false; 88 | } 89 | 90 | foreach (var l in Span) { 91 | if (l != 0) { 92 | return false; 93 | } 94 | } 95 | 96 | return true; 97 | } 98 | 99 | public readonly bool IsZero => !IsTrue(); 100 | 101 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 102 | public static bool operator true(BitSpan bs) { 103 | return bs.IsTrue(); 104 | } 105 | 106 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 107 | public static bool operator false(BitSpan bs) { 108 | return !bs.IsTrue(); 109 | } 110 | 111 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 112 | public static bool operator <(BitSpan left, BitSpan right) { 113 | return left.CompareTo(right) < 0; 114 | } 115 | 116 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 117 | public static bool operator >(BitSpan left, BitSpan right) { 118 | return left.CompareTo(right) > 0; 119 | } 120 | 121 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 122 | public static bool operator <=(BitSpan left, BitSpan right) { 123 | return left.CompareTo(right) <= 0; 124 | } 125 | 126 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 127 | public static bool operator >=(BitSpan left, BitSpan right) { 128 | return left.CompareTo(right) >= 0; 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /ASFFreeGames/Maxisoft.Utils/BitSpan.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections; 3 | using System.Diagnostics.CodeAnalysis; 4 | using System.Runtime.CompilerServices; 5 | using System.Runtime.InteropServices; 6 | 7 | namespace Maxisoft.Utils.Collections.Spans { 8 | [SuppressMessage("Design", "CA1051")] 9 | public ref partial struct BitSpan { 10 | public readonly Span Span; 11 | public const int LongNumBit = sizeof(long) * 8; 12 | 13 | public BitSpan(Span span) { 14 | Span = span; 15 | } 16 | 17 | public bool this[int index] { 18 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 19 | get => Get(index); 20 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 21 | set => Set(index, value); 22 | } 23 | 24 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 25 | private readonly void ThrowForOutOfBounds(int index) { 26 | if ((uint) index >= LongNumBit * (uint) Span.Length) { 27 | throw new ArgumentOutOfRangeException(nameof(index), index, null); 28 | } 29 | } 30 | 31 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 32 | public readonly bool Get(int index) { 33 | ThrowForOutOfBounds(index); 34 | 35 | return (Span[index / LongNumBit] & (1L << (index % LongNumBit))) != 0; 36 | } 37 | 38 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 39 | public void Set(int index, bool value) { 40 | ThrowForOutOfBounds(index); 41 | 42 | if (value) { 43 | Span[index / LongNumBit] |= 1L << (index % LongNumBit); 44 | } 45 | else { 46 | Span[index / LongNumBit] &= ~(1L << (index % LongNumBit)); 47 | } 48 | } 49 | 50 | public void SetAll(bool value) { 51 | if (value) { 52 | Span.Fill(unchecked((long) ulong.MaxValue)); 53 | } 54 | else { 55 | Span.Clear(); 56 | } 57 | } 58 | 59 | public BitSpan And(in BitSpan other) { 60 | if (Span.Length < other.Span.Length) { 61 | throw new ArgumentException(null, nameof(other)); 62 | } 63 | 64 | for (var i = 0; i < other.Span.Length; i++) { 65 | Span[i] &= other.Span[i]; 66 | } 67 | 68 | return this; 69 | } 70 | 71 | public BitSpan Or(in BitSpan other) { 72 | if (Span.Length < other.Span.Length) { 73 | throw new ArgumentException(null, nameof(other)); 74 | } 75 | 76 | for (var i = 0; i < other.Span.Length; i++) { 77 | Span[i] |= other.Span[i]; 78 | } 79 | 80 | return this; 81 | } 82 | 83 | public BitSpan Xor(in BitSpan other) { 84 | if (Span.Length < other.Span.Length) { 85 | throw new ArgumentException(null, nameof(other)); 86 | } 87 | 88 | for (var i = 0; i < other.Span.Length; i++) { 89 | Span[i] ^= other.Span[i]; 90 | } 91 | 92 | return this; 93 | } 94 | 95 | public BitSpan Not() { 96 | for (var i = 0; i < Span.Length; i++) { 97 | Span[i] = ~Span[i]; 98 | } 99 | 100 | return this; 101 | } 102 | 103 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 104 | public readonly Span ASpan() where T : unmanaged { 105 | return MemoryMarshal.Cast(Span); 106 | } 107 | 108 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 109 | public readonly BitArray ToBitArray() { 110 | return new BitArray(ASpan().ToArray()); 111 | } 112 | 113 | public readonly int Length => Span.Length * LongNumBit; 114 | 115 | public readonly int Count => Length; 116 | 117 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 118 | public readonly Enumerator GetEnumerator() { 119 | return new Enumerator(this); 120 | } 121 | 122 | public override readonly int GetHashCode() { 123 | var h = Count.GetHashCode(); 124 | 125 | foreach (var l in Span) { 126 | h = unchecked(31 * h + l.GetHashCode()); 127 | } 128 | 129 | return h; 130 | } 131 | 132 | public override readonly bool Equals(object? obj) { 133 | return obj switch { 134 | BitArray ba => Equals((BitSpan) ba), 135 | _ => false 136 | }; 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /ASFFreeGames/Maxisoft.Utils/IOrderedDictionary.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace Maxisoft.Utils.Collections.Dictionaries { 4 | public interface IOrderedDictionary : IDictionary { 5 | public TValue this[int index] { get; set; } 6 | public void Insert(int index, in TKey key, in TValue value); 7 | public void RemoveAt(int index); 8 | 9 | public int IndexOf(in TKey key); 10 | 11 | public int IndexOf(in TValue value); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /ASFFreeGames/Maxisoft.Utils/SpanDict.Helpers.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Diagnostics; 3 | using System.Diagnostics.CodeAnalysis; 4 | using System.Runtime.CompilerServices; 5 | 6 | namespace Maxisoft.Utils.Collections.Spans { 7 | public ref partial struct SpanDict where TKey : notnull { 8 | public readonly KeyValuePair[] ToArray() { 9 | var array = new KeyValuePair[Count]; 10 | CopyTo(array, 0); 11 | 12 | return array; 13 | } 14 | 15 | public readonly TDictionary ToDictionary() where TDictionary : IDictionary, new() { 16 | var dict = new TDictionary(); 17 | 18 | foreach (var pair in this) { 19 | dict.Add(pair); 20 | } 21 | 22 | return dict; 23 | } 24 | 25 | public readonly Dictionary ToDictionary() { 26 | var dict = new Dictionary(Count); 27 | 28 | foreach (var pair in this) { 29 | dict.Add(pair.Key, pair.Value); 30 | } 31 | 32 | return dict; 33 | } 34 | 35 | [DebuggerNonUserCode] 36 | private readonly ref struct DebuggerTypeProxyImpl { 37 | private readonly SpanDict _dict; 38 | 39 | public DebuggerTypeProxyImpl(SpanDict dict) { 40 | _dict = dict; 41 | } 42 | 43 | [DebuggerBrowsable(DebuggerBrowsableState.Collapsed)] 44 | public long Count => _dict.Count; 45 | 46 | [DebuggerBrowsable(DebuggerBrowsableState.Collapsed)] 47 | public long Capacity => _dict.Capacity; 48 | 49 | [DebuggerBrowsable(DebuggerBrowsableState.Never)] 50 | public BitSpan Mask => _dict.Mask; 51 | 52 | [DebuggerBrowsable(DebuggerBrowsableState.RootHidden)] 53 | public KeyValuePair[] Items => _dict.ToArray(); 54 | } 55 | 56 | [SuppressMessage("Design", "CA1034")] 57 | public ref struct Enumerator { 58 | /// The span being enumerated. 59 | private readonly SpanDict _dict; 60 | 61 | /// The next index to yield. 62 | private int _index; 63 | 64 | /// Initialize the enumerator. 65 | /// The dict to enumerate. 66 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 67 | internal Enumerator(SpanDict dict) { 68 | _dict = dict; 69 | _index = -1; 70 | } 71 | 72 | /// Advances the enumerator to the next element of the dict. 73 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 74 | public bool MoveNext() { 75 | var index = _index + 1; 76 | 77 | while (index < _dict.Capacity && !_dict.Mask[index]) { 78 | index += 1; 79 | } 80 | 81 | if (index >= _dict.Capacity) { 82 | return false; 83 | } 84 | 85 | _index = index; 86 | 87 | return true; 88 | } 89 | 90 | /// Gets the element at the current position of the enumerator. 91 | public ref KeyValuePair Current { 92 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 93 | get => ref _dict.Buckets[_index]; 94 | } 95 | } 96 | 97 | [SuppressMessage("Design", "CA1034")] 98 | public ref struct KeyEnumerator { 99 | /// The span being enumerated. 100 | private readonly SpanDict _dict; 101 | 102 | /// The next index to yield. 103 | private int _index; 104 | 105 | /// Initialize the enumerator. 106 | /// The dict to enumerate. 107 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 108 | internal KeyEnumerator(SpanDict dict) { 109 | _dict = dict; 110 | _index = -1; 111 | } 112 | 113 | public int Count => _dict.Count; 114 | 115 | /// Advances the enumerator to the next element of the dict. 116 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 117 | public bool MoveNext() { 118 | var index = _index + 1; 119 | 120 | while (index < _dict.Capacity && !_dict.Mask[index]) { 121 | index += 1; 122 | } 123 | 124 | if (index >= _dict.Capacity) { 125 | return false; 126 | } 127 | 128 | _index = index; 129 | 130 | return true; 131 | } 132 | 133 | /// Gets the element at the current position of the enumerator. 134 | public TKey Current { 135 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 136 | get => _dict.Buckets[_index].Key; 137 | } 138 | 139 | public KeyEnumerator GetEnumerator() { 140 | return this; 141 | } 142 | } 143 | 144 | [SuppressMessage("Design", "CA1034")] 145 | public ref struct ValueEnumerator { 146 | /// The span being enumerated. 147 | private readonly SpanDict _dict; 148 | 149 | /// The next index to yield. 150 | private int _index; 151 | 152 | /// Initialize the enumerator. 153 | /// The dict to enumerate. 154 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 155 | internal ValueEnumerator(SpanDict dict) { 156 | _dict = dict; 157 | _index = -1; 158 | } 159 | 160 | public int Count => _dict.Count; 161 | 162 | public ValueEnumerator GetEnumerator() { 163 | return this; 164 | } 165 | 166 | /// Advances the enumerator to the next element of the dict. 167 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 168 | public bool MoveNext() { 169 | var index = _index + 1; 170 | 171 | while (index < _dict.Capacity && !_dict.Mask[index]) { 172 | index += 1; 173 | } 174 | 175 | if (index >= _dict.Capacity) { 176 | return false; 177 | } 178 | 179 | _index = index; 180 | 181 | return true; 182 | } 183 | 184 | /// Gets the element at the current position of the enumerator. 185 | public TValue Current { 186 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 187 | get => _dict.Buckets[_index].Value; 188 | } 189 | } 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /ASFFreeGames/Maxisoft.Utils/SpanExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.Runtime.CompilerServices; 5 | using System.Runtime.InteropServices; 6 | 7 | namespace Maxisoft.Utils.Collections.Spans 8 | { 9 | public static class SpanExtensions 10 | { 11 | 12 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 13 | public static int AddSorted(this SpanList list, in T item, IComparer? comparer = null) 14 | { 15 | if ((uint) list.Count >= (uint) list.Capacity) 16 | { 17 | throw new InvalidOperationException("span is full"); 18 | } 19 | 20 | var index = list.BinarySearch(in item, comparer); 21 | var res = index < 0 ? ~index : index; 22 | list.Insert(res, in item); 23 | Debug.Assert((comparer ?? Comparer.Default).Compare(item, list[res]) == 0); 24 | return res; 25 | } 26 | 27 | public static Span ASpan(this SpanList list) 28 | where TIn : struct where TOut : struct 29 | { 30 | return MemoryMarshal.Cast(list.AsSpan()); 31 | } 32 | 33 | public static SpanList Cast(this SpanList list) 34 | where TIn : unmanaged where TOut : unmanaged 35 | { 36 | var span = MemoryMarshal.Cast(list.Span); 37 | int count; 38 | unsafe 39 | { 40 | if (sizeof(TIn) > sizeof(TOut)) 41 | { 42 | Debug.Assert(sizeof(TIn) % sizeof(TOut) == 0); 43 | count = list.Count * (sizeof(TIn) / sizeof(TOut)); 44 | } 45 | else 46 | { 47 | Debug.Assert(sizeof(TOut) % sizeof(TIn) == 0); 48 | count = list.Count / (sizeof(TOut) / sizeof(TIn)); 49 | } 50 | } 51 | 52 | return new SpanList(span, count); 53 | } 54 | } 55 | } -------------------------------------------------------------------------------- /ASFFreeGames/Maxisoft.Utils/SpanList.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.Diagnostics.CodeAnalysis; 5 | using System.Runtime.CompilerServices; 6 | 7 | namespace Maxisoft.Utils.Collections.Spans { 8 | [DebuggerDisplay("Count = {Count}, Capacity = {Capacity}")] 9 | [DebuggerTypeProxy(typeof(SpanList<>.DebuggerTypeProxyImpl))] 10 | public ref struct SpanList { 11 | internal readonly Span Span; 12 | 13 | public SpanList(Span span, int count = 0) { 14 | if ((uint) count > (uint) span.Length) { 15 | throw new ArgumentOutOfRangeException(nameof(count), count, null); 16 | } 17 | 18 | Span = span; 19 | Count = count; 20 | } 21 | 22 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 23 | public readonly Span AsSpan() { 24 | return Span.Slice(0, Count); 25 | } 26 | 27 | public readonly int Capacity => Span.Length; 28 | 29 | public static implicit operator Span(SpanList list) { 30 | return list.AsSpan(); 31 | } 32 | 33 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 34 | public readonly Span.Enumerator GetEnumerator() { 35 | return AsSpan().GetEnumerator(); 36 | } 37 | 38 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 39 | public Span Data() { 40 | return Span; 41 | } 42 | 43 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 44 | public void Add(in T item) { 45 | if ((uint) Count >= (uint) Span.Length) { 46 | throw new InvalidOperationException("span is full"); 47 | } 48 | 49 | Span[Count++] = item; 50 | } 51 | 52 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 53 | public void Clear() { 54 | Count = 0; 55 | Span.Clear(); 56 | } 57 | 58 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 59 | public readonly bool Contains(in T item) { 60 | return IndexOf(in item) >= 0; 61 | } 62 | 63 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 64 | public readonly void CopyTo(T[] array, int arrayIndex) { 65 | Span destination = array; 66 | AsSpan().CopyTo(destination.Slice(arrayIndex)); 67 | } 68 | 69 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 70 | public bool Remove(in T item) { 71 | var index = IndexOf(in item); 72 | 73 | if (index < 0) { 74 | return false; 75 | } 76 | 77 | RemoveAt(index); 78 | 79 | return true; 80 | } 81 | 82 | public int Count { get; internal set; } 83 | 84 | public readonly bool IsReadOnly => Count == Capacity; 85 | 86 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 87 | public readonly int IndexOf(in T item) { 88 | var comparer = EqualityComparer.Default; 89 | 90 | return IndexOf(in item, in comparer); 91 | } 92 | 93 | public readonly int IndexOf(in T item, in TEqualityComparer comparer) 94 | where TEqualityComparer : IEqualityComparer { 95 | var c = 0; 96 | 97 | foreach (var element in AsSpan()) { 98 | if (comparer.Equals(element, item)) { 99 | return c; 100 | } 101 | 102 | c += 1; 103 | } 104 | 105 | return -1; 106 | } 107 | 108 | public void Insert(int index, in T item) { 109 | if ((uint) Count >= (uint) Span.Length) { 110 | throw new InvalidOperationException("span is full"); 111 | } 112 | 113 | if (index == Count) { 114 | Add(in item); 115 | 116 | return; 117 | } 118 | 119 | AsSpan().Slice(index, Count - index).CopyTo(Span.Slice(index + 1)); 120 | Span[index] = item; 121 | Count += 1; 122 | } 123 | 124 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 125 | public void RemoveAt(int index) { 126 | AsSpan().Slice(index + 1, Count - 1 - index).CopyTo(Span.Slice(index)); 127 | Count -= 1; 128 | } 129 | 130 | public readonly ref T this[int index] { 131 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 132 | get => ref AsSpan()[index]; 133 | } 134 | 135 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 136 | public readonly ref T At(WrappedIndex index) { 137 | return ref AsSpan()[index.Resolve(Count)]; 138 | } 139 | 140 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 141 | public readonly ref T Front() { 142 | CheckForOutOfBounds(0, nameof(Count)); 143 | 144 | return ref Span[0]; 145 | } 146 | 147 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 148 | public readonly ref T Back() { 149 | CheckForOutOfBounds(Count - 1, nameof(Count)); 150 | 151 | return ref Span[Count - 1]; 152 | } 153 | 154 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 155 | private readonly void CheckForOutOfBounds( 156 | int index, string paramName, 157 | string message = 158 | "Index was out of range. Must be non-negative and less than the size of the collection." 159 | ) { 160 | if ((uint) index >= (uint) Count) { 161 | throw new ArgumentOutOfRangeException(paramName, index, message); 162 | } 163 | } 164 | 165 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 166 | public readonly Span GetSlice(int index, int count) { 167 | return AsSpan().Slice(index, count); 168 | } 169 | 170 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 171 | public readonly T[] ToArray() { 172 | return AsSpan().ToArray(); 173 | } 174 | 175 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 176 | [SuppressMessage("Design", "CA1002")] 177 | public readonly List ToList() { 178 | var res = new List(Count); 179 | 180 | foreach (var item in AsSpan()) { 181 | res.Add(item); 182 | } 183 | 184 | return res; 185 | } 186 | 187 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 188 | public readonly TList ToList() where TList : IList, new() { 189 | var res = new TList(); 190 | 191 | foreach (var item in AsSpan()) { 192 | res.Add(item); 193 | } 194 | 195 | return res; 196 | } 197 | 198 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 199 | public void Reverse() { 200 | Reverse(0, Count); 201 | } 202 | 203 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 204 | public void Reverse(int index, int count) { 205 | GetSlice(index, count).Reverse(); 206 | } 207 | 208 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 209 | public readonly int BinarySearch(int index, int count, in T item, IComparer? comparer = null) { 210 | comparer ??= Comparer.Default; 211 | 212 | return GetSlice(index, count).BinarySearch(item, comparer); 213 | } 214 | 215 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 216 | public readonly int BinarySearch(in T item, IComparer? comparer = null) { 217 | return BinarySearch(0, Count, in item, comparer); 218 | } 219 | 220 | [DebuggerNonUserCode] 221 | private readonly ref struct DebuggerTypeProxyImpl { 222 | private readonly SpanList _list; 223 | 224 | public DebuggerTypeProxyImpl(SpanList list) { 225 | _list = list; 226 | } 227 | 228 | [DebuggerBrowsable(DebuggerBrowsableState.Collapsed)] 229 | public long Count => _list.Count; 230 | 231 | [DebuggerBrowsable(DebuggerBrowsableState.Collapsed)] 232 | public long Capacity => _list.Capacity; 233 | 234 | [DebuggerBrowsable(DebuggerBrowsableState.Never)] 235 | public Span Span => _list.Span; 236 | 237 | [DebuggerBrowsable(DebuggerBrowsableState.RootHidden)] 238 | public T[] Items => _list.ToArray(); 239 | } 240 | 241 | public readonly Span ToSpan() => Span; 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /ASFFreeGames/Maxisoft.Utils/WrappedIndex.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections; 3 | using System.Collections.Generic; 4 | using System.Diagnostics; 5 | using System.Diagnostics.CodeAnalysis; 6 | using System.Runtime.CompilerServices; 7 | #pragma warning disable CS0660, CS0661 8 | 9 | namespace Maxisoft.Utils.Collections { 10 | /// 11 | /// 12 | /// Prefer .NET Standard 2.1 when available 13 | [DebuggerDisplay("{" + nameof(Value) + "}")] 14 | [SuppressMessage("Design", "CA1051")] 15 | [SuppressMessage("Performance", "CA1815")] 16 | public readonly struct WrappedIndex { 17 | public readonly int Value; 18 | 19 | public WrappedIndex(int value) { 20 | Value = value; 21 | } 22 | 23 | [SuppressMessage("Usage", "CA2225")] 24 | public static implicit operator WrappedIndex(int value) { 25 | return new WrappedIndex(value); 26 | } 27 | 28 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 29 | public int Resolve(int size) { 30 | return Value >= 0 ? Value : size + Value; 31 | } 32 | 33 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 34 | public int Resolve([NotNull] in ICollection collection) where TCollection : ICollection { 35 | return Resolve(collection.Count); 36 | } 37 | 38 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 39 | public int Resolve(in ICollection collection) { 40 | return Resolve>(collection); 41 | } 42 | 43 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 44 | public int Resolve(in TCollection collection) where TCollection : ICollection { 45 | return Resolve(collection.Count); 46 | } 47 | 48 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 49 | public int Resolve([NotNull] in Array array) { 50 | return Resolve(array.Length); 51 | } 52 | 53 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 54 | public int Resolve([NotNull] in T[] array) { 55 | return Resolve(array.Length); 56 | } 57 | 58 | public bool Equals(WrappedIndex other) => Value == other.Value; 59 | 60 | public override int GetHashCode() => Value.GetHashCode(); 61 | 62 | public static bool operator ==(WrappedIndex left, WrappedIndex right) => left.Equals(right); 63 | 64 | public static bool operator !=(WrappedIndex left, WrappedIndex right) => !(left == right); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /ASFFreeGames/PluginContext.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading; 4 | using ArchiSteamFarm.Steam; 5 | using ASFFreeGames.Configurations; 6 | using Maxisoft.ASF.Utils; 7 | 8 | namespace Maxisoft.ASF; 9 | 10 | internal sealed record PluginContext(IReadOnlyCollection Bots, IContextRegistry BotContexts, ASFFreeGamesOptions Options, LoggerFilter LoggerFilter, bool Valid = false) { 11 | /// 12 | /// Gets the cancellation token associated with this context. 13 | /// 14 | public CancellationToken CancellationToken => CancellationTokenLazy.Value; 15 | 16 | internal Lazy CancellationTokenLazy { private get; set; } = new(static () => default(CancellationToken)); 17 | 18 | /// 19 | /// A struct that implements IDisposable and temporarily changes the cancellation token of the PluginContext instance. 20 | /// 21 | public readonly struct CancellationTokenChanger : IDisposable { 22 | private readonly PluginContext Context; 23 | private readonly Lazy Original; 24 | 25 | /// 26 | /// Initializes a new instance of the struct with the specified context and factory. 27 | /// 28 | /// The PluginContext instance to change. 29 | /// The function that creates a new cancellation token. 30 | public CancellationTokenChanger(PluginContext context, Func factory) { 31 | Context = context; 32 | Original = context.CancellationTokenLazy; 33 | context.CancellationTokenLazy = new Lazy(factory); 34 | } 35 | 36 | /// 37 | /// 38 | /// Restores the original cancellation token to the PluginContext instance. 39 | /// 40 | public void Dispose() => Context.CancellationTokenLazy = Original; 41 | } 42 | 43 | /// 44 | /// Creates a new instance of the struct with the specified factory. 45 | /// 46 | /// The function that creates a new cancellation token. 47 | /// A new instance of the struct. 48 | public CancellationTokenChanger TemporaryChangeCancellationToken(Func factory) => new(this, factory); 49 | } 50 | -------------------------------------------------------------------------------- /ASFFreeGames/Reddit/ERedditGameEntryKind.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Maxisoft.ASF.Reddit; 4 | 5 | [Flags] 6 | public enum ERedditGameEntryKind : byte { 7 | None = 0, 8 | FreeToPlay = 1, 9 | Dlc = 1 << 1 10 | } 11 | -------------------------------------------------------------------------------- /ASFFreeGames/Reddit/EmptyStruct.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Maxisoft.ASF.Reddit; 4 | 5 | internal struct EmptyStruct : IEquatable { 6 | public bool Equals(EmptyStruct other) => true; 7 | 8 | public override bool Equals(object? obj) => obj is EmptyStruct; 9 | 10 | public override int GetHashCode() => 0; 11 | 12 | public static bool operator ==(EmptyStruct left, EmptyStruct right) => true; 13 | 14 | public static bool operator !=(EmptyStruct left, EmptyStruct right) => false; 15 | } 16 | -------------------------------------------------------------------------------- /ASFFreeGames/Reddit/GameEntryIdentifierEqualityComparer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace Maxisoft.ASF.Reddit; 5 | 6 | internal readonly struct GameEntryIdentifierEqualityComparer : IEqualityComparer { 7 | public bool Equals(RedditGameEntry x, RedditGameEntry y) => string.Equals(x.Identifier, y.Identifier, StringComparison.OrdinalIgnoreCase); 8 | 9 | public int GetHashCode(RedditGameEntry obj) => StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Identifier); 10 | } 11 | -------------------------------------------------------------------------------- /ASFFreeGames/Reddit/RedditGameEntry.cs: -------------------------------------------------------------------------------- 1 | namespace Maxisoft.ASF.Reddit; 2 | 3 | public readonly record struct RedditGameEntry(string Identifier, ERedditGameEntryKind Kind, long Date) { 4 | /// 5 | /// Indicates that the entry a DLC or a required game linked to a free DLC entry 6 | /// 7 | public bool IsForDlc => Kind.HasFlag(ERedditGameEntryKind.Dlc); 8 | 9 | public bool IsFreeToPlay => Kind.HasFlag(ERedditGameEntryKind.FreeToPlay); 10 | 11 | public void Deconstruct(out string identifier, out long date, out bool freeToPlay, out bool dlc) { 12 | identifier = Identifier; 13 | date = Date; 14 | freeToPlay = IsFreeToPlay; 15 | dlc = IsForDlc; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /ASFFreeGames/Reddit/RedditHelperRegexes.cs: -------------------------------------------------------------------------------- 1 | using System.Text.RegularExpressions; 2 | 3 | namespace Maxisoft.ASF.Reddit; 4 | 5 | internal static partial class RedditHelperRegexes { 6 | [GeneratedRegex(@"(.addlicense)\s+(asf)?\s*((?(s/|a/)\d+)\s*,?\s*)+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] 7 | internal static partial Regex Command(); 8 | 9 | [GeneratedRegex(@"free\s+DLC\s+for\s+a", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] 10 | internal static partial Regex IsDlc(); 11 | 12 | [GeneratedRegex(@"permanently\s+free", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] 13 | internal static partial Regex IsPermanentlyFree(); 14 | } 15 | -------------------------------------------------------------------------------- /ASFFreeGames/Reddit/RedditServerException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net; 3 | 4 | namespace Maxisoft.ASF.Reddit; 5 | 6 | public class RedditServerException : Exception { 7 | // A property to store the status code of the response 8 | public HttpStatusCode StatusCode { get; } 9 | 10 | // A constructor that takes a message and a status code as parameters 11 | public RedditServerException(string message, HttpStatusCode statusCode) : base(message) => StatusCode = statusCode; 12 | 13 | public RedditServerException() { } 14 | 15 | public RedditServerException(string message) : base(message) { } 16 | 17 | public RedditServerException(string message, Exception innerException) : base(message, innerException) { } 18 | } 19 | -------------------------------------------------------------------------------- /ASFFreeGames/Redlib/EGameType.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Maxisoft.ASF.Reddit; 3 | 4 | namespace Maxisoft.ASF.Redlib; 5 | 6 | [Flags] 7 | public enum EGameType : sbyte { 8 | None = 0, 9 | FreeToPlay = 1 << 0, 10 | PermenentlyFree = 1 << 1, 11 | Dlc = 1 << 2 12 | } 13 | 14 | public static class GameTypeExtensions { 15 | public static ERedditGameEntryKind ToRedditGameEntryKind(this EGameType type) { 16 | ERedditGameEntryKind res = ERedditGameEntryKind.None; 17 | 18 | if (type.HasFlag(EGameType.FreeToPlay)) { 19 | res |= ERedditGameEntryKind.FreeToPlay; 20 | } 21 | 22 | if (type.HasFlag(EGameType.Dlc)) { 23 | res |= ERedditGameEntryKind.Dlc; 24 | } 25 | 26 | return res; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /ASFFreeGames/Redlib/Exceptions/RedlibDisabledException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Maxisoft.ASF.Redlib; 4 | 5 | public class RedlibDisabledException : RedlibException { 6 | public RedlibDisabledException(string message) : base(message) { } 7 | 8 | public RedlibDisabledException() { } 9 | 10 | public RedlibDisabledException(string message, Exception innerException) : base(message, innerException) { } 11 | } 12 | -------------------------------------------------------------------------------- /ASFFreeGames/Redlib/Exceptions/RedlibException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Maxisoft.ASF.Redlib; 4 | 5 | public abstract class RedlibException : Exception { 6 | protected RedlibException(string message) : base(message) { } 7 | 8 | protected RedlibException() { } 9 | 10 | protected RedlibException(string message, Exception innerException) : base(message, innerException) { } 11 | } 12 | -------------------------------------------------------------------------------- /ASFFreeGames/Redlib/Exceptions/RedlibOutDatedListException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Maxisoft.ASF.Redlib; 4 | 5 | public class RedlibOutDatedListException : RedlibException { 6 | public RedlibOutDatedListException(string message) : base(message) { } 7 | 8 | public RedlibOutDatedListException() { } 9 | 10 | public RedlibOutDatedListException(string message, Exception innerException) : base(message, innerException) { } 11 | } 12 | -------------------------------------------------------------------------------- /ASFFreeGames/Redlib/GameIdentifiersEqualityComparer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using ASFFreeGames.ASFExtensions.Games; 4 | 5 | namespace Maxisoft.ASF.Redlib; 6 | #pragma warning disable CA1819 7 | 8 | public sealed class GameIdentifiersEqualityComparer : IEqualityComparer { 9 | public bool Equals(RedlibGameEntry x, RedlibGameEntry y) { 10 | if (x.GameIdentifiers.Count != y.GameIdentifiers.Count) { 11 | return false; 12 | } 13 | 14 | using IEnumerator xIt = x.GameIdentifiers.GetEnumerator(); 15 | using IEnumerator yIt = y.GameIdentifiers.GetEnumerator(); 16 | 17 | while (xIt.MoveNext() && yIt.MoveNext()) { 18 | if (!xIt.Current.Equals(yIt.Current)) { 19 | return false; 20 | } 21 | } 22 | 23 | return true; 24 | } 25 | 26 | public int GetHashCode(RedlibGameEntry obj) { 27 | HashCode h = new(); 28 | 29 | foreach (GameIdentifier id in obj.GameIdentifiers) { 30 | h.Add(id); 31 | } 32 | 33 | return h.ToHashCode(); 34 | } 35 | } 36 | 37 | #pragma warning restore CA1819 38 | -------------------------------------------------------------------------------- /ASFFreeGames/Redlib/Html/ParserIndices.cs: -------------------------------------------------------------------------------- 1 | namespace Maxisoft.ASF.Redlib.Html; 2 | 3 | internal readonly record struct ParserIndices(int StartOfCommandIndex, int EndOfCommandIndex, int StartOfFooterIndex, int HrefStartIndex, int HrefEndIndex, int DateStartIndex, int DateEndIndex); 4 | -------------------------------------------------------------------------------- /ASFFreeGames/Redlib/Html/RedlibHtmlParserRegex.cs: -------------------------------------------------------------------------------- 1 | using System.Text.RegularExpressions; 2 | 3 | namespace Maxisoft.ASF.Redlib.Html; 4 | 5 | #pragma warning disable CA1052 6 | 7 | public partial class RedlibHtmlParserRegex { 8 | [GeneratedRegex(@"(.addlicense)\s+(asf)?\s*((?(s/|a/)\d+)\s*,?\s*)+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] 9 | internal static partial Regex CommandRegex(); 10 | 11 | [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)] 12 | internal static partial Regex HrefCommentLinkRegex(); 13 | 14 | [GeneratedRegex(@".*free\s+DLC\s+for\s+a.*", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] 15 | internal static partial Regex IsDlcRegex(); 16 | 17 | [GeneratedRegex(@".*free\s+to\s+play.*", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] 18 | internal static partial Regex IsFreeToPlayRegex(); 19 | 20 | [GeneratedRegex(@".*permanently\s+free.*", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] 21 | internal static partial Regex IsPermanentlyFreeRegex(); 22 | } 23 | 24 | #pragma warning restore CA1052 25 | -------------------------------------------------------------------------------- /ASFFreeGames/Redlib/Html/SkipAndContinueParsingException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Maxisoft.ASF.Redlib.Html; 4 | 5 | public class SkipAndContinueParsingException : Exception { 6 | public int StartIndex { get; init; } 7 | 8 | public SkipAndContinueParsingException(string message, Exception innerException) : base(message, innerException) { } 9 | 10 | public SkipAndContinueParsingException() { } 11 | 12 | public SkipAndContinueParsingException(string message) : base(message) { } 13 | } 14 | -------------------------------------------------------------------------------- /ASFFreeGames/Redlib/Instances/CachedRedlibInstanceList.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics.CodeAnalysis; 4 | using System.Linq; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using ASFFreeGames.Configurations; 8 | using Maxisoft.ASF.HttpClientSimple; 9 | 10 | namespace Maxisoft.ASF.Redlib.Instances; 11 | 12 | public class CachedRedlibInstanceList(ASFFreeGamesOptions options, CachedRedlibInstanceListStorage storage) : IRedlibInstanceList { 13 | private readonly RedlibInstanceList InstanceList = new(options); 14 | 15 | public async Task> ListInstances([NotNull] SimpleHttpClient httpClient, CancellationToken cancellationToken) { 16 | if (((DateTimeOffset.Now - storage.LastUpdate).Duration() > TimeSpan.FromHours(1)) || (storage.Instances.Count == 0)) { 17 | List res = await InstanceList.ListInstances(httpClient, cancellationToken).ConfigureAwait(false); 18 | 19 | if (res.Count > 0) { 20 | storage.UpdateInstances(res); 21 | } 22 | } 23 | 24 | return storage.Instances.ToList(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /ASFFreeGames/Redlib/Instances/CachedRedlibInstanceListStorage.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace Maxisoft.ASF.Redlib.Instances; 5 | 6 | public record CachedRedlibInstanceListStorage(ICollection Instances, DateTimeOffset LastUpdate) { 7 | public ICollection Instances { get; private set; } = Instances; 8 | public DateTimeOffset LastUpdate { get; private set; } = LastUpdate; 9 | 10 | /// 11 | /// Updates the list of instances and its last update time 12 | /// 13 | /// The list of instances to update 14 | internal void UpdateInstances(ICollection instances) { 15 | Instances = instances; 16 | LastUpdate = DateTimeOffset.Now; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /ASFFreeGames/Redlib/Instances/IRedlibInstanceList.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics.CodeAnalysis; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using Maxisoft.ASF.HttpClientSimple; 7 | 8 | namespace Maxisoft.ASF.Redlib.Instances; 9 | 10 | [SuppressMessage("ReSharper", "RedundantNullableFlowAttribute")] 11 | public interface IRedlibInstanceList { 12 | Task> ListInstances([NotNull] SimpleHttpClient httpClient, CancellationToken cancellationToken); 13 | } 14 | -------------------------------------------------------------------------------- /ASFFreeGames/Redlib/Instances/RedlibInstanceList.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics.CodeAnalysis; 4 | using System.Globalization; 5 | using System.IO; 6 | using System.Reflection; 7 | using System.Text.Json.Nodes; 8 | using System.Threading; 9 | using System.Threading.Tasks; 10 | using ArchiSteamFarm.Core; 11 | using ASFFreeGames.Configurations; 12 | using Maxisoft.ASF.HttpClientSimple; 13 | using Maxisoft.ASF.Reddit; 14 | 15 | #nullable enable 16 | 17 | // ReSharper disable once CheckNamespace 18 | namespace Maxisoft.ASF.Redlib.Instances; 19 | 20 | [SuppressMessage("ReSharper", "RedundantNullableFlowAttribute")] 21 | public class RedlibInstanceList(ASFFreeGamesOptions options) : IRedlibInstanceList { 22 | private const string EmbeddedFileName = "redlib_instances.json"; 23 | 24 | private static readonly HashSet DisabledKeywords = new(StringComparer.OrdinalIgnoreCase) { 25 | "disabled", 26 | "off", 27 | "no", 28 | "false" 29 | }; 30 | 31 | private readonly ASFFreeGamesOptions Options = options ?? throw new ArgumentNullException(nameof(options)); 32 | 33 | public async Task> ListInstances([NotNull] SimpleHttpClient httpClient, CancellationToken cancellationToken) { 34 | if (IsDisabled(Options.RedlibInstanceUrl)) { 35 | throw new RedlibDisabledException(); 36 | } 37 | 38 | if (!Uri.TryCreate(Options.RedlibInstanceUrl, UriKind.Absolute, out Uri? uri)) { 39 | ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericError("[FreeGames] Invalid redlib instances url: " + Options.RedlibInstanceUrl); 40 | 41 | return await ListFromEmbedded(cancellationToken).ConfigureAwait(false); 42 | } 43 | 44 | #pragma warning disable CAC001 45 | #pragma warning disable CA2007 46 | await using HttpStreamResponse response = await httpClient.GetStreamAsync(uri, cancellationToken: cancellationToken).ConfigureAwait(false); 47 | #pragma warning restore CA2007 48 | #pragma warning restore CAC001 49 | 50 | if (!response.StatusCode.IsSuccessCode()) { 51 | return await ListFromEmbedded(cancellationToken).ConfigureAwait(false); 52 | } 53 | 54 | JsonNode? node = await ParseJsonNode(response, cancellationToken).ConfigureAwait(false); 55 | 56 | if (node is null) { 57 | return await ListFromEmbedded(cancellationToken).ConfigureAwait(false); 58 | } 59 | 60 | CheckUpToDate(node); 61 | 62 | List res = ParseUrls(node); 63 | 64 | return res.Count > 0 ? res : await ListFromEmbedded(cancellationToken).ConfigureAwait(false); 65 | } 66 | 67 | internal static void CheckUpToDate(JsonNode node) { 68 | int currentYear = DateTime.Now.Year; 69 | string updated = node["updated"]?.GetValue() ?? ""; 70 | 71 | if (!updated.StartsWith(currentYear.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal) && 72 | !updated.StartsWith((currentYear - 1).ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal)) { 73 | throw new RedlibOutDatedListException(); 74 | } 75 | } 76 | 77 | internal static async Task> ListFromEmbedded(CancellationToken cancellationToken) { 78 | JsonNode? node = await LoadEmbeddedInstance(cancellationToken).ConfigureAwait(false); 79 | 80 | if (node is null) { 81 | #pragma warning disable CA2201 82 | throw new NullReferenceException($"unable to find embedded file {EmbeddedFileName}"); 83 | #pragma warning restore CA2201 84 | } 85 | 86 | CheckUpToDate(node); 87 | 88 | return ParseUrls(node); 89 | } 90 | 91 | internal static List ParseUrls(JsonNode json) { 92 | JsonNode? instances = json["instances"]; 93 | 94 | if (instances is null) { 95 | return []; 96 | } 97 | 98 | List uris = new(((JsonArray) instances).Count); 99 | 100 | // ReSharper disable once LoopCanBePartlyConvertedToQuery 101 | foreach (JsonNode? instance in (JsonArray) instances) { 102 | JsonNode? url = instance?["url"]; 103 | 104 | if (Uri.TryCreate(url?.GetValue() ?? "", UriKind.Absolute, out Uri? instanceUri) && instanceUri.Scheme is "http" or "https") { 105 | uris.Add(instanceUri); 106 | } 107 | } 108 | 109 | return uris; 110 | } 111 | 112 | private static bool IsDisabled(string? instanceUrl) => instanceUrl is not null && DisabledKeywords.Contains(instanceUrl.Trim()); 113 | 114 | private static async Task LoadEmbeddedInstance(CancellationToken cancellationToken) { 115 | Assembly assembly = Assembly.GetExecutingAssembly(); 116 | 117 | #pragma warning disable CAC001 118 | #pragma warning disable CA2007 119 | await using Stream stream = assembly.GetManifestResourceStream($"{assembly.GetName().Name}.Resources.{EmbeddedFileName}")!; 120 | #pragma warning restore CA2007 121 | #pragma warning restore CAC001 122 | 123 | using StreamReader reader = new(stream); // assume the encoding is UTF8, cannot be specified as per issue #91 124 | string data = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); 125 | 126 | return JsonNode.Parse(data); 127 | } 128 | 129 | private static Task ParseJsonNode(HttpStreamResponse stream, CancellationToken cancellationToken) => RedditHelper.ParseJsonNode(stream, cancellationToken); 130 | } 131 | -------------------------------------------------------------------------------- /ASFFreeGames/Redlib/RedlibGameEntry.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using ASFFreeGames.ASFExtensions.Games; 4 | using Maxisoft.ASF.Reddit; 5 | 6 | // ReSharper disable once CheckNamespace 7 | namespace Maxisoft.ASF.Redlib; 8 | 9 | #pragma warning disable CA1819 10 | 11 | public readonly record struct RedlibGameEntry(IReadOnlyCollection GameIdentifiers, string CommentLink, EGameType TypeFlags, DateTimeOffset Date) { 12 | public RedditGameEntry ToRedditGameEntry(long date = default) { 13 | if ((Date != default(DateTimeOffset)) && (Date != DateTimeOffset.MinValue)) { 14 | date = Date.ToUnixTimeMilliseconds(); 15 | } 16 | 17 | return new RedditGameEntry(string.Join(',', GameIdentifiers), TypeFlags.ToRedditGameEntryKind(), date); 18 | } 19 | } 20 | 21 | #pragma warning restore CA1819 22 | -------------------------------------------------------------------------------- /ASFFreeGames/Resources/redlib_instances.json: -------------------------------------------------------------------------------- 1 | { 2 | "updated": "2025-03-05", 3 | "instances": [ 4 | { 5 | "url": "https://safereddit.com", 6 | "country": "US", 7 | "version": "v0.35.1", 8 | "description": "SFW only" 9 | }, 10 | { 11 | "url": "https://l.opnxng.com", 12 | "country": "SG", 13 | "version": "v0.35.1" 14 | }, 15 | { 16 | "url": "https://libreddit.projectsegfau.lt", 17 | "country": "LU", 18 | "version": "v0.35.1" 19 | }, 20 | { 21 | "url": "https://redlib.catsarch.com", 22 | "country": "US", 23 | "version": "v0.35.1" 24 | }, 25 | { 26 | "url": "https://redlib.perennialte.ch", 27 | "country": "AU", 28 | "version": "v0.35.1", 29 | "cloudflare": true 30 | }, 31 | { 32 | "url": "https://rl.bloat.cat", 33 | "country": "RO", 34 | "version": "v0.35.1" 35 | }, 36 | { 37 | "url": "https://red.ngn.tf", 38 | "country": "TR", 39 | "version": "v0.35.1" 40 | }, 41 | { 42 | "url": "https://r.darrennathanael.com", 43 | "country": "ID", 44 | "version": "v0.35.1", 45 | "description": "contact noc at darrennathanael.com" 46 | }, 47 | { 48 | "url": "https://redlib.kittywi.re", 49 | "country": "FR", 50 | "version": "v0.35.1" 51 | }, 52 | { 53 | "url": "https://redlib.privacyredirect.com", 54 | "country": "FI", 55 | "version": "v0.35.1" 56 | }, 57 | { 58 | "url": "https://reddit.nerdvpn.de", 59 | "country": "UA", 60 | "version": "v0.35.1", 61 | "description": "SFW only" 62 | }, 63 | { 64 | "url": "https://redlib.baczek.me", 65 | "country": "PL", 66 | "version": "v0.35.1" 67 | }, 68 | { 69 | "url": "https://redlib.nadeko.net", 70 | "country": "CL", 71 | "version": "v0.35.1", 72 | "description": "I don't like reddit." 73 | }, 74 | { 75 | "url": "https://redlib.private.coffee", 76 | "country": "AT", 77 | "version": "v0.35.1" 78 | }, 79 | { 80 | "url": "https://red.arancia.click", 81 | "country": "US", 82 | "version": "v0.35.1" 83 | }, 84 | { 85 | "url": "https://redlib.reallyaweso.me", 86 | "country": "DE", 87 | "version": "v0.35.1", 88 | "description": "A reallyaweso.me redlib instance!" 89 | }, 90 | { 91 | "url": "https://redlib.privacy.com.de", 92 | "country": "DE", 93 | "version": "v0.35.1" 94 | }, 95 | { 96 | "onion": "http://red.lpoaj7z2zkajuhgnlltpeqh3zyq7wk2iyeggqaduhgxhyajtdt2j7wad.onion", 97 | "country": "DE", 98 | "version": "v0.35.1", 99 | "description": "Onion of red.artemislena.eu" 100 | } 101 | ] 102 | } 103 | -------------------------------------------------------------------------------- /ASFFreeGames/Utils/LoggerFilter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Collections.Generic; 4 | using System.Diagnostics.CodeAnalysis; 5 | using System.Linq; 6 | using System.Reflection; 7 | using System.Text.RegularExpressions; 8 | using ArchiSteamFarm.NLog; 9 | using ArchiSteamFarm.Steam; 10 | using ASFFreeGames.ASFExtensions.Bot; 11 | using Maxisoft.ASF.ASFExtensions; 12 | using NLog; 13 | using NLog.Config; 14 | using NLog.Filters; 15 | 16 | // ReSharper disable RedundantNullableFlowAttribute 17 | 18 | namespace Maxisoft.ASF.Utils; 19 | 20 | #nullable enable 21 | 22 | /// 23 | /// Represents a class that provides methods for filtering log events based on custom criteria. 24 | /// 25 | public partial class LoggerFilter { 26 | // A concurrent dictionary that maps bot names to lists of filter functions 27 | private readonly ConcurrentDictionary>> Filters = new(); 28 | 29 | // A custom filter that invokes the FilterLogEvent method 30 | private readonly MarkedWhenMethodFilter MethodFilter; 31 | 32 | /// 33 | /// Initializes a new instance of the class. 34 | /// 35 | public LoggerFilter() => MethodFilter = new MarkedWhenMethodFilter(FilterLogEvent); 36 | 37 | /// 38 | /// Disables logging for a specific bot based on a filter function. 39 | /// 40 | /// The filter function that determines whether to ignore a log event. 41 | /// The bot instance whose logging should be disabled. 42 | /// A disposable object that can be used to re-enable logging when disposed. 43 | public IDisposable DisableLogging(Func filter, [NotNull] Bot bot) { 44 | Logger logger = GetLogger(bot.ArchiLogger, bot.BotName); 45 | 46 | lock (Filters) { 47 | Filters.TryGetValue(bot.BotName, out LinkedList>? filters); 48 | 49 | if (filters is null) { 50 | filters = new LinkedList>(); 51 | 52 | if (!Filters.TryAdd(bot.BotName, filters)) { 53 | filters = Filters[bot.BotName]; 54 | } 55 | } 56 | 57 | LinkedListNode> node = filters.AddLast(filter); 58 | LoggingConfiguration? config = logger.Factory.Configuration; 59 | 60 | bool reconfigure = false; 61 | 62 | foreach (LoggingRule loggingRule in config.LoggingRules.Where(loggingRule => !loggingRule.Filters.Any(f => ReferenceEquals(f, MethodFilter)))) { 63 | loggingRule.Filters.Insert(0, MethodFilter); 64 | reconfigure = true; 65 | } 66 | 67 | if (reconfigure) { 68 | logger.Factory.ReconfigExistingLoggers(); 69 | } 70 | 71 | return new LoggerRemoveFilterDisposable(node); 72 | } 73 | } 74 | 75 | /// 76 | /// Disables logging for a specific bot based on a filter function and a regex pattern for common errors when adding licenses. 77 | /// 78 | /// The filter function that determines whether to ignore a log event. 79 | /// The bot instance whose logging should be disabled. 80 | /// A disposable object that can be used to re-enable logging when disposed. 81 | public IDisposable DisableLoggingForAddLicenseCommonErrors(Func filter, [NotNull] Bot bot) { 82 | bool filter2(LogEventInfo info) => (info.Level == LogLevel.Debug) && filter(info) && AddLicenseCommonErrorsRegex().IsMatch(info.Message); 83 | 84 | return DisableLogging(filter2, bot); 85 | } 86 | 87 | /// 88 | /// Removes all filters for a specific bot. 89 | /// 90 | /// The bot instance whose filters should be removed. 91 | /// True if the removal was successful; otherwise, false. 92 | public bool RemoveFilters(Bot? bot) => bot is not null && RemoveFilters(bot.BotName); 93 | 94 | // A regex pattern for common errors when adding licenses 95 | [GeneratedRegex(@"^.*?InternalRequest(?>\s*)\(\w*?\)(?>\s*)(?:(?:InternalServerError)|(?:Forbidden)).*?$", RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.CultureInvariant)] 96 | private static partial Regex AddLicenseCommonErrorsRegex(); 97 | 98 | // A method that filters log events based on the registered filter functions 99 | private FilterResult FilterLogEvent(LogEventInfo eventInfo) { 100 | Bot? bot = eventInfo.LoggerName == "ASF" ? null : Bot.GetBot(eventInfo.LoggerName ?? ""); 101 | 102 | if (Filters.TryGetValue(bot?.BotName ?? eventInfo.LoggerName ?? "", out LinkedList>? filters)) { 103 | return filters.Any(func => func(eventInfo)) ? FilterResult.IgnoreFinal : FilterResult.Log; 104 | } 105 | 106 | return FilterResult.Log; 107 | } 108 | 109 | // A method that gets the logger instance from the ArchiLogger instance using introspection 110 | private static Logger GetLogger(ArchiLogger logger, string name = "ASF") { 111 | FieldInfo? field = logger.GetType().GetField("Logger", BindingFlags.IgnoreCase | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.GetField | BindingFlags.GetProperty); 112 | 113 | // Check if the field is null or the value is not a Logger instance 114 | return field?.GetValue(logger) is not Logger loggerInstance 115 | ? 116 | 117 | // Return a default logger with the given name 118 | LogManager.GetLogger(name) 119 | : 120 | 121 | // Return the logger instance from the field 122 | loggerInstance; 123 | } 124 | 125 | // A method that removes filters by bot name 126 | private bool RemoveFilters(BotName botName) => Filters.TryRemove(botName, out _); 127 | 128 | // A class that implements a disposable object for removing filters 129 | private sealed class LoggerRemoveFilterDisposable(LinkedListNode> node) : IDisposable { 130 | public void Dispose() => node.List?.Remove(node); 131 | } 132 | 133 | // A class that implements a custom filter that invokes a method 134 | private class MarkedWhenMethodFilter(Func filterMethod) : WhenMethodFilter(filterMethod); 135 | } 136 | -------------------------------------------------------------------------------- /ASFFreeGames/Utils/RandomUtils.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.Diagnostics.CodeAnalysis; 4 | using System.Runtime.CompilerServices; 5 | using System.Runtime.InteropServices; 6 | using System.Security.Cryptography; 7 | using System.Threading; 8 | 9 | namespace Maxisoft.ASF.Utils; 10 | 11 | #nullable enable 12 | 13 | /// 14 | /// Provides utility methods for generating random numbers. 15 | /// 16 | public static class RandomUtils { 17 | internal sealed class GaussianRandom { 18 | // A flag to indicate if there is a stored value for the next Gaussian number 19 | private int HasNextGaussian; 20 | 21 | private const int True = 1; 22 | private const int False = 0; 23 | 24 | // The stored value for the next Gaussian number 25 | private double NextGaussianValue; 26 | 27 | /// 28 | /// Fills the provided span with non-zero random bytes. 29 | /// 30 | /// The span to fill with non-zero random bytes. 31 | private void GetNonZeroBytes(Span data) { 32 | Span bytes = stackalloc byte[sizeof(long)]; 33 | 34 | static void fill(Span bytes) { 35 | // use this method to use a RNGs function that's still included with the ASF trimmed binary 36 | // do not try to refactor or optimize this without testing 37 | byte[] rng = RandomNumberGenerator.GetBytes(bytes.Length); 38 | ((ReadOnlySpan) rng).CopyTo(bytes); 39 | } 40 | 41 | fill(bytes); 42 | int c = 0; 43 | 44 | for (int i = 0; i < data.Length; i++) { 45 | byte value; 46 | 47 | do { 48 | value = bytes[c]; 49 | c++; 50 | 51 | if (c >= bytes.Length) { 52 | fill(bytes); 53 | c = 0; 54 | } 55 | } while (value == 0); 56 | 57 | data[i] = value; 58 | } 59 | } 60 | 61 | /// 62 | /// Generates a random double value. 63 | /// 64 | /// A random double value. 65 | private double NextDouble() { 66 | if (Interlocked.CompareExchange(ref HasNextGaussian, False, True) == True) { 67 | return NextGaussianValue; 68 | } 69 | 70 | Span bytes = stackalloc byte[2 * sizeof(long)]; 71 | 72 | Span ulongs = MemoryMarshal.Cast(bytes); 73 | double u1; 74 | 75 | do { 76 | GetNonZeroBytes(bytes); 77 | u1 = ulongs[0] / (double) ulong.MaxValue; 78 | } while (u1 <= double.Epsilon); 79 | 80 | double u2 = ulongs[1] / (double) ulong.MaxValue; 81 | 82 | // Box-Muller formula 83 | double r = Math.Sqrt(-2.0 * Math.Log(u1)); 84 | double theta = 2.0 * Math.PI * u2; 85 | 86 | if (Interlocked.CompareExchange(ref HasNextGaussian, True, False) == False) { 87 | NextGaussianValue = r * Math.Sin(theta); 88 | } 89 | 90 | return r * Math.Cos(theta); 91 | } 92 | 93 | /// 94 | /// Generates a random number from a normal distribution with the specified mean and standard deviation. 95 | /// 96 | /// The mean of the normal distribution. 97 | /// The standard deviation of the normal distribution. 98 | /// A random number from the normal distribution. 99 | /// 100 | /// This method uses the overridden NextDouble method to get a normally distributed random number. 101 | /// 102 | public double NextGaussian(double mean, double standardDeviation) { 103 | // Use the overridden NextDouble method to get a normally distributed random 104 | double rnd; 105 | 106 | do { 107 | rnd = NextDouble(); 108 | } while (!double.IsFinite(rnd)); 109 | 110 | return mean + (standardDeviation * rnd); 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /ASFFreeGames/Utils/Workarounds/AsyncLocal.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Reflection; 3 | 4 | namespace Maxisoft.ASF.Utils.Workarounds; 5 | 6 | public sealed class AsyncLocal { 7 | // ReSharper disable once StaticMemberInGenericType 8 | private static readonly Type? AsyncLocalType; 9 | 10 | #pragma warning disable CA1810 11 | static AsyncLocal() { 12 | #pragma warning restore CA1810 13 | try { 14 | AsyncLocalType = Type.GetType("System.Threading.AsyncLocal`1") 15 | ?.MakeGenericType(typeof(T)); 16 | } 17 | catch (InvalidOperationException) { 18 | // ignore 19 | } 20 | 21 | try { 22 | AsyncLocalType ??= Type.GetType("System.Threading.AsyncLocal") 23 | ?.MakeGenericType(typeof(T)); 24 | } 25 | 26 | catch (InvalidOperationException) { 27 | // ignore 28 | } 29 | } 30 | 31 | private readonly object? Delegate; 32 | private T? NonSafeValue; 33 | 34 | /// Instantiates an instance that does not receive change notifications. 35 | public AsyncLocal() { 36 | if (AsyncLocalType is not null) { 37 | try { 38 | Delegate = Activator.CreateInstance(AsyncLocalType)!; 39 | } 40 | catch (Exception) { 41 | // ignored 42 | } 43 | } 44 | } 45 | 46 | /// Gets or sets the value of the ambient data. 47 | /// The value of the ambient data. If no value has been set, the returned value is default(T). 48 | public T? Value { 49 | get { 50 | if (Delegate is not null) { 51 | try { 52 | PropertyInfo? property = Delegate.GetType().GetProperty("Value"); 53 | 54 | if (property is not null) { 55 | return (T) property.GetValue(Delegate)!; 56 | } 57 | } 58 | catch (Exception) { 59 | // ignored 60 | } 61 | } 62 | 63 | return (T) NonSafeValue!; 64 | } 65 | set { 66 | if (Delegate is not null) { 67 | try { 68 | PropertyInfo? property = Delegate.GetType().GetProperty("Value"); 69 | 70 | if (property is not null) { 71 | property.SetValue(Delegate, value); 72 | 73 | return; 74 | } 75 | } 76 | catch (Exception) { 77 | // ignored 78 | } 79 | } 80 | 81 | NonSafeValue = value; 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /ASFFreeGames/Utils/Workarounds/BotPackageChecker.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Collections.Generic; 4 | using System.Globalization; 5 | using System.Reflection; 6 | using System.Threading; 7 | using ArchiSteamFarm.Steam; 8 | 9 | namespace Maxisoft.ASF.Utils.Workarounds; 10 | 11 | /// 12 | /// Provides resilient package ownership checks for bots with automatic fallback strategies. 13 | /// Implements caching and hot-reload awareness for improved performance and reliability. 14 | /// 15 | public static class BotPackageChecker { 16 | /// 17 | /// Checks if a bot owns a specific package using multiple strategies: 18 | /// 1. Direct property access (fast path) 19 | /// 2. Cached reflection metadata 20 | /// 3. Full reflection fallback 21 | /// 22 | /// Target bot instance 23 | /// Steam application ID to check 24 | /// 25 | /// True if the bot owns the package, false otherwise. 26 | /// Returns false for null bots or invalid app IDs. 27 | /// 28 | public static bool BotOwnsPackage(Bot? bot, uint appId) { 29 | if (bot is null) { 30 | return false; 31 | } 32 | 33 | try { 34 | MaintainHotReloadAwareness(); 35 | 36 | if (TryGetCachedResult(bot, appId, out bool cachedResult)) { 37 | return cachedResult; 38 | } 39 | 40 | bool result = CheckOwnership(bot, appId); 41 | UpdateCache(bot, appId, result); 42 | 43 | return result; 44 | } 45 | catch (Exception e) { 46 | bot.ArchiLogger.LogGenericException(e); 47 | 48 | return false; 49 | } 50 | } 51 | 52 | #region Cache Configuration 53 | private static readonly ConcurrentDictionary> OwnershipCache = new(); 54 | private static readonly Lock CacheLock = new(); 55 | private static Guid LastKnownBotAssemblyMvid; 56 | #endregion 57 | 58 | #region Reflection State 59 | private static bool? DirectAccessValid; 60 | private static PropertyInfo? CachedOwnershipProperty; 61 | #endregion 62 | 63 | #region Core Implementation 64 | private static bool CheckOwnership(Bot bot, uint appId) { 65 | // Attempt direct access first when possible 66 | if (DirectAccessValid is not false) { 67 | DirectAccessValid = false; // the MissingMemberException may not be caught in this very method. this act as a guard if that fails 68 | 69 | try { 70 | bool result = DirectOwnershipCheck(bot, appId); 71 | DirectAccessValid = true; 72 | 73 | return result; 74 | } 75 | catch (Exception e) { 76 | DirectAccessValid = false; 77 | bot.ArchiLogger.LogGenericError($"Direct access failed: {e.Message}"); 78 | } 79 | } 80 | 81 | return ReflectiveOwnershipCheck(bot, appId); 82 | } 83 | 84 | // ReSharper disable once ConditionalAccessQualifierIsNonNullableAccordingToAPIContract 85 | private static bool DirectOwnershipCheck(Bot bot, uint appId) => bot.OwnedPackages?.ContainsKey(appId) ?? false; 86 | #endregion 87 | 88 | #region Reflection Implementation 89 | private static bool ReflectiveOwnershipCheck(Bot bot, uint appId) { 90 | PropertyInfo? property = GetOwnershipProperty(bot); 91 | object? ownedPackages = property?.GetValue(bot); 92 | 93 | if (ownedPackages is null) { 94 | bot.ArchiLogger.LogGenericError("Owned packages property is null"); 95 | 96 | return false; 97 | } 98 | 99 | Type dictType = ownedPackages.GetType(); 100 | 101 | Type? iDictType = dictType.GetInterface("System.Collections.Generic.IDictionary`2") ?? 102 | dictType.GetInterface("System.Collections.Generic.IReadOnlyDictionary`2"); 103 | 104 | if (iDictType is null) { 105 | bot.ArchiLogger.LogGenericError("Owned packages is not a recognized dictionary type"); 106 | 107 | return false; 108 | } 109 | 110 | Type keyType = iDictType.GetGenericArguments()[0]; 111 | object convertedKey; 112 | 113 | try { 114 | convertedKey = Convert.ChangeType(appId, keyType, CultureInfo.InvariantCulture); 115 | } 116 | catch (OverflowException) { 117 | bot.ArchiLogger.LogGenericError($"Overflow converting AppID {appId} to {keyType.Name}"); 118 | 119 | return false; 120 | } 121 | catch (InvalidCastException) { 122 | bot.ArchiLogger.LogGenericError($"Invalid cast converting AppID {appId} to {keyType.Name}"); 123 | 124 | return false; 125 | } 126 | 127 | MethodInfo? containsKeyMethod = iDictType.GetMethod("ContainsKey"); 128 | 129 | if (containsKeyMethod is null) { 130 | bot.ArchiLogger.LogGenericError("ContainsKey method not found on dictionary"); 131 | 132 | return false; 133 | } 134 | 135 | try { 136 | return (bool) (containsKeyMethod.Invoke(ownedPackages, [convertedKey]) ?? false); 137 | } 138 | catch (TargetInvocationException e) { 139 | bot.ArchiLogger.LogGenericError($"Invocation of {containsKeyMethod.Name} failed: {e.InnerException?.Message ?? e.Message}"); 140 | 141 | return false; 142 | } 143 | } 144 | 145 | private static PropertyInfo? GetOwnershipProperty(Bot bot) { 146 | if (CachedOwnershipProperty != null) { 147 | return CachedOwnershipProperty; 148 | } 149 | 150 | const StringComparison comparison = StringComparison.Ordinal; 151 | PropertyInfo[] properties = typeof(Bot).GetProperties(BindingFlags.Public | BindingFlags.Instance); 152 | 153 | // ReSharper disable once LoopCanBePartlyConvertedToQuery 154 | foreach (PropertyInfo property in properties) { 155 | if (property.Name.Equals("OwnedPackages", comparison) || 156 | property.Name.Equals("OwnedPackageIDs", comparison)) { 157 | CachedOwnershipProperty = property; 158 | 159 | return property; 160 | } 161 | } 162 | 163 | bot.ArchiLogger.LogGenericError("Valid ownership property not found"); 164 | 165 | return null; 166 | } 167 | #endregion 168 | 169 | #region Cache Management 170 | private static void MaintainHotReloadAwareness() { 171 | Guid currentMvid = typeof(Bot).Assembly.ManifestModule.ModuleVersionId; 172 | 173 | lock (CacheLock) { 174 | if (currentMvid != LastKnownBotAssemblyMvid) { 175 | OwnershipCache.Clear(); 176 | CachedOwnershipProperty = null; 177 | DirectAccessValid = null; 178 | LastKnownBotAssemblyMvid = currentMvid; 179 | } 180 | } 181 | } 182 | 183 | private static bool TryGetCachedResult(Bot bot, uint appId, out bool result) { 184 | ConcurrentDictionary botCache = OwnershipCache.GetOrAdd( 185 | bot.BotName, 186 | static _ => new ConcurrentDictionary() 187 | ); 188 | 189 | return botCache.TryGetValue(appId, out result); 190 | } 191 | 192 | private static void UpdateCache(Bot bot, uint appId, bool result) { 193 | ConcurrentDictionary botCache = OwnershipCache.GetOrAdd( 194 | bot.BotName, 195 | static _ => new ConcurrentDictionary() 196 | ); 197 | 198 | botCache[appId] = result; 199 | } 200 | 201 | internal static void RemoveBotCache(Bot bot) => OwnershipCache.TryRemove(bot.BotName, out _); 202 | #endregion 203 | } 204 | -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ASFFreeGames 6 | 1.9.0.0 7 | net9.0 8 | 9 | 10 | 11 | AllEnabledByDefault 12 | 13 | Maxisoft 14 | $(Authors) 15 | Copyright © 2022-$([System.DateTime]::UtcNow.Year) $(Company) 16 | Gather free steam games 17 | 18 | AGPL 19 | https://github.com/$(Company)/$(PluginName) 20 | $(PackageProjectUrl)/releases 21 | $(PackageProjectUrl).git 22 | 23 | 24 | 25 | NU1507 26 | 27 | 28 | 29 | 30 | 31 | false 32 | false 33 | 34 | 35 | 36 | 37 | ../resources/$(PluginName).snk.pub 38 | true 39 | true 40 | 41 | 42 | 43 | 44 | ../resources/$(PluginName).snk 45 | false 46 | true 47 | 48 | 49 | -------------------------------------------------------------------------------- /Directory.Packages.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ASF-FreeGames 2 | 3 | [![License: AGPL v3](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) [![Plugin-ci](https://github.com/maxisoft/ASFFreeGames/actions/workflows/ci.yml/badge.svg)](https://github.com/maxisoft/ASFFreeGames/actions/workflows/ci.yml) [![Github All Releases](https://img.shields.io/github/downloads/maxisoft/ASFFreeGames/total.svg)]() 4 | 5 | ## Description 6 | 7 | ASF-FreeGames is a **[plugin](https://github.com/JustArchiNET/ArchiSteamFarm/wiki/Plugins)** for **[ArchiSteamFarm](https://github.com/JustArchiNET/ArchiSteamFarm)** allowing one to automatically **collect free steam games** 🔑 posted on [Reddit](https://www.reddit.com/user/ASFinfo?sort=new). 8 | 9 | --- 10 | 11 | ## Requirements 12 | 13 | - ✅ a working [ArchiSteamFarm](https://github.com/JustArchiNET/ArchiSteamFarm) environment 14 | 15 | ## Installation 16 | 17 | - 🔽 Download latest [Dll](https://github.com/maxisoft/ASFFreeGames/releases) from the release page 18 | - ➡️ Move the **dll** into the `plugins` folder of your *ArchiSteamFarm* installation 19 | - 🔄 (re)start ArchiSteamFarm 20 | - 🎉 Have fun 21 | 22 | ## How does it work 23 | 24 | Every ⏰`30 minutes` the plugin starts 🔬analyzing [reddit](https://www.reddit.com/user/ASFinfo?sort=new) for new **free games**⚾. 25 | Then every 🔑`addlicense asf appid` command found is broadcasted to each currently **logged bot** 💪. 26 | 27 | ## Commands 28 | 29 | - `freegames` to collect free games right now 🚀 30 | - `getip` to get the IP used by ASF 👀 31 | - `set` to configure this plugin's options (see below) 🛠️ 32 | 33 | For information about issuing 📢commands see [ASF's wiki](https://github.com/JustArchiNET/ArchiSteamFarm/wiki) 34 | 35 | ### Advanced configuration 36 | 37 | The plugin behavior is configurable via command 38 | 39 | - `freegames set nof2p` to ⛔**prevent** the plugin from collecting **free to play** games 40 | - `freegames set f2p` to ☑️**allow** the plugin to collect **f2p** (the default) 41 | - `freegames set nodlc` to ⛔**prevent** the plugin from collecting **dlc** 42 | - `freegames set dlc` to ☑️**allow** the plugin to collect **dlc** (the default) 43 | 44 | 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. 45 | 46 | ## Proxy Setup 47 | 48 | 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: 49 | 50 | 1. **Environment Variable:** Set the environment variable `FREEGAMES_RedditProxy` with your desired proxy URL (e.g., `http://yourproxy:port`). 51 | 2. **`freegames.json.config`:** Edit the `redditProxy` property within the JSON configuration file located at `/config/freegames.json.config`. Set the value to your proxy URL. 52 | 53 | **Example `freegames.json.config` with Proxy:** 54 | 55 | ```json 56 | { 57 | ... 58 | "redditProxy": "http://127.0.0.1:1080" 59 | } 60 | ``` 61 | 62 | **Important Note:** If you pass a proxy **password**, it will be **stored in clear text** in the `freegames.json.config` file, even when passing it via the environment variable. 63 | 64 | **Note:** Whichever method you choose (environment variable or config file), only one will be used at a time. 65 | The environment variable takes precedence over the config file setting. 66 | 67 | ## FAQ 68 | 69 | ### Log is full of `Request failed after 5 attempts!` messages is there something wrong ? 70 | 71 | - There's nothing wrong (most likely), those error messages are the result of the plugin trying to add a steam key which is unavailable. With time those errors should occurs less frequently (see [#3](https://github.com/maxisoft/ASFFreeGames/issues/3) for more details). 72 | 73 | ### How to configure automatic updates for the plugin? 74 | 75 | The plugin supports checking for updates on GitHub. You can enable automatic updates by modifying the `PluginsUpdateList` property in your ArchiSteamFarm configuration (refer to the [ArchiSteamFarm wiki](https://github.com/JustArchiNET/ArchiSteamFarm/wiki/Configuration#pluginsupdatelist) for details). 76 | 77 | **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. 78 | 79 | ------ 80 | 81 | ## Dev notes 82 | 83 | ### Compilation 84 | 85 | Simply execute `dotnet build ASFFreeGames -c Release` and find the dll in `ASFFreeGames/bin` folder, which you can drag to ASF's `plugins` folder. 86 | 87 | [![GitHub sponsor](https://img.shields.io/badge/GitHub-sponsor-ea4aaa.svg?logo=github-sponsors)](https://github.com/sponsors/maxisoft) 88 | -------------------------------------------------------------------------------- /nuget.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | --------------------------------------------------------------------------------