diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..9f0ee2c
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,260 @@
+# EditorConfig is awesome: https://EditorConfig.org
+
+# top-most EditorConfig file
+root = true
+
+[*]
+indent_style = space
+charset = utf-8
+end_of_line = lf
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+# XML project files
+[*.csproj]
+indent_size = 2
+
+resharper_space_before_self_closing = true
+
+# XML config files
+[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}]
+indent_size = 2
+
+# JSON files
+[*.json]
+indent_size = 2
+
+# Shell script files
+[*.sh]
+indent_size = 2
+
+# CSharp code style settings:
+[*.cs]
+indent_size = 4
+
+# Non-private static fields are PascalCase
+dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.severity = warning
+dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.symbols = non_private_static_fields
+dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.style = pascal_case_style
+
+dotnet_naming_symbols.non_private_static_fields.applicable_kinds = field
+dotnet_naming_symbols.non_private_static_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected
+dotnet_naming_symbols.non_private_static_fields.required_modifiers = static
+
+# Private static fields are camelCase
+dotnet_naming_rule.private_static_fields_should_be_camel_case.severity = warning
+dotnet_naming_rule.private_static_fields_should_be_camel_case.symbols = private_static_fields
+dotnet_naming_rule.private_static_fields_should_be_camel_case.style = camel_case_style
+
+dotnet_naming_symbols.private_static_fields.applicable_kinds = field
+dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private
+dotnet_naming_symbols.private_static_fields.required_modifiers = static
+
+# Non-private readonly fields are PascalCase
+dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.severity = warning
+dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.symbols = non_private_readonly_fields
+dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.style = pascal_case_style
+
+dotnet_naming_symbols.non_private_readonly_fields.applicable_kinds = field
+dotnet_naming_symbols.non_private_readonly_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected
+dotnet_naming_symbols.non_private_readonly_fields.required_modifiers = readonly
+
+# Private readonly fields with underscore
+dotnet_naming_rule.private_members_with_underscore.symbols = private_fields
+dotnet_naming_rule.private_members_with_underscore.style = prefix_underscore
+dotnet_naming_rule.private_members_with_underscore.severity = warning
+
+dotnet_naming_symbols.private_fields.applicable_kinds = field
+dotnet_naming_symbols.private_fields.applicable_accessibilities = private
+
+# Prefix style
+dotnet_naming_style.prefix_underscore.capitalization = camel_case
+dotnet_naming_style.prefix_underscore.required_prefix = _
+
+# Constants are PascalCase
+dotnet_naming_rule.local_constants_rule.severity = warning
+dotnet_naming_rule.local_constants_rule.style = pascal_case_style
+dotnet_naming_rule.local_constants_rule.symbols = local_constants_symbols
+
+dotnet_naming_symbols.private_constants_symbols.applicable_accessibilities = private
+dotnet_naming_symbols.private_constants_symbols.applicable_kinds = field
+dotnet_naming_symbols.private_constants_symbols.required_modifiers = const
+
+dotnet_naming_rule.private_constants_rule.severity = warning
+dotnet_naming_rule.private_constants_rule.style = pascal_case_style
+dotnet_naming_rule.private_constants_rule.symbols = private_constants_symbols
+
+dotnet_naming_symbols.local_constants_symbols.applicable_accessibilities = *
+dotnet_naming_symbols.local_constants_symbols.applicable_kinds = local
+dotnet_naming_symbols.local_constants_symbols.required_modifiers = const
+
+# Locals and parameters are camelCase
+dotnet_naming_rule.locals_should_be_camel_case.severity = warning
+dotnet_naming_rule.locals_should_be_camel_case.symbols = locals_and_parameters
+dotnet_naming_rule.locals_should_be_camel_case.style = camel_case_style
+
+dotnet_naming_symbols.locals_and_parameters.applicable_kinds = parameter, local
+
+# Local functions are PascalCase
+dotnet_naming_rule.local_functions_should_be_pascal_case.severity = warning
+dotnet_naming_rule.local_functions_should_be_pascal_case.symbols = local_functions
+dotnet_naming_rule.local_functions_should_be_pascal_case.style = pascal_case_style
+
+dotnet_naming_symbols.local_functions.applicable_kinds = local_function
+
+# Test methods are underscore tolerant
+dotnet_naming_rule.test_methods_should_be_underscore_tolerant.severity = warning
+dotnet_naming_rule.test_methods_should_be_underscore_tolerant.symbols = test_methods
+dotnet_naming_rule.test_methods_should_be_underscore_tolerant.style = test_methods_style
+
+dotnet_naming_symbols.test_methods.applicable_accessibilities = local, public
+dotnet_naming_symbols.test_methods.applicable_kinds =
+dotnet_naming_symbols.test_methods.resharper_applicable_kinds = test_member
+dotnet_naming_symbols.test_methods.resharper_required_modifiers = instance
+
+dotnet_naming_style.test_methods_style.capitalization = pascal_case
+dotnet_naming_style.test_methods_style.word_separator = _
+
+dotnet_naming_style.camel_case_style.capitalization = camel_case
+dotnet_naming_style.pascal_case_style.capitalization = pascal_case
+
+# Microsoft .NET properties
+csharp_preferred_modifier_order = public, protected, internal, private, new, abstract, virtual, override, sealed, static, readonly, extern, unsafe, volatile, async:suggestion
+dotnet_sort_system_directives_first = true
+dotnet_separate_import_directive_groups = false
+dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
+dotnet_style_predefined_type_for_member_access = true:suggestion
+dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion
+
+# Parentheses settings
+dotnet_style_parentheses_in_arithmetic_binary_operators = never_if_unnecessary:none
+dotnet_style_parentheses_in_other_binary_operators = never_if_unnecessary:none
+dotnet_style_parentheses_in_relational_binary_operators = never_if_unnecessary:none
+
+# Newline settings
+csharp_new_line_before_open_brace = all
+csharp_new_line_before_else = true
+csharp_new_line_before_catch = true
+csharp_new_line_before_finally = true
+csharp_new_line_before_members_in_object_initializers = false
+csharp_new_line_before_members_in_anonymous_types = true
+csharp_new_line_between_query_expression_clauses = true
+
+resharper_csharp_new_line_before_while = true
+
+# Indentation preferences
+csharp_indent_block_contents = true
+csharp_indent_braces = false
+csharp_indent_case_contents = true
+csharp_indent_case_contents_when_block = true
+csharp_indent_switch_labels = true
+csharp_indent_labels = flush_left
+
+# Prefer "var" everywhere
+csharp_style_var_for_built_in_types = true:suggestion
+csharp_style_var_when_type_is_apparent = true:suggestion
+csharp_style_var_elsewhere = true:suggestion
+
+# Prefer method-like constructs to have a block body
+csharp_style_expression_bodied_methods = when_on_single_line:suggestion
+csharp_style_expression_bodied_constructors = when_on_single_line:suggestion
+csharp_style_expression_bodied_operators = when_on_single_line:suggestion
+csharp_style_expression_bodied_local_functions = when_on_single_line:suggestion
+
+resharper_csharp_keep_existing_expr_member_arrangement = true
+resharper_csharp_place_expr_method_on_single_line = if_owner_is_single_line
+resharper_csharp_place_expr_property_on_single_line = if_owner_is_single_line
+
+# Prefer property-like constructs to have an expression-body
+csharp_style_expression_bodied_properties = true:suggestion
+csharp_style_expression_bodied_indexers = true:suggestion
+csharp_style_expression_bodied_accessors = true:suggestion
+
+resharper_local_function_body = expression_body
+resharper_method_or_operator_body = expression_body
+
+# Suggest more modern language features when available
+csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
+csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
+csharp_style_inlined_variable_declaration = true:suggestion
+csharp_style_throw_expression = true:suggestion
+csharp_style_conditional_delegate_call = true:suggestion
+csharp_prefer_simple_default_expression = true:suggestion
+
+# Space preferences
+csharp_space_after_cast = false
+csharp_space_after_colon_in_inheritance_clause = true
+csharp_space_after_comma = true
+csharp_space_after_dot = false
+csharp_space_after_keywords_in_control_flow_statements = true
+csharp_space_after_semicolon_in_for_statement = true
+csharp_space_around_binary_operators = before_and_after
+csharp_space_around_declaration_statements = false
+csharp_space_before_colon_in_inheritance_clause = true
+csharp_space_before_comma = false
+csharp_space_before_dot = false
+csharp_space_before_open_square_brackets = false
+csharp_space_before_semicolon_in_for_statement = false
+csharp_space_between_empty_square_brackets = false
+csharp_space_between_method_call_empty_parameter_list_parentheses = false
+csharp_space_between_method_call_name_and_opening_parenthesis = false
+csharp_space_between_method_call_parameter_list_parentheses = false
+csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
+csharp_space_between_method_declaration_name_and_open_parenthesis = false
+csharp_space_between_method_declaration_parameter_list_parentheses = false
+csharp_space_between_parentheses = false
+csharp_space_between_square_brackets = false
+
+# Blocks are allowed
+csharp_prefer_braces = true:silent
+csharp_preserve_single_line_blocks = true
+csharp_preserve_single_line_statements = true
+csharp_style_namespace_declarations = file_scoped
+
+resharper_csharp_braces_for_for = required
+resharper_csharp_braces_for_foreach = required
+resharper_csharp_braces_for_while = required
+resharper_csharp_braces_for_ifelse = required_for_multiline_statement
+resharper_csharp_space_within_single_line_array_initializer_braces = true
+
+# Alignment
+resharper_csharp_align_linq_query = true
+resharper_csharp_align_multiline_binary_expressions_chain = false
+resharper_csharp_keep_blank_lines_in_code = 1
+resharper_csharp_keep_blank_lines_in_declarations = 1
+resharper_csharp_stick_comment = false
+resharper_csharp_wrap_before_first_type_parameter_constraint = true
+resharper_csharp_wrap_lines = false
+resharper_csharp_wrap_multiple_type_parameter_constraints_style = chop_always
+resharper_csharp_wrap_linq_expressions = chop_always
+resharper_csharp_wrap_array_initializer_style = chop_if_long
+resharper_csharp_place_constructor_initializer_on_same_line = false
+resharper_csharp_place_accessorholder_attribute_on_same_line = false
+resharper_csharp_place_field_attribute_on_same_line = false
+resharper_csharp_place_simple_embedded_statement_on_same_line = false
+resharper_wrap_before_arrow_with_expressions = true
+resharper_csharp_wrap_before_arrow_with_expressions = true
+
+# Arrangement of method signatures
+resharper_csharp_wrap_parameters_style = chop_if_long
+resharper_csharp_max_formal_parameters_on_line = 3
+resharper_csharp_wrap_after_declaration_lpar = true
+
+# Arrangement of invocations
+resharper_csharp_wrap_arguments_style = chop_if_long
+resharper_csharp_max_invocation_arguments_on_line = 3
+resharper_csharp_wrap_after_invocation_lpar = true
+
+# Arguments
+resharper_csharp_arguments_literal = named
+
+# Comments
+resharper_xmldoc_indent_child_elements = RemoveIndent
+resharper_xmldoc_indent_text = RemoveIndent
+resharper_xmldoc_space_before_self_closing = false
+resharper_xmldoc_max_line_length = 150
+
+# Other
+resharper_event_handler_pattern_long = $object$_On$event$
+resharper_empty_statement_highlighting = suggestion
+resharper_csharp_trailing_comma_in_multiline_lists = true
diff --git a/.github/workflows/nuget_publish.yaml b/.github/workflows/nuget_publish.yaml
new file mode 100644
index 0000000..b4f111b
--- /dev/null
+++ b/.github/workflows/nuget_publish.yaml
@@ -0,0 +1,125 @@
+name: publish nuget
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - 'main'
+ pull_request:
+ branches:
+ - '*'
+ release:
+ types:
+ - published
+
+env:
+ DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1
+ DOTNET_NOLOGO: true
+ NuGetDirectory: ${{github.workspace}}/nuget
+ DOTNET_TARGET_VERSION: 8.0.x
+ BUILD_NUMBER: ${{ github.run_number }}
+
+defaults:
+ run:
+ shell: pwsh
+
+jobs:
+ create_nuget:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # Get all history to allow automatic versioning using MinVer
+
+ # Install the .NET SDK indicated in the global.json file
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: ${{ env.DOTNET_TARGET_VERSION }}
+
+
+ # Create the NuGet package in the folder from the environment variable NuGetDirectory
+ - name: Build packages
+ run: dotnet pack --property:ContinuousIntegrationBuild=true --version-suffix ".${{ env.BUILD_NUMBER }}-dev" --configuration Release --output ${{ env.NuGetDirectory }}
+ if: github.event_name != 'release'
+
+ # Create the NuGet package in the folder from the environment variable NuGetDirectory
+ - name: Build packages
+ run: dotnet pack --property:ContinuousIntegrationBuild=true --version-suffix "-beta" --configuration Release --output ${{ env.NuGetDirectory }}
+ if: github.event_name == 'release'
+
+ # Publish the NuGet package as an artifact, so they can be used in the following jobs
+ - uses: actions/upload-artifact@v4
+ with:
+ name: nuget
+ if-no-files-found: error
+ retention-days: 7
+ path: |
+ ${{ env.NuGetDirectory }}/*.nupkg
+ ${{ env.NuGetDirectory }}/*.snupkg
+
+ validate_nuget:
+ runs-on: ubuntu-latest
+ needs: [ create_nuget ]
+ steps:
+ # Install the .NET SDK indicated in the global.json file
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: ${{ env.DOTNET_TARGET_VERSION }}
+
+ # Download the NuGet package created in the previous job
+ - uses: actions/download-artifact@v4
+ with:
+ name: nuget
+ path: ${{ env.NuGetDirectory }}
+
+ - name: Install nuget validator
+ run: dotnet tool update Meziantou.Framework.NuGetPackageValidation.Tool --global
+
+ # Validate metadata and content of the NuGet package
+ # https://www.nuget.org/packages/Meziantou.Framework.NuGetPackageValidation.Tool#readme-body-tab
+ # If some rules are not applicable, you can disable them
+ # using the --excluded-rules or --excluded-rule-ids option
+ - name: Validate package
+ run: meziantou.validate-nuget-package (Get-ChildItem "${{ env.NuGetDirectory }}/*.nupkg") --excluded-rules LicenseMustBeSet
+
+
+ run_test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: ${{ env.DOTNET_TARGET_VERSION }}
+ - name: Run tests
+ run: dotnet test --configuration Release
+
+ deploy:
+ # Publish only when creating a GitHub Release
+ # https://docs.github.com/en/repositories/releasing-projects-on-github/managing-releases-in-a-repository
+ # You can update this logic if you want to manage releases differently
+ if: github.event_name == 'release'
+ runs-on: ubuntu-latest
+ needs: [ validate_nuget, run_test ]
+ steps:
+ # Download the NuGet package created in the previous job
+ - uses: actions/download-artifact@v4
+ with:
+ name: nuget
+ path: ${{ env.NuGetDirectory }}
+
+ # Install the .NET SDK indicated in the global.json file
+ - name: Setup .NET Core
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: ${{ env.DOTNET_TARGET_VERSION }}
+
+ # Publish all NuGet packages to NuGet.org
+ # Use --skip-duplicate to prevent errors if a package with the same version already exists.
+ # If you retry a failed workflow, already published packages will be skipped without error.
+ - name: Publish NuGet package
+ run: |
+ foreach($file in (Get-ChildItem "${{ env.NuGetDirectory }}" -Recurse -Include *.nupkg)) {
+ dotnet nuget push $file --api-key "${{ secrets.NUGET_API_TOKEN }}" --source https://api.nuget.org/v3/index.json --skip-duplicate
+ }
diff --git a/Directory.Build.props b/Directory.Build.props
index 3748614..a3092c3 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -8,19 +8,33 @@
false
false
true
+ embedded
true
snupkg
+ true
- MaxLevs
- Copyright (c) 2021-$([System.DateTime]::UtcNow.ToString('yyyy')) $(Authors)
-
- Library for .Net which implements Minecraft Query protocol. You can use it for getting statuses of a Minecraft server.
-
+ 2.0.0
+
+
+
+
+ Maxim (MaxLevs) Liven
minecraft, query, client
icon.png
README.md
+ Copyright (c) 2021-$([System.DateTime]::UtcNow.ToString('yyyy')) $(Authors)
+ $(VersionPrefix)$(VersionSuffix)
+ https://github.com/MaxLevs/McQuery.Net
+ $(BasicPackageUrl)
+ $(BasicPackageUrl)
+ git
+
+
+
+ $(ProjectName)
+ $(ProjectName)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index bfee0ac..62c6997 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -4,5 +4,13 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/McQuery.Net.sln b/McQuery.Net.sln
new file mode 100644
index 0000000..82b85ec
--- /dev/null
+++ b/McQuery.Net.sln
@@ -0,0 +1,28 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "McQuery.Net", "sources\McQuery.Net\McQuery.Net.csproj", "{2DDC52DA-E5E5-4685-9DFB-08365C6C1771}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "McQuery.Net.Samples", "sources\McQuery.Net.Samples\McQuery.Net.Samples.csproj", "{4264FB51-B938-46ED-907A-AAD0AC9363E7}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {2DDC52DA-E5E5-4685-9DFB-08365C6C1771}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {2DDC52DA-E5E5-4685-9DFB-08365C6C1771}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {2DDC52DA-E5E5-4685-9DFB-08365C6C1771}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {2DDC52DA-E5E5-4685-9DFB-08365C6C1771}.Release|Any CPU.Build.0 = Release|Any CPU
+ {4264FB51-B938-46ED-907A-AAD0AC9363E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {4264FB51-B938-46ED-907A-AAD0AC9363E7}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {4264FB51-B938-46ED-907A-AAD0AC9363E7}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {4264FB51-B938-46ED-907A-AAD0AC9363E7}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+EndGlobal
diff --git a/McQuery.Net.sln.DotSettings b/McQuery.Net.sln.DotSettings
new file mode 100644
index 0000000..99b124e
--- /dev/null
+++ b/McQuery.Net.sln.DotSettings
@@ -0,0 +1,2 @@
+
+ True
\ No newline at end of file
diff --git a/McQuery.Net.slnx b/McQuery.Net.slnx
deleted file mode 100644
index d632f3e..0000000
--- a/McQuery.Net.slnx
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
diff --git a/README.md b/README.md
index 5bb8dc0..e43f3f9 100644
--- a/README.md
+++ b/README.md
@@ -1,29 +1,34 @@
-# McQueryLib.Net
+# McQuery.Net
Library for .Net which implements Minecraft Query protocol. You can use it for getting statuses of a Minecraft server.
# Example of using
```cs
-static async Task DoSomething(IEnumerable mcServersEndPoints)
-{
- McQueryService service = new(5, 5000, 500, 1000);
+IMcQueryClientFactory factory = new McQueryClientFactory();
+using var client = factory.Get();
- List servers = mcServersEndPoints.Select(service.RegistrateServer).ToList();
+async Task ExecuteQueries(IReadOnlyCollection endpoints, CancellationToken cancellationToken = default)
+{
+ var queryTasks = endpoints.SelectMany(
+ endpoint =>
+ [
+ GetBasicStatusAndPrint(endpoint, cancellationToken),
+ GetFullStatusAndPrint(endpoint, cancellationToken)
+ ],
+ (_, task) => task
+ ).ToArray();
- List> requests = new();
- foreach (Server server in servers)
- {
- requests.Add(service.GetBasicStatusCommon(server));
- requests.Add(service.GetFullStatusCommon(server));
- }
+ await Task.WhenAll(queryTasks);
+}
- Task.WaitAll(requests.ToArray());
+async Task GetBasicStatusAndPrint(IPEndPoint endpoint, CancellationToken cancellationToken = default)
+{
+ Console.WriteLine(await client.GetBasicStatusAsync(endpoint, cancellationToken));
+}
- foreach (Task request in requests)
- {
- IResponse response = await request;
- Console.WriteLine(response.ToString() + "\n");
- }
+async Task GetFullStatusAndPrint(IPEndPoint endpoint, CancellationToken cancellationToken = default)
+{
+ Console.WriteLine(await client.GetFullStatusAsync(endpoint, cancellationToken));
}
```
diff --git a/sources/McQuery.Net.Samples/McQuery.Net.Samples.csproj b/sources/McQuery.Net.Samples/McQuery.Net.Samples.csproj
new file mode 100644
index 0000000..c1c4c1e
--- /dev/null
+++ b/sources/McQuery.Net.Samples/McQuery.Net.Samples.csproj
@@ -0,0 +1,25 @@
+
+
+
+ Exe
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Always
+
+
+
+
diff --git a/sources/McQuery.Net.Samples/Program.cs b/sources/McQuery.Net.Samples/Program.cs
new file mode 100644
index 0000000..9348a92
--- /dev/null
+++ b/sources/McQuery.Net.Samples/Program.cs
@@ -0,0 +1,75 @@
+using System.Diagnostics;
+using System.Net;
+using McQuery.Net;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+var loggingConfiguration = new ConfigurationBuilder()
+ .SetBasePath(Directory.GetCurrentDirectory())
+ .AddJsonFile("logging.json", optional: false, reloadOnChange: true)
+ .Build();
+var serviceProvider = new ServiceCollection()
+ .AddLogging(
+ builder =>
+ {
+ builder.AddConfiguration(loggingConfiguration.GetSection("Logging"));
+ builder.SetMinimumLevel(LogLevel.Debug);
+ builder.AddConsole();
+ })
+ .AddSingleton()
+ .BuildServiceProvider();
+
+var factory = serviceProvider.GetRequiredService();
+using var client = factory.Get();
+
+int[] ports = [25565, 25566, 25567];
+Func[] commandFactories =
+[
+ ep => new BasicStatusCommand(ep),
+ ep => new FullStatusCommand(ep),
+];
+CommandBase[] commands =
+[
+ ..
+ from _ in Enumerable.Range(start: 0, count: 5000)
+ from fc in commandFactories
+ from port in ports
+ select fc(new IPEndPoint(IPAddress.Loopback, port)),
+];
+Random.Shared.Shuffle(commands);
+
+var logger = serviceProvider.GetRequiredService>();
+logger.IsEnabled(LogLevel.Trace);
+try
+{
+ logger.LogInformation("Starting McQuery.Net.Sample with {Count} requests", commands.Length);
+ var stopwatch = Stopwatch.StartNew();
+ await Task.WhenAll(commands.Select(x => x.ExecuteAsync(client)).ToArray());
+ stopwatch.Stop();
+ logger.LogInformation("Finished. It took {Elapsed}", stopwatch.Elapsed);
+}
+catch (Exception ex)
+{
+ logger.LogError(ex, "Cannot finish calculating McQuery.Net.Sample");
+ throw;
+}
+
+abstract file class CommandBase(IPEndPoint endPoint)
+{
+ protected readonly IPEndPoint EndPoint = endPoint;
+
+ public abstract Task ExecuteAsync(IMcQueryClient client, CancellationToken cancellationToken = default);
+}
+
+file class BasicStatusCommand(IPEndPoint endPoint) : CommandBase(endPoint)
+{
+ public override Task ExecuteAsync(IMcQueryClient client, CancellationToken cancellationToken = default) =>
+ client.GetBasicStatusAsync(EndPoint, cancellationToken);
+}
+
+file class FullStatusCommand(IPEndPoint endPoint) : CommandBase(endPoint)
+{
+ public override Task ExecuteAsync(IMcQueryClient client, CancellationToken cancellationToken = default) =>
+ client.GetFullStatusAsync(EndPoint, cancellationToken);
+}
diff --git a/sources/McQuery.Net.Samples/logging.json b/sources/McQuery.Net.Samples/logging.json
new file mode 100644
index 0000000..d983ea9
--- /dev/null
+++ b/sources/McQuery.Net.Samples/logging.json
@@ -0,0 +1,14 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Debug",
+ "System": "Information",
+ "Microsoft": "Information"
+ },
+ "Console": {
+ "IncludeScopes": "true",
+ "TimestampFormat": "[HH:mm:ss] ",
+ "LogToStandardErrorThreshold": "Warning"
+ }
+ }
+}
diff --git a/sources/McQuery.Net/Data/BasicStatus.cs b/sources/McQuery.Net/Data/BasicStatus.cs
new file mode 100644
index 0000000..89831ad
--- /dev/null
+++ b/sources/McQuery.Net/Data/BasicStatus.cs
@@ -0,0 +1,31 @@
+namespace McQuery.Net.Data;
+
+///
+/// Represents data which is received from BasicStatus request.
+///
+///
+/// Message of the day.
+/// Type of the game.
+/// Name of a map.
+/// Current number of players.
+/// Maximum number of players what is allowed to enter.
+/// Port to connect.
+/// Ip to connect.
+[PublicAPI]
+public record BasicStatus(
+ string Motd,
+ string GameType,
+ string Map,
+ int NumPlayers,
+ int MaxPlayers,
+ int HostPort,
+ string HostIp
+) : StatusBase(
+ Motd,
+ GameType,
+ Map,
+ NumPlayers,
+ MaxPlayers,
+ HostPort,
+ HostIp
+);
diff --git a/sources/McQuery.Net/Data/ChallengeToken.cs b/sources/McQuery.Net/Data/ChallengeToken.cs
deleted file mode 100644
index d884a33..0000000
--- a/sources/McQuery.Net/Data/ChallengeToken.cs
+++ /dev/null
@@ -1,52 +0,0 @@
-namespace McQuery.Net.Data;
-
-public class ChallengeToken
-{
- private byte[]? challengeToken;
-
- private const int alivePeriod = 30000; // Milliseconds before revoking
-
- private DateTime revokeDateTime;
-
- public bool IsFine => challengeToken != null && DateTime.Now < revokeDateTime;
-
- public ChallengeToken()
- {
- challengeToken = null;
- }
-
- public ChallengeToken(byte[] challengeToken)
- {
- UpdateToken(challengeToken);
- }
-
- public void UpdateToken(byte[] challengeToken)
- {
- this.challengeToken = (byte[])challengeToken.Clone();
- revokeDateTime = DateTime.Now.AddMilliseconds(alivePeriod);
- }
-
- public string GetString()
- {
- ArgumentNullException.ThrowIfNull(challengeToken);
-
- return BitConverter.ToString(challengeToken);
- }
-
- public byte[] GetBytes()
- {
- ArgumentNullException.ThrowIfNull(challengeToken);
-
- byte[] challengeTokenSnapshot = new byte[4];
- Buffer.BlockCopy(challengeToken, 0, challengeTokenSnapshot, 0, 4);
-
- return challengeTokenSnapshot;
- }
-
- public void WriteTo(List list)
- {
- ArgumentNullException.ThrowIfNull(challengeToken);
-
- list.AddRange(challengeToken);
- }
-}
diff --git a/sources/McQuery.Net/Data/FullStatus.cs b/sources/McQuery.Net/Data/FullStatus.cs
new file mode 100644
index 0000000..d3ad8f2
--- /dev/null
+++ b/sources/McQuery.Net/Data/FullStatus.cs
@@ -0,0 +1,39 @@
+namespace McQuery.Net.Data;
+
+///
+/// Represents data which is received from FullStatus request.
+///
+///
+/// Message of the day.
+/// Type of the game.
+/// Identifier of a game. Constant value: MINECRAFT.
+/// Game version number.
+/// List of plugins as a string.
+/// Name of a map.
+/// Current number of players.
+/// Maximum number of players what is allowed to enter.
+/// List of players' nicknames.
+/// Port to connect.
+/// Ip to connect.
+[PublicAPI]
+public record FullStatus(
+ string Motd,
+ string GameType,
+ string GameId,
+ string Version,
+ string Plugins,
+ string Map,
+ int NumPlayers,
+ int MaxPlayers,
+ string[] PlayerList,
+ int HostPort,
+ string HostIp
+) : StatusBase(
+ Motd,
+ GameType,
+ Map,
+ NumPlayers,
+ MaxPlayers,
+ HostPort,
+ HostIp
+);
diff --git a/sources/McQuery.Net/Data/Packages/Request.cs b/sources/McQuery.Net/Data/Packages/Request.cs
deleted file mode 100644
index 6f7ae99..0000000
--- a/sources/McQuery.Net/Data/Packages/Request.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-namespace McQuery.Net.Data.Packages;
-
-public class Request
-{
- public byte[] RawRequestData { get; private set; }
-
- public byte RequestType => RawRequestData[2];
-
- public Request(byte[] rawRequestData)
- {
- RawRequestData = rawRequestData;
- }
-}
diff --git a/sources/McQuery.Net/Data/Packages/Responses/IResponse.cs b/sources/McQuery.Net/Data/Packages/Responses/IResponse.cs
deleted file mode 100644
index fd42871..0000000
--- a/sources/McQuery.Net/Data/Packages/Responses/IResponse.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace McQuery.Net.Data.Packages.Responses;
-
-public interface IResponse
-{
- public Guid ServerUUID { get; }
-
- public byte[] RawData { get; }
-}
diff --git a/sources/McQuery.Net/Data/Packages/Responses/RawResponse.cs b/sources/McQuery.Net/Data/Packages/Responses/RawResponse.cs
deleted file mode 100644
index e2d85e2..0000000
--- a/sources/McQuery.Net/Data/Packages/Responses/RawResponse.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-namespace McQuery.Net.Data.Packages.Responses;
-
-public class RawResponse : IResponse
-{
- public RawResponse(Guid serverUUID, byte[] rawData)
- {
- ServerUUID = serverUUID;
- RawData = rawData;
- }
-
- public Guid ServerUUID { get; }
-
- public byte[] RawData { get; }
-}
diff --git a/sources/McQuery.Net/Data/Packages/Responses/ServerBasicStateResponse.cs b/sources/McQuery.Net/Data/Packages/Responses/ServerBasicStateResponse.cs
deleted file mode 100644
index bc9ce62..0000000
--- a/sources/McQuery.Net/Data/Packages/Responses/ServerBasicStateResponse.cs
+++ /dev/null
@@ -1,62 +0,0 @@
-namespace McQuery.Net.Data.Packages.Responses;
-
-///
-/// Represents data which is received from BasicState request
-///
-public class ServerBasicStateResponse : IResponse
-{
- public ServerBasicStateResponse(
- Guid serverUUID,
- SessionId sessionId,
- string motd,
- string gameType,
- string map,
- int numPlayers,
- int maxPlayers,
- short hostPort,
- string hostIp,
- byte[] rawData)
- {
- ServerUUID = serverUUID;
- SessionId = sessionId;
- Motd = motd;
- GameType = gameType;
- Map = map;
- NumPlayers = numPlayers;
- MaxPlayers = maxPlayers;
- HostPort = hostPort;
- HostIp = hostIp;
- RawData = rawData;
- }
-
- public SessionId SessionId { get; }
-
- public string Motd { get; }
-
- public string GameType { get; }
-
- public string Map { get; }
-
- public int NumPlayers { get; }
-
- public int MaxPlayers { get; }
-
- public short HostPort { get; }
-
- public string HostIp { get; }
-
- public Guid ServerUUID { get; }
-
- public byte[] RawData { get; }
-
- public override string ToString() => "BasicStatus\n" +
- $"| {nameof(ServerUUID)}: {ServerUUID}\n" +
- $"| {nameof(SessionId)}: {SessionId.GetString()}\n" +
- $"| {nameof(Motd)}: {Motd}\n" +
- $"| {nameof(GameType)}: {GameType}\n" +
- $"| {nameof(Map)}: {Map}\n" +
- $"| {nameof(NumPlayers)}: {NumPlayers}\n" +
- $"| {nameof(MaxPlayers)}: {MaxPlayers}\n" +
- $"| {nameof(HostPort)}: {HostPort}\n" +
- $"| {nameof(HostIp)}: {HostIp}";
-}
diff --git a/sources/McQuery.Net/Data/Packages/Responses/ServerFullStateResponse.cs b/sources/McQuery.Net/Data/Packages/Responses/ServerFullStateResponse.cs
deleted file mode 100644
index 46fdfe6..0000000
--- a/sources/McQuery.Net/Data/Packages/Responses/ServerFullStateResponse.cs
+++ /dev/null
@@ -1,82 +0,0 @@
-namespace McQuery.Net.Data.Packages.Responses;
-
-///
-/// Represents data which is received from FullState request
-///
-public class ServerFullStateResponse : IResponse
-{
- public ServerFullStateResponse(
- Guid serverUUID,
- SessionId sessionId,
- string motd,
- string gameType,
- string gameId,
- string version,
- string plugins,
- string map,
- int numPlayers,
- int maxPlayers,
- string[] playerList,
- int hostPort,
- string hostIp,
- byte[] rawData)
- {
- ServerUUID = serverUUID;
- SessionId = sessionId;
- Motd = motd;
- GameType = gameType;
- GameId = gameId;
- Version = version;
- Plugins = plugins;
- Map = map;
- NumPlayers = numPlayers;
- MaxPlayers = maxPlayers;
- PlayerList = playerList;
- HostPort = hostPort;
- HostIp = hostIp;
- RawData = rawData;
- }
-
- public SessionId SessionId { get; }
-
- public string Motd { get; }
-
- public string GameType { get; }
-
- public string GameId { get; }
-
- public string Version { get; }
-
- public string Plugins { get; }
-
- public string Map { get; }
-
- public int NumPlayers { get; }
-
- public int MaxPlayers { get; }
-
- public string[] PlayerList { get; }
-
- public int HostPort { get; }
-
- public string HostIp { get; }
-
- public Guid ServerUUID { get; }
-
- public byte[] RawData { get; }
-
- public override string ToString() => "FullStatus\n" +
- $"| {nameof(ServerUUID)}: {ServerUUID}\n" +
- $"| {nameof(SessionId)}: {SessionId.GetString()}\n" +
- $"| {nameof(Motd)}: {Motd}\n" +
- $"| {nameof(GameType)}: {GameType}\n" +
- $"| {nameof(GameId)}: {GameId}\n" +
- $"| {nameof(Version)}: {Version}\n" +
- $"| {nameof(Plugins)}: {Plugins}\n" +
- $"| {nameof(Map)}: {Map}\n" +
- $"| {nameof(NumPlayers)}: {NumPlayers}\n" +
- $"| {nameof(MaxPlayers)}: {MaxPlayers}\n" +
- $"| {nameof(PlayerList)}: [{string.Join(", ", PlayerList)}]\n" +
- $"| {nameof(HostPort)}: {HostPort}\n" +
- $"| {nameof(HostIp)}: {HostIp}";
-}
diff --git a/sources/McQuery.Net/Data/Packages/Responses/TimeoutResponse.cs b/sources/McQuery.Net/Data/Packages/Responses/TimeoutResponse.cs
deleted file mode 100644
index 6ba02ac..0000000
--- a/sources/McQuery.Net/Data/Packages/Responses/TimeoutResponse.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-namespace McQuery.Net.Data.Packages.Responses;
-
-public class TimeoutResponse : IResponse
-{
- public TimeoutResponse(Guid serverUUID)
- {
- ServerUUID = serverUUID;
- }
-
- public byte[] RawData => throw new NotSupportedException();
-
- public Guid ServerUUID { get; }
-
- public string Message => "Request is timed out";
-}
diff --git a/sources/McQuery.Net/Data/Packages/Responses/WrongResponse.cs b/sources/McQuery.Net/Data/Packages/Responses/WrongResponse.cs
deleted file mode 100644
index 77e292d..0000000
--- a/sources/McQuery.Net/Data/Packages/Responses/WrongResponse.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-namespace McQuery.Net.Data.Packages.Responses;
-
-public class WrongResponse : IResponse
-{
- public WrongResponse(Guid serverUUID, byte[] rawData)
- {
- ServerUUID = serverUUID;
- RawData = rawData;
- }
-
- public byte[] RawData { get; }
-
- public Guid ServerUUID { get; }
-
- public string Message => "This response package can't be parsed";
-}
diff --git a/sources/McQuery.Net/Data/Server.cs b/sources/McQuery.Net/Data/Server.cs
deleted file mode 100644
index 8fc6630..0000000
--- a/sources/McQuery.Net/Data/Server.cs
+++ /dev/null
@@ -1,74 +0,0 @@
-using System.Net;
-using System.Net.Sockets;
-
-namespace McQuery.Net.Data;
-
-// todo: add cancellation token support
-public class Server : IDisposable
-{
- public Server(SessionId sessionId, IPAddress host, int port)
- {
- UUID = Guid.NewGuid();
- SessionId = sessionId;
- Host = host;
- Port = port;
- ChallengeToken = new ChallengeToken();
- UdpClient = new UdpClient(Host.ToString(), Port);
- UdpClientSemaphoreSlim = new SemaphoreSlim(0, 1);
- UdpClientSemaphoreSlim.Release();
- }
-
- public Guid UUID { get; }
-
- public SessionId SessionId { get; }
-
- public IPAddress Host { get; }
-
- public int Port { get; }
-
- public ChallengeToken ChallengeToken { get; }
-
- public UdpClient UdpClient { get; private set; }
-
- public SemaphoreSlim UdpClientSemaphoreSlim { get; }
-
- public async void InvalidateSocket()
- {
- await UdpClientSemaphoreSlim.WaitAsync();
- UdpClient.Dispose();
- UdpClient = new UdpClient(Host.ToString(), Port);
- UdpClientSemaphoreSlim.Release();
- }
-
- private bool disposed = false;
-
- public void Dispose()
- {
- Dispose(true);
- GC.SuppressFinalize(this);
- }
-
- public void Dispose(bool disposing)
- {
- if (!disposed)
- {
- if (disposing)
- {
- UdpClient.Dispose();
- UdpClientSemaphoreSlim.Dispose();
- }
-
- disposed = true;
- }
- }
-
- ~Server()
- {
- Dispose(true);
- }
-
- public override bool Equals(object? obj) => obj is Server server &&
- EqualityComparer.Default.Equals(UUID, server.UUID);
-
- public override int GetHashCode() => UUID.GetHashCode();
-}
diff --git a/sources/McQuery.Net/Data/SessionId.cs b/sources/McQuery.Net/Data/SessionId.cs
deleted file mode 100644
index 7047b77..0000000
--- a/sources/McQuery.Net/Data/SessionId.cs
+++ /dev/null
@@ -1,35 +0,0 @@
-namespace McQuery.Net.Data;
-
-///
-/// This class represents SessionId filed into packages.
-/// It provides api for create random SessionId or parse it from byte[]
-///
-public class SessionId
-{
- private readonly byte[] sessionId;
-
- public SessionId(byte[] sessionId)
- {
- this.sessionId = sessionId;
- }
-
- public string GetString() => BitConverter.ToString(sessionId);
-
- public byte[] GetBytes()
- {
- byte[] sessionIdSnapshot = new byte[4];
- Buffer.BlockCopy(sessionId, 0, sessionIdSnapshot, 0, 4);
-
- return sessionIdSnapshot;
- }
-
- public void WriteTo(List list)
- {
- list.AddRange(sessionId);
- }
-
- public override bool Equals(object? obj) =>
- obj is SessionId anotherSessionId && sessionId.SequenceEqual(anotherSessionId.sessionId);
-
- public override int GetHashCode() => BitConverter.ToInt32(sessionId, 0);
-}
diff --git a/sources/McQuery.Net/Data/StatusBase.cs b/sources/McQuery.Net/Data/StatusBase.cs
new file mode 100644
index 0000000..f8a0b42
--- /dev/null
+++ b/sources/McQuery.Net/Data/StatusBase.cs
@@ -0,0 +1,27 @@
+using McQuery.Net.Internal.Data;
+
+namespace McQuery.Net.Data;
+
+///
+/// Represents a basic status response.
+///
+///
+/// Message of the day.
+/// Type of the game.
+/// Name of a map.
+/// Current number of players.
+/// Maximum number of players what is allowed to enter.
+/// Port to connect.
+/// Ip to connect.
+[PublicAPI]
+public abstract record StatusBase(
+ string Motd,
+ string GameType,
+ string Map,
+ int NumPlayers,
+ int MaxPlayers,
+ int HostPort,
+ string HostIp)
+{
+ internal SessionId SessionId { get; init; } = null!;
+}
diff --git a/sources/McQuery.Net/Exceptions/ExpiredException.cs b/sources/McQuery.Net/Exceptions/ExpiredException.cs
new file mode 100644
index 0000000..9a8fc1f
--- /dev/null
+++ b/sources/McQuery.Net/Exceptions/ExpiredException.cs
@@ -0,0 +1,25 @@
+using McQuery.Net.Internal.Abstract;
+
+namespace McQuery.Net.Exceptions;
+
+///
+/// Something was expired.
+///
+[PublicAPI]
+public class ExpiredException : ArgumentException
+{
+ internal ExpiredException(IExpirable expirable)
+ : base($"{expirable.GetType().Name} is already expired")
+ {
+ }
+
+ ///
+ /// Helper method to throw new exception form .
+ ///
+ /// Something that can be expired.
+ /// Something was exprired.
+ internal static void ThrowIfExpired(IExpirable expirable)
+ {
+ if (expirable.IsExpired) throw new ExpiredException(expirable);
+ }
+}
diff --git a/sources/McQuery.Net/IMcQueryClient.cs b/sources/McQuery.Net/IMcQueryClient.cs
new file mode 100644
index 0000000..62d3f53
--- /dev/null
+++ b/sources/McQuery.Net/IMcQueryClient.cs
@@ -0,0 +1,30 @@
+using System.Net;
+using McQuery.Net.Data;
+
+namespace McQuery.Net;
+
+///
+/// Client to request minecraft server status by Minecraft Query Protocol.
+///
+[PublicAPI]
+public interface IMcQueryClient : IDisposable
+{
+ ///
+ /// Get .
+ ///
+ /// to access Minecraft server by UDP.
+ /// .
+ /// .
+ Task GetBasicStatusAsync(IPEndPoint serverEndpoint, CancellationToken cancellationToken = default);
+
+ ///
+ /// Get .
+ ///
+ ///
+ /// Minecraft server caches prepared full status response for some time.
+ ///
+ /// to access Minecraft server by UDP.
+ /// .
+ /// .
+ Task GetFullStatusAsync(IPEndPoint serverEndpoint, CancellationToken cancellationToken = default);
+}
diff --git a/sources/McQuery.Net/IMcQueryClientFactory.cs b/sources/McQuery.Net/IMcQueryClientFactory.cs
new file mode 100644
index 0000000..837d020
--- /dev/null
+++ b/sources/McQuery.Net/IMcQueryClientFactory.cs
@@ -0,0 +1,14 @@
+namespace McQuery.Net;
+
+///
+/// Factory to create instances of .
+///
+[PublicAPI]
+public interface IMcQueryClientFactory
+{
+ ///
+ /// Create instance of .
+ ///
+ /// Instance of .
+ IMcQueryClient Get();
+}
diff --git a/sources/McQuery.Net/Internal/Abstract/IAuthOnlyClient.cs b/sources/McQuery.Net/Internal/Abstract/IAuthOnlyClient.cs
new file mode 100644
index 0000000..a3eed6a
--- /dev/null
+++ b/sources/McQuery.Net/Internal/Abstract/IAuthOnlyClient.cs
@@ -0,0 +1,23 @@
+using System.Net;
+using McQuery.Net.Internal.Data;
+
+namespace McQuery.Net.Internal.Abstract;
+
+///
+/// Client that provides interface to acquire .
+///
+internal interface IAuthOnlyClient : IDisposable
+{
+ ///
+ /// Request from Minecraft server.
+ ///
+ ///
+ /// .
+ /// .
+ /// .
+ internal Task HandshakeAsync(
+ IPEndPoint serverEndpoint,
+ SessionId sessionId,
+ CancellationToken cancellationToken = default
+ );
+}
diff --git a/sources/McQuery.Net/Internal/Abstract/IExpoirable.cs b/sources/McQuery.Net/Internal/Abstract/IExpoirable.cs
new file mode 100644
index 0000000..68e3e2f
--- /dev/null
+++ b/sources/McQuery.Net/Internal/Abstract/IExpoirable.cs
@@ -0,0 +1,6 @@
+namespace McQuery.Net.Internal.Abstract;
+
+internal interface IExpirable
+{
+ public bool IsExpired { get; }
+}
diff --git a/sources/McQuery.Net/Internal/Data/ChallengeToken.cs b/sources/McQuery.Net/Internal/Data/ChallengeToken.cs
new file mode 100644
index 0000000..caa45cd
--- /dev/null
+++ b/sources/McQuery.Net/Internal/Data/ChallengeToken.cs
@@ -0,0 +1,42 @@
+using McQuery.Net.Exceptions;
+using McQuery.Net.Internal.Abstract;
+
+namespace McQuery.Net.Internal.Data;
+
+///
+/// Secret value provided by Minecraft server to issue status requests.
+///
+internal record ChallengeToken : IExpirable
+{
+ private const int AlivePeriod = 29;
+ private readonly DateTime _expiresAt = DateTime.UtcNow.AddSeconds(AlivePeriod);
+
+ ///
+ /// .ctor.
+ ///
+ /// Bytes that represents challenge token.
+ ///
+ /// Number of bytes is incorrect.
+ ///
+ public ChallengeToken(byte[] data)
+ {
+ if (data.Length != 4)
+ {
+ throw new ArgumentOutOfRangeException(nameof(data), data, "Challenge token must have 4 bytes");
+ }
+
+ Data = data;
+ }
+
+ private byte[] Data { get; }
+ public bool IsExpired => DateTime.UtcNow >= _expiresAt;
+
+ public static implicit operator byte[](ChallengeToken token)
+ {
+ ExpiredException.ThrowIfExpired(token);
+ return [..token.Data];
+ }
+
+ public static implicit operator ReadOnlySpan(ChallengeToken token)
+ => (byte[])token;
+}
diff --git a/sources/McQuery.Net/Internal/Data/Session.cs b/sources/McQuery.Net/Internal/Data/Session.cs
new file mode 100644
index 0000000..2933bf0
--- /dev/null
+++ b/sources/McQuery.Net/Internal/Data/Session.cs
@@ -0,0 +1,14 @@
+namespace McQuery.Net.Internal.Data;
+
+///
+/// Represents a combination of and values.
+///
+///
+/// Replica of something similar that Minecraft server use.
+///
+/// .
+/// .
+internal record Session(SessionId SessionId, ChallengeToken Token)
+{
+ public bool IsExpired => Token.IsExpired;
+}
diff --git a/sources/McQuery.Net/Internal/Data/SessionId.cs b/sources/McQuery.Net/Internal/Data/SessionId.cs
new file mode 100644
index 0000000..f6e46aa
--- /dev/null
+++ b/sources/McQuery.Net/Internal/Data/SessionId.cs
@@ -0,0 +1,47 @@
+namespace McQuery.Net.Internal.Data;
+
+///
+/// Represents Session Identifier.
+///
+///
+/// Minecraft server does not validate this value but store along with as long as handshake session
+/// for current issuer is alive.
+/// Can be rewritten by new value if current client send another one handshake request.
+/// Server sends stored in every response (even if status request contains different
+/// compared to handshake request, response contains actual from the last handshake request).
+///
+internal class SessionId
+{
+ private byte[] Data { get; }
+
+ ///
+ /// .ctor.
+ ///
+ /// Bytes that represents session identifier.
+ ///
+ /// Number of bytes is incorrect.
+ ///
+ public SessionId(byte[] data)
+ {
+ if (data.Length != 4)
+ {
+ throw new ArgumentOutOfRangeException(nameof(data), data, "Session identifier must have 4 bytes");
+ }
+
+ Data = data;
+ }
+
+ public static implicit operator string(SessionId sessionId) => BitConverter.ToString(sessionId.Data);
+
+ public static implicit operator byte[](SessionId sessionId) => [..sessionId.Data];
+
+ public static implicit operator ReadOnlySpan(SessionId sessionId) => (byte[])sessionId;
+
+ public override bool Equals(object? obj)
+ {
+ return obj is SessionId anotherSessionId
+ && Data.SequenceEqual(anotherSessionId.Data);
+ }
+
+ public override int GetHashCode() => BitConverter.ToInt32(Data, startIndex: 0);
+}
diff --git a/sources/McQuery.Net/Internal/Factories/IRequestFactory.cs b/sources/McQuery.Net/Internal/Factories/IRequestFactory.cs
new file mode 100644
index 0000000..087f5f3
--- /dev/null
+++ b/sources/McQuery.Net/Internal/Factories/IRequestFactory.cs
@@ -0,0 +1,30 @@
+using McQuery.Net.Internal.Data;
+
+namespace McQuery.Net.Internal.Factories;
+
+///
+/// Provides methods to build requests.
+///
+internal interface IRequestFactory
+{
+ ///
+ /// Builds handshake request.
+ ///
+ /// .
+ /// Binary representation of the request.
+ internal byte[] GetHandshakeRequest(SessionId sessionId);
+
+ ///
+ /// Builds basic status request.
+ ///
+ /// .
+ /// Binary representation of the request.
+ internal byte[] GetBasicStatusRequest(Session session);
+
+ ///
+ /// Builds full status request.
+ ///
+ /// .
+ /// Binary representation of the request.
+ internal byte[] GetFullStatusRequest(Session session);
+}
diff --git a/sources/McQuery.Net/Internal/Factories/RequestFactory.cs b/sources/McQuery.Net/Internal/Factories/RequestFactory.cs
new file mode 100644
index 0000000..828756e
--- /dev/null
+++ b/sources/McQuery.Net/Internal/Factories/RequestFactory.cs
@@ -0,0 +1,51 @@
+using McQuery.Net.Internal.Data;
+
+namespace McQuery.Net.Internal.Factories;
+
+///
+/// Implementation of .
+///
+internal class RequestFactory : IRequestFactory
+{
+ private const byte HandshakeRequestTypeConst = 0x09;
+ private const byte StatusRequestTypeConst = 0x00;
+ private static readonly byte[] magicConst = [0xfe, 0xfd];
+
+ ///
+ public byte[] GetHandshakeRequest(SessionId sessionId)
+ {
+ using MemoryStream packetStream = new();
+ FormRequestHeader(packetStream, HandshakeRequestTypeConst, sessionId);
+ return packetStream.ToArray();
+ }
+
+ ///
+ public byte[] GetBasicStatusRequest(Session session)
+ {
+ using MemoryStream packetStream = new();
+ FormBasicStatusRequest(packetStream, session);
+ return packetStream.ToArray();
+ }
+
+ ///
+ public byte[] GetFullStatusRequest(Session session)
+ {
+ using MemoryStream packetStream = new();
+ FormBasicStatusRequest(packetStream, session);
+ packetStream.Write([0x00, 0x00, 0x00, 0x00]);
+ return packetStream.ToArray();
+ }
+
+ private static void FormRequestHeader(Stream packetStream, byte packageType, SessionId sessionId)
+ {
+ packetStream.Write(magicConst);
+ packetStream.Write([packageType]);
+ packetStream.Write(sessionId);
+ }
+
+ private static void FormBasicStatusRequest(Stream packetStream, Session session)
+ {
+ FormRequestHeader(packetStream, StatusRequestTypeConst, session.SessionId);
+ packetStream.Write(session.Token);
+ }
+}
diff --git a/sources/McQuery.Net/Internal/Helpers/CancellationTokenTimeoutEnricher.cs b/sources/McQuery.Net/Internal/Helpers/CancellationTokenTimeoutEnricher.cs
new file mode 100644
index 0000000..6c58c84
--- /dev/null
+++ b/sources/McQuery.Net/Internal/Helpers/CancellationTokenTimeoutEnricher.cs
@@ -0,0 +1,44 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace McQuery.Net.Internal.Helpers;
+
+internal static class CancellationTokenTimeoutEnrichHelper
+{
+ public static CancellationTokenSourceWithTimeout ToSourceWithTimeout(this CancellationToken token, TimeSpan timeout)
+ => CancellationTokenSourceWithTimeout.Create(token, timeout);
+
+ public record struct CancellationTokenSourceWithTimeout : IDisposable
+ {
+ private readonly CancellationTokenSource _originSource;
+ private readonly CancellationTokenSource _linkedSource;
+
+ public CancellationToken Token => _linkedSource.Token;
+
+ private CancellationTokenSourceWithTimeout(CancellationTokenSource originSource, CancellationTokenSource linkedSource)
+ {
+ _originSource = originSource;
+ _linkedSource = linkedSource;
+ }
+
+ [SuppressMessage("Design", "CA1068:CancellationToken parameters must come last")]
+ public static CancellationTokenSourceWithTimeout Create(CancellationToken cancellationToken, TimeSpan timeout)
+ {
+ CancellationTokenSource timeoutSource = new(timeout);
+ return new CancellationTokenSourceWithTimeout(
+ timeoutSource,
+ CancellationTokenSource.CreateLinkedTokenSource(
+ cancellationToken,
+ timeoutSource.Token));
+ }
+
+ private bool _isDisposed = false;
+
+ public void Dispose()
+ {
+ if (_isDisposed) return;
+ _originSource.Dispose();
+ _linkedSource.Dispose();
+ _isDisposed = true;
+ }
+ }
+}
diff --git a/sources/McQuery.Net/Internal/Parsers/BasicStatusResponseParser.cs b/sources/McQuery.Net/Internal/Parsers/BasicStatusResponseParser.cs
new file mode 100644
index 0000000..4543d93
--- /dev/null
+++ b/sources/McQuery.Net/Internal/Parsers/BasicStatusResponseParser.cs
@@ -0,0 +1,24 @@
+using McQuery.Net.Data;
+
+namespace McQuery.Net.Internal.Parsers;
+
+internal class BasicStatusResponseParser : StatusResponseParser
+{
+ public override BasicStatus Parse(byte[] data)
+ {
+ var sessionId = StartParsing(data, out var reader);
+
+ return new BasicStatus(
+ ParseNullTerminatingString(ref reader),
+ ParseNullTerminatingString(ref reader),
+ ParseNullTerminatingString(ref reader),
+ int.Parse(ParseNullTerminatingString(ref reader)),
+ int.Parse(ParseNullTerminatingString(ref reader)),
+ ParseShortLittleEndian(ref reader),
+ ParseNullTerminatingString(ref reader)
+ )
+ {
+ SessionId = sessionId,
+ };
+ }
+}
diff --git a/sources/McQuery.Net/Internal/Parsers/FullStatusResponseParser.cs b/sources/McQuery.Net/Internal/Parsers/FullStatusResponseParser.cs
new file mode 100644
index 0000000..f507467
--- /dev/null
+++ b/sources/McQuery.Net/Internal/Parsers/FullStatusResponseParser.cs
@@ -0,0 +1,49 @@
+using McQuery.Net.Data;
+
+namespace McQuery.Net.Internal.Parsers;
+
+internal class FullStatusResponseParser : StatusResponseParser
+{
+ private static readonly byte[] constant1 = [0x73, 0x70, 0x6C, 0x69, 0x74, 0x6E, 0x75, 0x6D, 0x00, 0x80, 0x00];
+ private static readonly byte[] constant2 = [0x01, 0x70, 0x6C, 0x61, 0x79, 0x65, 0x72, 0x5F, 0x00, 0x00];
+ private static readonly InvalidOperationException responseFormatError = new("Invalid full status response format");
+
+ public override FullStatus Parse(byte[] data)
+ {
+ var sessionId = StartParsing(data, out var reader);
+
+ if (!reader.IsNext(constant1, advancePast: true)) throw responseFormatError;
+
+ Dictionary statusKeyValues = new();
+ while (!reader.IsNext(next: 0, advancePast: true))
+ {
+ var key = ParseNullTerminatingString(ref reader);
+ var value = ParseNullTerminatingString(ref reader);
+ statusKeyValues.Add(key, value);
+ }
+
+ if (!reader.IsNext(constant2, advancePast: true)) throw responseFormatError;
+
+ List players = [];
+ while (!reader.IsNext(next: 0, advancePast: true))
+ {
+ players.Add(ParseNullTerminatingString(ref reader));
+ }
+
+ return new FullStatus(
+ statusKeyValues["hostname"],
+ statusKeyValues["gametype"],
+ statusKeyValues["game_id"],
+ statusKeyValues["version"],
+ statusKeyValues["plugins"],
+ statusKeyValues["map"],
+ int.Parse(statusKeyValues["numplayers"]),
+ int.Parse(statusKeyValues["maxplayers"]),
+ players.ToArray(),
+ int.Parse(statusKeyValues["hostport"]),
+ statusKeyValues["hostip"])
+ {
+ SessionId = sessionId,
+ };
+ }
+}
diff --git a/sources/McQuery.Net/Internal/Parsers/HandshakeResponseParser.cs b/sources/McQuery.Net/Internal/Parsers/HandshakeResponseParser.cs
new file mode 100644
index 0000000..dab59fb
--- /dev/null
+++ b/sources/McQuery.Net/Internal/Parsers/HandshakeResponseParser.cs
@@ -0,0 +1,20 @@
+using McQuery.Net.Internal.Data;
+
+namespace McQuery.Net.Internal.Parsers;
+
+internal class HandshakeResponseParser : ResponseParserBase, IResponseParser
+{
+ protected override byte ResponseType => 0x09;
+
+ public ChallengeToken Parse(byte[] data)
+ {
+ StartParsing(data, out var reader);
+
+ var challengeTokenString = ParseNullTerminatingString(ref reader);
+ var challengeTokenBytes = BitConverter.GetBytes(int.Parse(challengeTokenString));
+
+ if (BitConverter.IsLittleEndian) Array.Reverse(challengeTokenBytes);
+
+ return new ChallengeToken(challengeTokenBytes);
+ }
+}
diff --git a/sources/McQuery.Net/Internal/Parsers/IResponseParser.cs b/sources/McQuery.Net/Internal/Parsers/IResponseParser.cs
new file mode 100644
index 0000000..a702317
--- /dev/null
+++ b/sources/McQuery.Net/Internal/Parsers/IResponseParser.cs
@@ -0,0 +1,6 @@
+namespace McQuery.Net.Internal.Parsers;
+
+internal interface IResponseParser
+{
+ T Parse(byte[] data);
+}
diff --git a/sources/McQuery.Net/Internal/Parsers/ResponseParserBase.cs b/sources/McQuery.Net/Internal/Parsers/ResponseParserBase.cs
new file mode 100644
index 0000000..ab3bbbc
--- /dev/null
+++ b/sources/McQuery.Net/Internal/Parsers/ResponseParserBase.cs
@@ -0,0 +1,52 @@
+using System.Buffers;
+using System.Text;
+using McQuery.Net.Internal.Data;
+
+namespace McQuery.Net.Internal.Parsers;
+
+internal abstract class ResponseParserBase
+{
+ protected abstract byte ResponseType { get; }
+
+ internal SessionId StartParsing(byte[] data, out SequenceReader reader)
+ {
+ ReadOnlySequence sequence = new(data);
+ reader = new SequenceReader(sequence);
+
+ if (!reader.IsNext([ResponseType], advancePast: true)) throw new InvalidOperationException("Invalid response type");
+
+ return ParseSessionId(ref reader);
+ }
+
+ private static SessionId ParseSessionId(ref SequenceReader reader)
+ {
+ if (reader.UnreadSequence.Length < 4)
+ {
+ throw new InvalidOperationException("Session id must contain exactly 4 bytes.");
+ }
+
+ reader.TryReadExact(count: 4, out var sessionIdBytes);
+
+ return new SessionId(sessionIdBytes.ToArray());
+ }
+
+ internal static string ParseNullTerminatingString(ref SequenceReader reader)
+ {
+ if (!reader.TryReadTo(out ReadOnlySequence bytes, delimiter: 0, advancePastDelimiter: true))
+ {
+ throw new InvalidOperationException("Cannot parse null terminating string: terminator was not found.");
+ }
+
+ return Encoding.ASCII.GetString(bytes);
+ }
+
+ internal static short ParseShortLittleEndian(ref SequenceReader reader)
+ {
+ if (!reader.TryReadLittleEndian(out short port))
+ {
+ throw new InvalidOperationException("Cannot parse short value");
+ }
+
+ return port;
+ }
+}
diff --git a/sources/McQuery.Net/Internal/Parsers/StatusResponseParser.cs b/sources/McQuery.Net/Internal/Parsers/StatusResponseParser.cs
new file mode 100644
index 0000000..2457aea
--- /dev/null
+++ b/sources/McQuery.Net/Internal/Parsers/StatusResponseParser.cs
@@ -0,0 +1,11 @@
+using McQuery.Net.Data;
+
+namespace McQuery.Net.Internal.Parsers;
+
+internal abstract class StatusResponseParser : ResponseParserBase, IResponseParser
+ where T : StatusBase
+{
+ protected override byte ResponseType => 0x00;
+
+ public abstract T Parse(byte[] data);
+}
diff --git a/sources/McQuery.Net/Internal/Providers/ISessionIdProvider.cs b/sources/McQuery.Net/Internal/Providers/ISessionIdProvider.cs
new file mode 100644
index 0000000..6dbb297
--- /dev/null
+++ b/sources/McQuery.Net/Internal/Providers/ISessionIdProvider.cs
@@ -0,0 +1,15 @@
+using McQuery.Net.Internal.Data;
+
+namespace McQuery.Net.Internal.Providers;
+
+///
+/// Provides every time it's needed.
+///
+internal interface ISessionIdProvider
+{
+ ///
+ /// Gets .
+ ///
+ /// .
+ SessionId Get();
+}
diff --git a/sources/McQuery.Net/Internal/Providers/ISessionStorage.cs b/sources/McQuery.Net/Internal/Providers/ISessionStorage.cs
new file mode 100644
index 0000000..c9f9b99
--- /dev/null
+++ b/sources/McQuery.Net/Internal/Providers/ISessionStorage.cs
@@ -0,0 +1,18 @@
+using System.Net;
+using McQuery.Net.Internal.Data;
+
+namespace McQuery.Net.Internal.Providers;
+
+///
+/// Creates and stores objects.
+///
+internal interface ISessionStorage : IDisposable
+{
+ ///
+ /// Gets stored session or acquire new.
+ ///
+ /// to access Minecraft server by UDP.
+ /// .
+ /// .
+ Task GetAsync(IPEndPoint serverEndpoint, CancellationToken cancellationToken = default);
+}
diff --git a/sources/McQuery.Net/Internal/Providers/SessionIdProvider.cs b/sources/McQuery.Net/Internal/Providers/SessionIdProvider.cs
new file mode 100644
index 0000000..b5587db
--- /dev/null
+++ b/sources/McQuery.Net/Internal/Providers/SessionIdProvider.cs
@@ -0,0 +1,22 @@
+using McQuery.Net.Internal.Data;
+
+namespace McQuery.Net.Internal.Providers;
+
+///
+/// Implementation of .
+///
+internal class SessionIdProvider : ISessionIdProvider
+{
+ private static uint counter;
+
+ ///
+ public SessionId Get()
+ {
+ var currentValue = Interlocked.Increment(ref counter);
+
+ var bytes = BitConverter.GetBytes(currentValue);
+ if (BitConverter.IsLittleEndian) Array.Reverse(bytes);
+
+ return new SessionId(bytes);
+ }
+}
diff --git a/sources/McQuery.Net/Internal/Providers/SessionStorage.cs b/sources/McQuery.Net/Internal/Providers/SessionStorage.cs
new file mode 100644
index 0000000..e7dbe4e
--- /dev/null
+++ b/sources/McQuery.Net/Internal/Providers/SessionStorage.cs
@@ -0,0 +1,83 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Net;
+using McQuery.Net.Internal.Abstract;
+using McQuery.Net.Internal.Data;
+using Microsoft.VisualStudio.Threading;
+
+namespace McQuery.Net.Internal.Providers;
+
+///
+/// Implementation of .
+///
+/// .
+internal class SessionStorage(ISessionIdProvider sessionIdProvider) : ISessionStorage
+{
+ [SuppressMessage("Usage", "VSTHRD012:Provide JoinableTaskFactory where allowed")]
+ private readonly AsyncReaderWriterLock _locker = new();
+
+ private IAuthOnlyClient? _authClient;
+ private readonly Dictionary _sessionsByEndpoints = new();
+
+ ///
+ public async Task GetAsync(IPEndPoint serverEndpoint, CancellationToken cancellationToken = default)
+ {
+ await using var releaser = await _locker.UpgradeableReadLockAsync(cancellationToken);
+
+ var sessionExists = _sessionsByEndpoints.TryGetValue(serverEndpoint, out var session);
+
+ if (sessionExists && !session!.IsExpired)
+ {
+ return session;
+ }
+
+ if (sessionExists && session!.IsExpired)
+ {
+ if (!_sessionsByEndpoints.Remove(serverEndpoint, out session))
+ {
+ throw new Exception($"Cannot remove expired session {session} for some reason.");
+ }
+ }
+
+ return await AcquireSessionAsync(serverEndpoint, cancellationToken);
+ }
+
+ internal void Init(IAuthOnlyClient client)
+ {
+ if (_authClient != null) throw new InvalidOperationException("SessionStorage already initialized.");
+
+ _authClient = client;
+ }
+
+ private async Task AcquireSessionAsync(IPEndPoint serverEndpoint, CancellationToken cancellationToken)
+ {
+ await using var releaser = await _locker.WriteLockAsync(cancellationToken);
+
+ var sessionExists = _sessionsByEndpoints.TryGetValue(serverEndpoint, out var currentSession);
+ if (sessionExists && !currentSession!.IsExpired)
+ {
+ return currentSession;
+ }
+
+ if (_authClient == null)
+ {
+ throw new InvalidOperationException("Storage must be initialized before calling this method.");
+ }
+
+ var sessionId = sessionIdProvider.Get();
+ var challengeToken = await _authClient!.HandshakeAsync(serverEndpoint, sessionId, cancellationToken);
+ Session session = new(sessionId, challengeToken);
+
+ return _sessionsByEndpoints[serverEndpoint] = session;
+ }
+
+ private bool _isDisposed;
+
+ public void Dispose()
+ {
+ if (_isDisposed) return;
+ _isDisposed = true;
+
+ _authClient?.Dispose();
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/sources/McQuery.Net/McQuery.Net.csproj b/sources/McQuery.Net/McQuery.Net.csproj
index 69a6dd0..010e7ba 100644
--- a/sources/McQuery.Net/McQuery.Net.csproj
+++ b/sources/McQuery.Net/McQuery.Net.csproj
@@ -2,6 +2,19 @@
true
+ McQuery.Net
+
+ Library for .Net which implements Minecraft Query protocol. You can use it for getting statuses of a Minecraft server.
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
diff --git a/sources/McQuery.Net/McQueryClient.cs b/sources/McQuery.Net/McQueryClient.cs
new file mode 100644
index 0000000..21746ba
--- /dev/null
+++ b/sources/McQuery.Net/McQueryClient.cs
@@ -0,0 +1,146 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Net;
+using System.Net.Sockets;
+using McQuery.Net.Data;
+using McQuery.Net.Internal.Abstract;
+using McQuery.Net.Internal.Data;
+using McQuery.Net.Internal.Factories;
+using McQuery.Net.Internal.Helpers;
+using McQuery.Net.Internal.Parsers;
+using McQuery.Net.Internal.Providers;
+using Microsoft.Extensions.Logging;
+using Microsoft.VisualStudio.Threading;
+
+namespace McQuery.Net;
+
+///
+/// Implementation of .
+///
+[UsedImplicitly]
+public class McQueryClient : IMcQueryClient, IAuthOnlyClient
+{
+ [SuppressMessage("Usage", "VSTHRD012:Provide JoinableTaskFactory where allowed")]
+ private readonly AsyncReaderWriterLock _locker = new();
+
+ private readonly UdpClient _socket;
+ private readonly IRequestFactory _requestFactory;
+ private readonly ISessionStorage _sessionStorage;
+ private readonly ILogger _logger;
+
+ private const int ResponseTimeoutSeconds = 5; // TODO: into the config
+
+ private static readonly IResponseParser handshakeResponseParser = new HandshakeResponseParser();
+ private static readonly IResponseParser basicStatusResponseParser = new BasicStatusResponseParser();
+ private static readonly IResponseParser fullStatusResponseParser = new FullStatusResponseParser();
+
+ internal McQueryClient(
+ UdpClient socket,
+ IRequestFactory requestFactory,
+ ISessionStorage sessionStorage,
+ ILogger logger
+ )
+ {
+ _requestFactory = requestFactory;
+ _sessionStorage = sessionStorage;
+ _logger = logger;
+ _socket = socket;
+ }
+
+ ///
+ public async Task GetBasicStatusAsync(IPEndPoint serverEndpoint, CancellationToken cancellationToken) =>
+ await SendRequestAsync(
+ serverEndpoint,
+ session => _requestFactory.GetBasicStatusRequest(session),
+ basicStatusResponseParser,
+ cancellationToken);
+
+ ///
+ public async Task GetFullStatusAsync(IPEndPoint serverEndpoint, CancellationToken cancellationToken) =>
+ await SendRequestAsync(
+ serverEndpoint,
+ session => _requestFactory.GetFullStatusRequest(session),
+ fullStatusResponseParser,
+ cancellationToken);
+
+ ///
+ async Task IAuthOnlyClient.HandshakeAsync(
+ IPEndPoint serverEndpoint,
+ SessionId sessionId,
+ CancellationToken cancellationToken
+ )
+ {
+ var packet = _requestFactory.GetHandshakeRequest(sessionId);
+ return await SendRequestAsync(
+ serverEndpoint,
+ packet,
+ handshakeResponseParser,
+ cancellationToken);
+ }
+
+ private async Task SendRequestAsync(
+ IPEndPoint serverEndpoint,
+ Func> packetFactory,
+ IResponseParser responseParser,
+ CancellationToken cancellationToken = default
+ )
+ {
+ var session = await _sessionStorage.GetAsync(serverEndpoint, cancellationToken);
+ var packet = packetFactory(session);
+ return await SendRequestAsync(
+ serverEndpoint,
+ packet,
+ responseParser,
+ cancellationToken);
+ }
+
+ private async Task SendRequestAsync(
+ IPEndPoint serverEndpoint,
+ ReadOnlyMemory packet,
+ IResponseParser responseParser,
+ CancellationToken cancellationToken = default
+ )
+ {
+ _logger.LogDebug(
+ "Sending {PacketLength} bytes to {Endpoint} with content {Content}",
+ packet.Length,
+ serverEndpoint,
+ BitConverter.ToString(packet.ToArray()));
+
+ var response = await ExecuteRequestConcurrentlyAsync();
+
+ _logger.LogDebug(
+ "Received response from server {Endpoint} [{Content}]",
+ serverEndpoint,
+ BitConverter.ToString(response.Buffer));
+
+ var responseData = responseParser.Parse(response.Buffer);
+ _logger.LogDebug(
+ "Parsed response from server {Endpoint} \n{Response}",
+ serverEndpoint,
+ responseData);
+
+ return responseData;
+
+ async Task ExecuteRequestConcurrentlyAsync()
+ {
+ using var timeoutSource = cancellationToken.ToSourceWithTimeout(TimeSpan.FromSeconds(ResponseTimeoutSeconds));
+ await using var _ = await _locker.WriteLockAsync(timeoutSource.Token);
+
+ await _socket.SendAsync(packet, serverEndpoint, cancellationToken).ConfigureAwait(continueOnCapturedContext: false);
+ return await _socket.ReceiveAsync(timeoutSource.Token).ConfigureAwait(continueOnCapturedContext: false);
+ }
+ }
+
+ private bool _isDisposed;
+
+ ///
+ public void Dispose()
+ {
+ if (_isDisposed) return;
+ _isDisposed = true;
+
+ _socket.Dispose();
+ _sessionStorage.Dispose();
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/sources/McQuery.Net/McQueryClientFactory.cs b/sources/McQuery.Net/McQueryClientFactory.cs
new file mode 100644
index 0000000..a5d624e
--- /dev/null
+++ b/sources/McQuery.Net/McQueryClientFactory.cs
@@ -0,0 +1,46 @@
+using System.Net.Sockets;
+using McQuery.Net.Internal.Factories;
+using McQuery.Net.Internal.Providers;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace McQuery.Net;
+
+///
+/// Implementation of .
+///
+[UsedImplicitly]
+public class McQueryClientFactory : IMcQueryClientFactory
+{
+ private readonly ILoggerFactory? _loggerFactory;
+ private readonly Lazy _sessionIdProvider;
+ private readonly Lazy _client;
+
+ ///
+ /// .ctor.
+ ///
+ /// .
+ public McQueryClientFactory(ILoggerFactory? loggerFactory = null)
+ {
+ _loggerFactory = loggerFactory;
+ _sessionIdProvider = new Lazy(() => new SessionIdProvider(), isThreadSafe: true);
+ _client = new Lazy(AcquireClient, isThreadSafe: true);
+ }
+
+ ///
+ public IMcQueryClient Get() => _client.Value;
+
+ private IMcQueryClient AcquireClient()
+ {
+ SessionStorage sessionStorage = new(_sessionIdProvider.Value);
+
+ McQueryClient client = new(
+ new UdpClient(),
+ new RequestFactory(),
+ sessionStorage,
+ _loggerFactory?.CreateLogger() ?? new NullLogger());
+ sessionStorage.Init(client);
+
+ return client;
+ }
+}
diff --git a/sources/McQuery.Net/Services/McQueryService.cs b/sources/McQuery.Net/Services/McQueryService.cs
deleted file mode 100644
index db09f95..0000000
--- a/sources/McQuery.Net/Services/McQueryService.cs
+++ /dev/null
@@ -1,218 +0,0 @@
-using System.Net;
-using McQuery.Net.Data;
-using McQuery.Net.Data.Packages;
-using McQuery.Net.Data.Packages.Responses;
-
-namespace McQuery.Net.Services;
-
-[PublicAPI]
-public class McQueryService : IDisposable
-{
- public McQueryService(
- Random random,
- uint maxTriesBeforeSocketInvalidate,
- int receiveAwaitInterval,
- int retryAwaitShortInterval,
- int retryAwaitLongInterval)
- {
- sessionIdProviderService = new SessionIdProviderService(random);
- ServersTimeoutCounters = new Dictionary();
- udpService = new UdpSendReceiveService(receiveAwaitInterval);
-
- MaxTriesBeforeSocketInvalidate = maxTriesBeforeSocketInvalidate;
- RetryAwaitShortInterval = retryAwaitShortInterval;
- RetryAwaitLongInterval = retryAwaitLongInterval;
- }
-
- public McQueryService(
- uint maxTriesBeforeSocketInvalidate,
- int receiveAwaitInterval,
- int retryAwaitShortInterval,
- int retryAwaitLongInterval)
- : this(new Random(), maxTriesBeforeSocketInvalidate, receiveAwaitInterval, retryAwaitShortInterval, retryAwaitLongInterval)
- {
- }
-
- private readonly SessionIdProviderService sessionIdProviderService;
-
- private readonly UdpSendReceiveService udpService;
-
- private Dictionary ServersTimeoutCounters { get; set; }
-
- public uint MaxTriesBeforeSocketInvalidate { get; set; }
-
- public int RetryAwaitShortInterval { get; set; }
-
- public int RetryAwaitLongInterval { get; set; }
-
- public Server RegistrateServer(IPEndPoint serverEndPoint)
- {
- SessionId sessionId = sessionIdProviderService.GenerateRandomId();
- Server server = new(sessionId, serverEndPoint.Address, serverEndPoint.Port);
- ServersTimeoutCounters.Add(server, 0);
-
- return server;
- }
-
- public void DisposeServer(Server server)
- {
- ServersTimeoutCounters.Remove(server);
- server.Dispose();
- }
-
- private void ResetTimeoutCounter(Server server)
- {
- ServersTimeoutCounters[server] = 0;
- }
-
- private async Task InvalidateChallengeToken(Server server)
- {
- Request request = RequestFormingService.HandshakeRequestPackage(server.SessionId);
- IResponse response;
-
- while (true)
- {
- response = await udpService.SendReceive(server, request);
-
- if (response is TimeoutResponse)
- {
- if (ServersTimeoutCounters[server] > MaxTriesBeforeSocketInvalidate)
- {
- Task delayTask = Task.Delay(RetryAwaitLongInterval);
-
- server.InvalidateSocket();
- ResetTimeoutCounter(server);
-
- await delayTask;
-
- continue;
- }
-
- ServersTimeoutCounters[server]++;
- await Task.Delay(RetryAwaitShortInterval);
-
- continue;
- }
-
- break;
- }
-
- byte[] challengeToken = ResposeParsingService.ParseHandshake((RawResponse)response);
-
- server.ChallengeToken.UpdateToken(challengeToken);
- }
-
- public async Task GetBasicStatusCommon(Server server) => await GetBasicStatus(server);
-
- public async Task GetBasicStatus(Server server)
- {
- if (!server.ChallengeToken.IsFine)
- await InvalidateChallengeToken(server);
-
- IResponse response;
-
- while (true)
- {
- Request request = RequestFormingService.GetBasicStatusRequestPackage(server.SessionId, server.ChallengeToken);
- response = await udpService.SendReceive(server, request);
-
- if (response is TimeoutResponse)
- {
- if (ServersTimeoutCounters[server] > MaxTriesBeforeSocketInvalidate)
- {
- Task delayTask = Task.Delay(RetryAwaitLongInterval);
-
- server.InvalidateSocket();
- Task invalidateTask = InvalidateChallengeToken(server);
- ResetTimeoutCounter(server);
-
- Task.WaitAll(new Task[] { delayTask, invalidateTask });
-
- continue;
- }
-
- ServersTimeoutCounters[server]++;
- await Task.Delay(RetryAwaitShortInterval);
-
- continue;
- }
-
- break;
- }
-
- ServerBasicStateResponse basicStateResponse = ResposeParsingService.ParseBasicState((RawResponse)response);
-
- return basicStateResponse;
- }
-
- public async Task GetFullStatusCommon(Server server) => await GetFullStatus(server);
-
- public async Task GetFullStatus(Server server)
- {
- if (!server.ChallengeToken.IsFine)
- await InvalidateChallengeToken(server);
-
- IResponse response;
-
- while (true)
- {
- Request request = RequestFormingService.GetFullStatusRequestPackage(server.SessionId, server.ChallengeToken);
- response = await udpService.SendReceive(server, request);
-
- if (response is TimeoutResponse)
- {
- if (ServersTimeoutCounters[server] > MaxTriesBeforeSocketInvalidate)
- {
- Task delayTask = Task.Delay(RetryAwaitLongInterval);
-
- server.InvalidateSocket();
- Task invalidateTask = InvalidateChallengeToken(server);
- ResetTimeoutCounter(server);
-
- Task.WaitAll(new Task[] { delayTask, invalidateTask });
-
- continue;
- }
-
- ServersTimeoutCounters[server]++;
- await Task.Delay(RetryAwaitShortInterval);
-
- continue;
- }
-
- break;
- }
-
- ServerFullStateResponse fullStateResponse = ResposeParsingService.ParseFullState((RawResponse)response);
-
- return fullStateResponse;
- }
-
- private bool disposed = false;
-
- public void Dispose()
- {
- Dispose(true);
- GC.SuppressFinalize(this);
- }
-
- public void Dispose(bool disposing)
- {
- if (disposed) return;
-
- if (disposing)
- {
- foreach (KeyValuePair record in ServersTimeoutCounters)
- record.Key.Dispose();
- }
-
- ServersTimeoutCounters.Clear();
-
- disposed = true;
- }
-
- ~McQueryService()
- {
- Dispose(true);
- }
-}
diff --git a/sources/McQuery.Net/Services/RequestFormingService.cs b/sources/McQuery.Net/Services/RequestFormingService.cs
deleted file mode 100644
index 1e9a9f2..0000000
--- a/sources/McQuery.Net/Services/RequestFormingService.cs
+++ /dev/null
@@ -1,75 +0,0 @@
-using McQuery.Net.Data;
-using McQuery.Net.Data.Packages;
-
-namespace McQuery.Net.Services;
-
-///
-/// This class builds Minecraft Query Packages for requests
-/// Wiki: https://wiki.vg/Query
-///
-public static class RequestFormingService
-{
- private static readonly byte[] MagicConst = [0xfe, 0xfd];
-
- private static readonly byte[] ChallengeRequestConst = [0x09];
-
- private static readonly byte[] StatusRequestConst = [0x00];
-
- public static Request HandshakeRequestPackage(SessionId sessionId)
- {
- List data = new(224);
- data.AddRange(MagicConst);
- data.AddRange(ChallengeRequestConst);
- sessionId.WriteTo(data);
-
- Request request = new(data.ToArray());
-
- return request;
- }
-
- public static Request GetBasicStatusRequestPackage(SessionId sessionId, ChallengeToken challengeToken)
- {
- if (challengeToken == null) throw new ChallengeTokenIsNullException();
-
- List data = new(416);
- data.AddRange(MagicConst);
- data.AddRange(StatusRequestConst);
- sessionId.WriteTo(data);
- challengeToken.WriteTo(data);
-
- Request request = new(data.ToArray());
-
- return request;
- }
-
- public static Request GetFullStatusRequestPackage(SessionId sessionId, ChallengeToken challengeToken)
- {
- if (challengeToken == null) throw new ChallengeTokenIsNullException();
-
- List data = new(544);
- data.AddRange(MagicConst);
- data.AddRange(StatusRequestConst);
- sessionId.WriteTo(data);
- challengeToken.WriteTo(data);
- data.AddRange([0x00, 0x00, 0x00, 0x00]); // Padding
-
- Request request = new(data.ToArray());
-
- return request;
- }
-}
-
-public class ChallengeTokenIsNullException : Exception
-{
- public ChallengeTokenIsNullException()
- {
- }
-
- public ChallengeTokenIsNullException(string? message) : base(message)
- {
- }
-
- public ChallengeTokenIsNullException(string? message, Exception? innerException) : base(message, innerException)
- {
- }
-}
diff --git a/sources/McQuery.Net/Services/ResposeParsingService.cs b/sources/McQuery.Net/Services/ResposeParsingService.cs
deleted file mode 100644
index 7a390c7..0000000
--- a/sources/McQuery.Net/Services/ResposeParsingService.cs
+++ /dev/null
@@ -1,159 +0,0 @@
-using System.Buffers;
-using System.Text;
-using McQuery.Net.Data;
-using McQuery.Net.Data.Packages.Responses;
-
-namespace McQuery.Net.Services;
-
-///
-/// This class parses Minecraft Query response packages for getting data from it
-/// Wiki: https://wiki.vg/Query
-///
-public static class ResposeParsingService
-{
- public static byte ParseType(byte[] data) => data[0];
-
- public static SessionId ParseSessionId(ref SequenceReader reader)
- {
- if (reader.UnreadSequence.Length < 4) throw new IncorrectPackageDataException(reader.Sequence.ToArray());
- byte[] sessionIdBytes = new byte[4];
- Span sessionIdSpan = new(sessionIdBytes);
- reader.TryCopyTo(sessionIdSpan);
- reader.Advance(4);
-
- return new SessionId(sessionIdSpan.ToArray());
- }
-
- ///
- /// Parses response package and returns ChallengeToken
- ///
- /// RawResponce package
- /// byte[] array which contains ChallengeToken as big-endian
- public static byte[] ParseHandshake(RawResponse rawResponse)
- {
- byte[] data = (byte[])rawResponse.RawData.Clone();
-
- if (data.Length < 5) throw new IncorrectPackageDataException(data);
- byte[] response = BitConverter.GetBytes(int.Parse(Encoding.ASCII.GetString(data, 5, rawResponse.RawData.Length - 6)));
- if (BitConverter.IsLittleEndian) response = response.Reverse().ToArray();
-
- return response;
- }
-
- public static ServerBasicStateResponse ParseBasicState(RawResponse rawResponse)
- {
- if (rawResponse.RawData.Length <= 5)
- throw new IncorrectPackageDataException(rawResponse.RawData);
-
- SequenceReader reader = new(new ReadOnlySequence(rawResponse.RawData));
- reader.Advance(1); // Skip Type
-
- SessionId sessionId = ParseSessionId(ref reader);
-
- string motd = ReadString(ref reader);
- string gameType = ReadString(ref reader);
- string map = ReadString(ref reader);
- int numPlayers = int.Parse(ReadString(ref reader));
- int maxPlayers = int.Parse(ReadString(ref reader));
-
- if (!reader.TryReadLittleEndian(out short port))
- throw new IncorrectPackageDataException(rawResponse.RawData);
-
- string hostIp = ReadString(ref reader);
-
- ServerBasicStateResponse serverInfo = new(
- rawResponse.ServerUUID,
- sessionId,
- motd,
- gameType,
- map,
- numPlayers,
- maxPlayers,
- port,
- hostIp,
- (byte[])rawResponse.RawData.Clone()
- );
-
- return serverInfo;
- }
-
- private static readonly byte[] constant1 = new byte[] { 0x73, 0x70, 0x6C, 0x69, 0x74, 0x6E, 0x75, 0x6D, 0x00, 0x80, 0x00 };
-
- private static readonly byte[] constant2 = new byte[] { 0x01, 0x70, 0x6C, 0x61, 0x79, 0x65, 0x72, 0x5F, 0x00, 0x00 };
-
- public static ServerFullStateResponse ParseFullState(RawResponse rawResponse)
- {
- if (rawResponse.RawData.Length <= 5)
- throw new IncorrectPackageDataException(rawResponse.RawData);
-
- SequenceReader reader = new(new ReadOnlySequence(rawResponse.RawData));
- reader.Advance(1); // Skip Type
-
- SessionId sessionId = ParseSessionId(ref reader);
-
- if (!reader.IsNext(constant1, true))
- throw new IncorrectPackageDataException(rawResponse.RawData);
-
- Dictionary statusKeyValues = new();
- while (!reader.IsNext(0, true))
- {
- string key = ReadString(ref reader);
- string value = ReadString(ref reader);
- statusKeyValues.Add(key, value);
- }
-
- if (!reader.IsNext(constant2, true)) // Padding: 10 bytes constant
- throw new IncorrectPackageDataException(rawResponse.RawData);
-
- List players = new();
- while (!reader.IsNext(0, true)) players.Add(ReadString(ref reader));
-
- ServerFullStateResponse fullState = new
- (
- rawResponse.ServerUUID,
- sessionId,
- statusKeyValues["hostname"],
- statusKeyValues["gametype"],
- statusKeyValues["game_id"],
- statusKeyValues["version"],
- statusKeyValues["plugins"],
- statusKeyValues["map"],
- int.Parse(statusKeyValues["numplayers"]),
- int.Parse(statusKeyValues["maxplayers"]),
- players.ToArray(),
- hostIp: statusKeyValues["hostip"],
- hostPort: int.Parse(statusKeyValues["hostport"]),
- rawData: (byte[])rawResponse.RawData.Clone()
- );
-
- return fullState;
- }
-
- private static string ReadString(ref SequenceReader reader)
- {
- if (!reader.TryReadTo(out ReadOnlySequence bytes, 0, true))
- throw new IncorrectPackageDataException("Zero byte not found", reader.Sequence.ToArray());
-
- return Encoding.ASCII.GetString(bytes); // а точно ASCII? Может, Utf8?
- }
-}
-
-public class IncorrectPackageDataException : Exception
-{
- public byte[] data { get; }
-
- public IncorrectPackageDataException(byte[] data)
- {
- this.data = data;
- }
-
- public IncorrectPackageDataException(string? message, byte[] data) : base(message)
- {
- this.data = data;
- }
-
- public IncorrectPackageDataException(string? message, Exception? innerException, byte[] data) : base(message, innerException)
- {
- this.data = data;
- }
-}
diff --git a/sources/McQuery.Net/Services/SessionIdProviderService.cs b/sources/McQuery.Net/Services/SessionIdProviderService.cs
deleted file mode 100644
index 60b9732..0000000
--- a/sources/McQuery.Net/Services/SessionIdProviderService.cs
+++ /dev/null
@@ -1,61 +0,0 @@
-using McQuery.Net.Data;
-using McQuery.Net.Utils;
-
-namespace McQuery.Net.Services;
-
-public class SessionIdProviderService
-{
- public SessionIdProviderService(Random random)
- {
- this.random = random;
- reservedIds = [];
- idCounter = new ByteCounter();
- }
-
-
- private readonly List reservedIds;
-
- private readonly Random random;
-
- public SessionId GenerateRandomId()
- {
- byte[] sessionIdData = new byte[4];
- SessionId sessionId;
-
- do
- {
- random.NextBytes(sessionIdData);
- for (int i = 0; i < sessionIdData.Length; ++i) sessionIdData[i] &= 0x0F;
-
- sessionId = new SessionId(sessionIdData);
- } while (IsIdReserved(sessionId));
-
- ReserveId(sessionId);
-
- return sessionId;
- }
-
-
- private readonly ByteCounter idCounter;
-
- public SessionId GetUniqueId()
- {
- byte[] sessionIdData = new byte[4];
- if (!idCounter.GetNext(sessionIdData))
- {
- // find released sessionIds
- }
-
- SessionId sessionId = new(sessionIdData);
- ReserveId(sessionId);
-
- return sessionId;
- }
-
- private void ReserveId(SessionId sessionId)
- {
- reservedIds.Add(sessionId);
- }
-
- public bool IsIdReserved(SessionId sessionId) => reservedIds.IndexOf(sessionId) != -1;
-}
diff --git a/sources/McQuery.Net/Services/UdpSendReceiveService.cs b/sources/McQuery.Net/Services/UdpSendReceiveService.cs
deleted file mode 100644
index 1b8d070..0000000
--- a/sources/McQuery.Net/Services/UdpSendReceiveService.cs
+++ /dev/null
@@ -1,75 +0,0 @@
-using System.Net;
-using System.Net.Sockets;
-using McQuery.Net.Data;
-using McQuery.Net.Data.Packages;
-using McQuery.Net.Data.Packages.Responses;
-
-namespace McQuery.Net.Services;
-
-// todo: add resend N times before returning TimeoutResponse
-// todo: add cancellation token support
-public class UdpSendReceiveService
-{
- public UdpSendReceiveService(int receiveAwaitInterval)
- {
- ReceiveAwaitInterval = receiveAwaitInterval;
- }
-
- public int ReceiveAwaitInterval { get; set; }
-
- public async Task SendReceive(Server server, Request request)
- {
- UdpClient client = server.UdpClient;
-
- IPEndPoint? ipEndPoint = null;
- byte[]? response = null;
-
- await server.UdpClientSemaphoreSlim.WaitAsync();
- await server.UdpClient.SendAsync(request.RawRequestData, request.RawRequestData.Length);
- IAsyncResult responseToken;
-
- try
- {
- responseToken = server.UdpClient.BeginReceive(null, null);
- }
- catch (SocketException)
- {
- server.UdpClientSemaphoreSlim.Release();
-
- return new TimeoutResponse(server.UUID);
- }
-
- responseToken.AsyncWaitHandle.WaitOne(TimeSpan.FromMilliseconds(ReceiveAwaitInterval));
- if (responseToken.IsCompleted)
- {
- try
- {
- response = server.UdpClient.EndReceive(responseToken, ref ipEndPoint);
- }
-
- catch (Exception)
- {
- server.UdpClientSemaphoreSlim.Release();
-
- return new TimeoutResponse(server.UUID);
- }
- }
- else
- {
- server.UdpClientSemaphoreSlim.Release();
-
- return new TimeoutResponse(server.UUID);
- }
-
- if (response == null)
- {
- server.UdpClientSemaphoreSlim.Release();
-
- return new TimeoutResponse(server.UUID);
- }
-
- server.UdpClientSemaphoreSlim.Release();
-
- return new RawResponse(server.UUID, response);
- }
-}
diff --git a/sources/McQuery.Net/Utils/ByteCounter.cs b/sources/McQuery.Net/Utils/ByteCounter.cs
deleted file mode 100644
index ca260e0..0000000
--- a/sources/McQuery.Net/Utils/ByteCounter.cs
+++ /dev/null
@@ -1,35 +0,0 @@
-namespace McQuery.Net.Utils;
-
-internal class ByteCounter
-{
- private readonly byte[] countUnits;
-
- public ByteCounter()
- {
- countUnits = new byte[4];
- Reset();
- }
-
- public bool GetNext(byte[] receiver)
- {
- for (int i = 0; i < countUnits.Length; ++i)
- {
- if (countUnits[i] < 0x0F)
- {
- countUnits[i]++;
- countUnits.CopyTo(receiver, 0);
-
- return true;
- }
-
- countUnits[i] = 0x00;
- }
-
- return false;
- }
-
- public void Reset()
- {
- for (int i = 0; i < countUnits.Length; ++i) countUnits[i] = 0;
- }
-}