├── .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